Skip to main content

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}