Skip to main content

soma_auth/
oauth_provider.rs

1use async_trait::async_trait;
2use reqwest::Url;
3use serde::{Deserialize, Serialize};
4
5use crate::error::AuthError;
6
7/// Parameters for building an upstream provider's `/authorize`-equivalent
8/// redirect URL. `AuthorizeUrlRequest` was originally Google-specific
9/// (`google::AuthorizeUrlRequest`); it moved here unchanged when the
10/// `OAuthProvider` trait was introduced, since every provider needs the same
11/// shape.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct AuthorizeUrlRequest {
14    pub state: String,
15    pub code_challenge: String,
16    pub code_challenge_method: String,
17    /// Force the upstream's full consent screen even if the user already
18    /// granted these scopes. Needed the first time (to guarantee a refresh
19    /// token comes back for providers that support one), but forcing it on
20    /// every retry adds a slow, interactive round trip that impatient MCP
21    /// clients can time out on before the human finishes clicking through it.
22    ///
23    /// This field is honestly OIDC/Google-shaped, not provider-neutral, and
24    /// that's surfaced here rather than hidden: Google's `prompt=consent` is
25    /// documented, verified behavior for guaranteeing a refresh token on
26    /// re-authorization. Authelia's need for the same treatment is plausible
27    /// (same `prompt` parameter, same OIDC family) but unverified against a
28    /// real Authelia instance by this plan — treat it as inherited-but-not-
29    /// proven. GitHub has no documented `prompt` parameter and no consent-
30    /// gated refresh-token semantics at all (OAuth Apps never issue refresh
31    /// tokens, full stop); `GitHubProvider::authorize_url` still appends
32    /// `prompt=consent` when this is `true` purely because GitHub silently
33    /// ignores unrecognized query params — it's dead weight, not a bug, but
34    /// don't read "GitHub honors force_consent" into that.
35    pub force_consent: bool,
36}
37
38/// Normalized result of a successful upstream code exchange or refresh,
39/// common to every [`OAuthProvider`] implementation.
40///
41/// `id_token` is `Some` for OIDC-shaped providers (Google, Authelia) and
42/// `None` for plain-OAuth2 providers with no ID token (GitHub).
43///
44/// `access_token`/`refresh_token`/`id_token` are `#[serde(skip_serializing)]`
45/// as defense-in-depth: nothing in this plan serializes a whole
46/// `ProviderExchange` to a client response or log line today (every call
47/// site destructures individual non-secret fields), but nothing about the
48/// type's shape should make that mistake easy for a future edit to make
49/// silently — nothing in this crate's existing secret-handling discipline
50/// (`fingerprint()`-before-log everywhere else) relies on "don't accidentally
51/// serialize the whole struct" being enforced only by convention.
52///
53/// `Debug` is hand-rolled (not derived) to match: it redacts `access_token`,
54/// `refresh_token`, and `id_token` the same way `{:?}` printing must not leak
55/// secrets — see `GoogleConfig`/`AutheliaConfig`/`GitHubConfig` and the
56/// per-provider `OAuthProvider` impls for the established pattern in this
57/// crate.
58#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ProviderExchange {
60    pub subject: String,
61    pub email: Option<String>,
62    pub email_verified: Option<bool>,
63    #[serde(skip_serializing)]
64    pub access_token: String,
65    #[serde(skip_serializing)]
66    pub refresh_token: Option<String>,
67    pub expires_in: Option<u64>,
68    #[serde(skip_serializing)]
69    pub id_token: Option<String>,
70}
71
72impl std::fmt::Debug for ProviderExchange {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("ProviderExchange")
75            .field("subject", &self.subject)
76            .field("email", &self.email)
77            .field("email_verified", &self.email_verified)
78            .field("access_token", &"<redacted>")
79            .field(
80                "refresh_token",
81                &self.refresh_token.as_ref().map(|_| "<redacted>"),
82            )
83            .field("expires_in", &self.expires_in)
84            .field("id_token", &self.id_token.as_ref().map(|_| "<redacted>"))
85            .finish()
86    }
87}
88
89/// An upstream identity provider soma-auth can redirect a user to for login.
90///
91/// Implementations: [`crate::google::GoogleProvider`],
92/// [`crate::authelia::AutheliaProvider`], [`crate::github::GitHubProvider`].
93/// `AuthState.providers` holds a `provider_id() -> Arc<dyn OAuthProvider>`
94/// map so a deployment can enable more than one simultaneously.
95#[async_trait]
96pub trait OAuthProvider: Send + Sync + std::fmt::Debug {
97    /// Stable identifier used as the `providers` map key, the `provider`
98    /// column value persisted in SQLite, and the subject-namespace prefix.
99    /// One of `"google"`, `"authelia"`, `"github"`.
100    fn provider_id(&self) -> &'static str;
101
102    /// The absolute path (no scheme/host) this provider's registered
103    /// `redirect_uri` resolves to, e.g. `/auth/google/callback`. Used by
104    /// `routes::router` to mount one callback route per configured provider.
105    fn callback_path(&self) -> &str;
106
107    fn authorize_url(&self, request: &AuthorizeUrlRequest) -> Result<Url, AuthError>;
108
109    async fn exchange_code(
110        &self,
111        code: &str,
112        code_verifier: &str,
113    ) -> Result<ProviderExchange, AuthError>;
114
115    async fn refresh(&self, refresh_token: &str) -> Result<ProviderExchange, AuthError>;
116}
117
118/// Namespace a raw upstream subject by provider so two different IdPs
119/// sharing one SQLite DB cannot collide on the same `subject` value.
120///
121/// Google is deliberately exempted (returns `raw_subject` unchanged): its
122/// subject format predates multi-provider support, and already-issued
123/// sessions/refresh tokens in production DBs have the bare, unprefixed
124/// format. Changing it would silently invalidate every existing Google
125/// session on upgrade. Authelia and GitHub are new — there is no existing
126/// data to break, so they get the safer namespaced form from day one.
127///
128/// Only called from `authorize.rs`/`token.rs`, both gated behind
129/// `http-axum` — a build of this crate with that feature off (and
130/// `--all-targets` but not `--tests`) has no production caller, hence the
131/// conditional `allow`. The two unit tests below exercise this function
132/// unconditionally regardless of `http-axum`.
133#[cfg_attr(not(feature = "http-axum"), allow(dead_code))]
134pub(crate) fn namespaced_subject(provider_id: &str, raw_subject: &str) -> String {
135    if provider_id == "google" {
136        raw_subject.to_string()
137    } else {
138        format!("{provider_id}:{raw_subject}")
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::namespaced_subject;
145
146    #[test]
147    fn google_subject_is_not_namespaced() {
148        assert_eq!(namespaced_subject("google", "108123456"), "108123456");
149    }
150
151    #[test]
152    fn non_google_subjects_are_namespaced() {
153        assert_eq!(namespaced_subject("github", "9182310"), "github:9182310");
154        assert_eq!(namespaced_subject("authelia", "alice"), "authelia:alice");
155    }
156}