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}