tablo_core/auth/oidc/linker.rs
1//! [`ProviderIdentity`], who a provider says signed in, and the [`IdentityLinker`] an app turns
2//! it into its user with.
3
4use std::{future::Future, sync::Arc};
5
6use topcoat::context::Cx;
7
8use super::super::{PanelUser, infrastructure_failure};
9use crate::BoxFuture;
10
11/// Who an OpenID Connect provider says signed in, once the panel has checked its ID token: the
12/// token came from the provider's token endpoint, names the panel's client, has not expired and
13/// carries the nonce this sign-in sent.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub struct ProviderIdentity {
17 /// The provider's key, such as `google`.
18 pub provider: String,
19 /// The provider's issuer, such as `https://accounts.google.com`.
20 pub issuer: String,
21 /// The provider's id for the account (`sub`): with the issuer, the one stable name of the
22 /// account. An email address can change hands; this cannot.
23 pub subject: String,
24 /// The account's email address, as the provider has it.
25 pub email: Option<String>,
26 /// Whether the provider says the account proved it reads `email`.
27 pub email_verified: bool,
28 /// The account's display name.
29 pub name: Option<String>,
30 /// Every claim of the ID token, such as Google's `hd`, the Workspace domain of the account:
31 /// what a linker reads that the fields above do not carry.
32 pub claims: serde_json::Map<String, serde_json::Value>,
33}
34
35impl ProviderIdentity {
36 /// The account `subject` at the provider keyed `provider` whose issuer is `issuer`, with no
37 /// address, name or other claim: for a test of an [`IdentityLinker`], which adds them with
38 /// [`with_email`](Self::with_email), [`with_name`](Self::with_name) and
39 /// [`with_claim`](Self::with_claim).
40 #[must_use]
41 pub fn new(
42 provider: impl Into<String>,
43 issuer: impl Into<String>,
44 subject: impl Into<String>,
45 ) -> Self {
46 Self {
47 provider: provider.into(),
48 issuer: issuer.into(),
49 subject: subject.into(),
50 email: None,
51 email_verified: false,
52 name: None,
53 claims: serde_json::Map::new(),
54 }
55 }
56
57 /// This identity holding `email`, which the provider `verified` or not.
58 #[must_use]
59 pub fn with_email(mut self, email: impl Into<String>, verified: bool) -> Self {
60 self.email = Some(email.into());
61 self.email_verified = verified;
62 self
63 }
64
65 /// This identity named `name`.
66 #[must_use]
67 pub fn with_name(mut self, name: impl Into<String>) -> Self {
68 self.name = Some(name.into());
69 self
70 }
71
72 /// This identity carrying the claim `name` with `value`.
73 #[must_use]
74 pub fn with_claim(
75 mut self,
76 name: impl Into<String>,
77 value: impl Into<serde_json::Value>,
78 ) -> Self {
79 self.claims.insert(name.into(), value.into());
80 self
81 }
82}
83
84/// How a panel turns a [`ProviderIdentity`] into one of the app's users, installed with
85/// [`Auth::oidc`](crate::Auth::oidc) through an [`Oidc`](super::Oidc).
86///
87/// The app stores which of its users each identity is, as a row keyed by
88/// [`issuer`](ProviderIdentity::issuer) and [`subject`](ProviderIdentity::subject). A sign-in
89/// whose identity no row names may link it to an existing account, or create one, as the app
90/// decides; linking by email alone hands that account to whoever controls the address at the
91/// provider, so link only an address the provider [verified](ProviderIdentity::email_verified).
92///
93/// ```rust
94/// # use tablo_core::{IdentityLinker, PanelUser, ProviderIdentity};
95/// # use topcoat::context::Cx;
96/// # #[derive(Debug, Clone, toasty::Model)]
97/// # struct Staff {
98/// # #[key] #[auto] id: uuid::Uuid,
99/// # #[unique] email: String,
100/// # name: String,
101/// # }
102/// # impl PanelUser for Staff {
103/// # fn user_id(&self) -> String { self.id.to_string() }
104/// # fn display_name(&self) -> &str { &self.name }
105/// # fn can_access_panel(&self, _panel: &str) -> bool { true }
106/// # }
107/// #[derive(Debug, Clone, toasty::Model)]
108/// struct StaffIdentity {
109/// #[key]
110/// #[auto]
111/// id: uuid::Uuid,
112/// #[index]
113/// staff_id: uuid::Uuid,
114/// issuer: String,
115/// subject: String,
116/// }
117///
118/// struct StaffLinker;
119///
120/// impl IdentityLinker for StaffLinker {
121/// type User = Staff;
122///
123/// async fn sign_in(
124/// &self,
125/// _cx: &Cx,
126/// identity: &ProviderIdentity,
127/// ex: &mut dyn toasty::Executor,
128/// ) -> topcoat::Result<Option<Staff>> {
129/// let linked = StaffIdentity::filter(
130/// StaffIdentity::fields()
131/// .issuer()
132/// .eq(identity.issuer.clone())
133/// .and(
134/// StaffIdentity::fields()
135/// .subject()
136/// .eq(identity.subject.clone()),
137/// ),
138/// )
139/// .first()
140/// .exec(&mut *ex)
141/// .await?;
142/// if let Some(linked) = linked {
143/// return Ok(Staff::filter_by_id(linked.staff_id)
144/// .first()
145/// .exec(ex)
146/// .await?);
147/// }
148/// // Link an existing account only by an address the provider verified.
149/// let (Some(email), true) = (&identity.email, identity.email_verified) else {
150/// return Ok(None);
151/// };
152/// let Some(staff) = Staff::filter_by_email(email).first().exec(&mut *ex).await? else {
153/// return Ok(None);
154/// };
155/// toasty::create!(StaffIdentity {
156/// staff_id: staff.id,
157/// issuer: identity.issuer.clone(),
158/// subject: identity.subject.clone(),
159/// })
160/// .exec(ex)
161/// .await?;
162/// Ok(Some(staff))
163/// }
164/// }
165/// ```
166pub trait IdentityLinker: Send + Sync + 'static {
167 /// The account an identity signs in as: the type the panel's
168 /// [`Authenticator`](crate::auth::Authenticator) loads, which `Auth<U>` checks.
169 type User: PanelUser;
170
171 /// The user `identity` signs in as, linking or creating one as the app decides, through the
172 /// sign-in's transaction `ex`, which commits when it returns `Ok`. `None` refuses the sign-in,
173 /// which reads as a failed one. A user who may not
174 /// [access the panel](PanelUser::can_access_panel) is refused too.
175 fn sign_in(
176 &self,
177 cx: &Cx,
178 identity: &ProviderIdentity,
179 ex: &mut dyn toasty::Executor,
180 ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
181}
182
183/// [`IdentityLinker`] with its user type erased, as the panel keeps it.
184pub(super) trait DynLinker: Send + Sync {
185 fn sign_in<'a>(
186 &'a self,
187 cx: &'a Cx,
188 identity: &'a ProviderIdentity,
189 ) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>>;
190}
191
192impl<L: IdentityLinker> DynLinker for L {
193 fn sign_in<'a>(
194 &'a self,
195 cx: &'a Cx,
196 identity: &'a ProviderIdentity,
197 ) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>> {
198 Box::pin(async move {
199 let mut db = crate::db::db(cx)?;
200 let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
201 let user = IdentityLinker::sign_in(self, cx, identity, &mut tx).await?;
202 tx.commit().await.map_err(infrastructure_failure)?;
203 Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
204 })
205 }
206}