Skip to main content

alibi_plugins/oauth/
mod.rs

1mod account;
2mod endpoint;
3pub use account::{OAuthAccountApi, OAuthAccountSelection};
4pub(crate) use endpoint::start_social_flow_response;
5pub use types::{
6    AccessTokenResponse as OAuthAccessToken, AccountInfoAccount as OAuthAccountIdentity,
7    AccountInfoResponse as OAuthAccountProfile,
8};
9mod client_assertion;
10pub(crate) use client_assertion::ALGORITHMS as PRIVATE_KEY_JWT_SIGNING_ALGORITHMS;
11pub use client_assertion::OAuthPrivateKeyJwtOptions;
12
13pub(crate) mod logout;
14
15mod account_cookie;
16
17pub mod encryption;
18
19pub(crate) mod handlers;
20
21pub(crate) mod id_token;
22
23mod providers;
24
25pub(crate) mod state;
26
27mod types;
28use alibi_core::AuthResult;
29use alibi_core::{AuthContext, AuthPlugin, AuthRoute};
30use alibi_core::{AuthRequest, AuthResponse, HttpMethod};
31use async_trait::async_trait;
32pub use endpoint::OAuthRefreshResult;
33pub(crate) use handlers::{
34    OAuthProcessPolicy, OAuthSignInError, create_account_cookie_headers, process_oauth_sign_in,
35};
36pub use handlers::{oauth_callback_path, oauth_disable_sign_up_option, resolve_oauth_account_key};
37pub use id_token::{
38    HttpOAuthJwksSource, OAuthIdTokenClaimsVerifier, OAuthIdTokenConfig, OAuthJwksSelection,
39    OAuthJwksSource, OAuthNonceComparison,
40};
41pub use providers::{
42    AppleOptions, AtlassianOptions, CloudflareOptions, CognitoOptions, DropboxAccessType,
43    DropboxOptions, FacebookOptions, FigmaOptions, GenericOAuthConfig, GenericOAuthError,
44    GenericOAuthMetadata, GenericOAuthResolved, HuggingFaceOptions, KakaoOptions, KickOptions,
45    LineOptions, LinearOptions, LinkedInOptions, MicrosoftOptions, MicrosoftProfilePhotoSize,
46    NaverOptions, NotionOptions, OAuthAccountKey, OAuthAccountKeyContext, OAuthAccountKeyResolver,
47    OAuthAccountSubject, OAuthAuthorizationCodeCallback, OAuthAuthorizationCodeContext,
48    OAuthAuthorizationCodeHandler, OAuthAuthorizationPolicy, OAuthCallbackUserName,
49    OAuthCallbackUserPayload, OAuthClientAssertion, OAuthClientAssertionContext,
50    OAuthClientAssertionGetter, OAuthConfig, OAuthEndSessionConfig, OAuthIdTokenVerifier,
51    OAuthProfileMapper, OAuthProvider, OAuthRefreshContext, OAuthRefreshTokenHandler,
52    OAuthRefreshTokenParams, OAuthRefreshTokenParamsResolver, OAuthScopeEncoding, OAuthScopeOrder,
53    OAuthTokenEndpointAuth, OAuthTokenGrant, OAuthTokenSet, OAuthUserInfo, OAuthUserInfoHandler,
54    OAuthUserInfoRequest, OAuthUserInfoResponse, PayPalEnvironment, PayPalOptions, PaybinOptions,
55    PolarOptions, RailwayOptions, RedditOptions, RobloxOptions, SalesforceEnvironment,
56    SalesforceOptions, SlackOptions, SpotifyOptions, TikTokOptions, TwitchOptions, TwitterOptions,
57    VercelOptions, VkOptions, WeChatLanguage, WeChatOptions, ZoomOptions,
58};
59pub(crate) use state::{
60    CapturedOAuthServerContext, OAuthServerContext, RecoveredOAuthServerContext,
61};
62pub use types::{
63    LinkSocialRequest as OAuthLinkSocialRequest, OAuthCallbackInput, OAuthIdTokenRequest,
64    SocialSignInRequest as OAuthSocialSignInRequest,
65    SocialSignInResponse as OAuthSocialSignInResponse,
66};
67
68pub struct OAuthPlugin {
69    config: OAuthConfig,
70}
71
72impl OAuthPlugin {
73    #[must_use]
74    pub fn new() -> Self {
75        Self {
76            config: OAuthConfig::default(),
77        }
78    }
79
80    #[must_use]
81    pub const fn with_config(config: OAuthConfig) -> Self {
82        Self { config }
83    }
84
85    #[must_use]
86    pub fn add_provider(mut self, name: &str, provider: OAuthProvider) -> Self {
87        _ = self.config.providers.insert(name.to_owned(), provider);
88        self
89    }
90
91    /// Resolve a generic provider's discovery now and register it. When
92    /// discovery leaves it unusable, the provider is skipped and discovery is
93    /// retried by the next request that names it, until it succeeds.
94    /// # Errors
95    /// Returns a configuration error that discovery cannot fix.
96    pub async fn add_generic_provider(
97        mut self,
98        name: &str,
99        config: GenericOAuthConfig,
100    ) -> Result<Self, GenericOAuthError> {
101        if let Some(resolved) = config.clone().resolve().await? {
102            return Ok(self.add_provider(name, resolved.provider));
103        }
104        _ = std::sync::Arc::make_mut(&mut self.config.deferred)
105            .insert(name.to_owned(), providers::DeferredDiscovery::new(config));
106        Ok(self)
107    }
108}
109
110/// The provider an endpoint call names, read as the generic plugin's retry
111/// matcher does: `body.provider`, `body.providerId`, `query.provider`, then the
112/// path's `id`.
113fn requested_provider(call: &alibi_core::endpoint::EndpointCall) -> Option<&str> {
114    use alibi_core::utils::json::JsValue;
115    fn field<'a>(input: Option<&'a JsValue>, key: &str) -> Option<&'a JsValue> {
116        input
117            .and_then(|input| input.get(key))
118            .filter(|value| !matches!(value, JsValue::Null))
119    }
120    field(call.body(), "provider")
121        .or_else(|| field(call.body(), "providerId"))
122        .or_else(|| field(call.query(), "provider"))
123        .or_else(|| field(call.params(), "id"))
124        .and_then(JsValue::as_str)
125}
126
127impl Default for OAuthPlugin {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133#[async_trait]
134impl<S: alibi_core::AuthSchema> AuthPlugin<S> for OAuthPlugin {
135    route_openapi_metadata!(S);
136
137    fn name(&self) -> &'static str {
138        "oauth"
139    }
140
141    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
142        endpoint::definitions()
143    }
144    fn validate_endpoint(
145        &self,
146        call: &alibi_core::endpoint::EndpointCall,
147        _: &AuthContext<S>,
148    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
149        endpoint::validate(call)
150    }
151    async fn on_endpoint(
152        &self,
153        call: &alibi_core::endpoint::EndpointCall,
154        ctx: &AuthContext<S>,
155    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
156        if call.operation_id() == "handleOAuthCallback" {
157            return self.call_callback_endpoint(call, ctx).await;
158        }
159        if call.operation_id() == "linkSocialAccount" {
160            return self.call_link_endpoint(call, ctx).await;
161        }
162        if call.operation_id() == "socialSignIn" {
163            return self.call_social_endpoint(call, ctx).await;
164        }
165        self.call_account_endpoint(call, ctx).await
166    }
167
168    fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> {
169        vec![self]
170    }
171    fn routes(&self) -> Vec<AuthRoute> {
172        vec![
173            AuthRoute::post("/sign-in/social", "social_sign_in"),
174            AuthRoute::get("/callback/{provider}", "oauth_callback")
175                .with_context_path("/callback/:id"),
176            AuthRoute::post("/callback/{provider}", "oauth_callback_post")
177                .with_context_path("/callback/:id"),
178            AuthRoute::post("/link-social", "link_social"),
179            AuthRoute::post("/get-access-token", "get_access_token"),
180            AuthRoute::post("/refresh-token", "refresh_token"),
181            AuthRoute::get("/account-info", "account_info"),
182        ]
183    }
184
185    async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
186        ctx.extensions.insert(self.config.clone());
187        Ok(())
188    }
189
190    fn allowed_media_types(&self, route: &AuthRoute) -> Vec<&'static str> {
191        if route.path == "/callback/{provider}" {
192            vec!["application/x-www-form-urlencoded", "application/json"]
193        } else {
194            vec!["application/json"]
195        }
196    }
197
198    async fn on_request(
199        &self,
200        req: &AuthRequest,
201        ctx: &AuthContext<S>,
202    ) -> AuthResult<Option<AuthResponse>> {
203        match (req.method(), req.path()) {
204            (HttpMethod::Post, "/sign-in/social") => Ok(Some(
205                handlers::handle_social_sign_in(&self.config, req, ctx).await?,
206            )),
207            (HttpMethod::Get | HttpMethod::Post, path) if path_matches_callback(path) => {
208                let provider = extract_provider_from_callback(path);
209                Ok(Some(
210                    handlers::handle_callback(&self.config, &provider, req, ctx).await?,
211                ))
212            }
213            (HttpMethod::Post, "/link-social") => Ok(Some(
214                handlers::handle_link_social(&self.config, req, ctx).await?,
215            )),
216            (HttpMethod::Post, "/get-access-token") => Ok(Some(
217                account::handle_get_access_token(&self.config, req, ctx).await?,
218            )),
219            (HttpMethod::Post, "/refresh-token") => Ok(Some(
220                account::handle_refresh_token(&self.config, req, ctx).await?,
221            )),
222            (HttpMethod::Get, "/account-info") => Ok(Some(
223                account::handle_account_info(&self.config, req, ctx).await?,
224            )),
225            _ => Ok(None),
226        }
227    }
228
229    async fn after_request(
230        &self,
231        req: &AuthRequest,
232        ctx: &AuthContext<S>,
233        mut response: AuthResponse,
234    ) -> AuthResult<AuthResponse> {
235        for header in Self::cache_account_headers(req, ctx, &response.headers)? {
236            response.headers.append("set-cookie", header);
237        }
238        Ok(response)
239    }
240}
241
242impl std::fmt::Debug for OAuthPlugin {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.debug_struct("OAuthPlugin").finish_non_exhaustive()
245    }
246}
247
248/// Check if the path matches `/callback/{provider}` (with optional query string).
249fn path_matches_callback(path: &str) -> bool {
250    let path_without_query = path.split('?').next().unwrap_or(path);
251    path_without_query
252        .strip_prefix("/callback/")
253        .is_some_and(|provider| !provider.is_empty() && !provider.contains('/'))
254}
255
256/// Extract the provider name from `/callback/{provider}?...`.
257fn extract_provider_from_callback(path: &str) -> String {
258    let path_without_query = path.split('?').next().unwrap_or(path);
259    path_without_query
260        .strip_prefix("/callback/")
261        .unwrap_or_default()
262        .to_owned()
263}
264
265impl OAuthPlugin {
266    fn cache_account_headers<S: alibi_core::AuthSchema>(
267        req: &impl alibi_core::session::SessionRequest,
268        ctx: &AuthContext<S>,
269        headers: &alibi_core::Headers,
270    ) -> AuthResult<Vec<String>> {
271        // Source renews an existing account JWE whenever setCookieCache emits
272        // a session cache, unless this endpoint already owns account issuance.
273        let account_name =
274            alibi_core::utils::cookie_utils::related_cookie_name(&ctx.config, "account_data");
275        let cache_name =
276            alibi_core::utils::cookie_utils::related_cookie_name(&ctx.config, "session_data");
277        let account_chunks = format!("{account_name}.");
278        let cache_chunks = format!("{cache_name}.");
279        let pending: Vec<_> = headers
280            .get_all("set-cookie")
281            .filter_map(|raw| cookie::Cookie::parse(raw.clone()).ok())
282            .collect();
283        if !ctx.config.account.store_account_cookie
284            || pending.iter().any(|cookie| {
285                cookie.name() == account_name || cookie.name().starts_with(&account_chunks)
286            })
287            || !pending.iter().any(|cookie| {
288                (cookie.name() == cache_name || cookie.name().starts_with(&cache_chunks))
289                    && !cookie.value().is_empty()
290                    && cookie.max_age().is_none_or(|age| age.whole_seconds() != 0)
291            })
292        {
293            return Ok(Vec::new());
294        }
295        let Some((user, _)) = alibi_core::session::cookie_cache::runtime::published_session(req)
296            .or_else(|| {
297                req.endpoint_call()
298                    .and_then(alibi_core::endpoint::EndpointCall::session_hook_snapshot)
299            })
300            .or_else(|| {
301                req.physical_request()
302                    .and_then(AuthRequest::session_hook_snapshot)
303            })
304        else {
305            return Ok(Vec::new());
306        };
307        let Ok(Some(account)) = handlers::decode_account_cookie(req, &ctx.config) else {
308            return Ok(Vec::new());
309        };
310        let headers = if account.user_id == user.id {
311            handlers::create_account_cookie_headers(&ctx.config, &account, req)?
312        } else {
313            alibi_core::session::cookie_cache::runtime::chunked_cookie_headers(
314                &account_name,
315                "",
316                Some(0.0),
317                &ctx.config,
318                req.session_headers(),
319                true,
320            )?
321        };
322        Ok(headers)
323    }
324}
325
326#[async_trait]
327impl<S: alibi_core::AuthSchema> alibi_core::endpoint::EndpointHook<S> for OAuthPlugin {
328    fn matches_before(
329        &self,
330        call: &alibi_core::endpoint::EndpointCall,
331        _ctx: &AuthContext<S>,
332    ) -> AuthResult<bool> {
333        Ok(requested_provider(call).is_some_and(|name| self.config.awaits_discovery(name)))
334    }
335    async fn before(
336        &self,
337        call: &alibi_core::endpoint::EndpointCall,
338        _ctx: &AuthContext<S>,
339    ) -> AuthResult<Option<alibi_core::endpoint::BeforeEndpointAction>> {
340        if let Some(name) = requested_provider(call) {
341            self.config.retry_discovery(name).await;
342        }
343        Ok(None)
344    }
345    async fn after(
346        &self,
347        call: &alibi_core::endpoint::EndpointCall,
348        ctx: &AuthContext<S>,
349        mut response: alibi_core::endpoint::EndpointResponse,
350    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
351        for header in Self::cache_account_headers(call, ctx, response.headers())? {
352            response = response.with_header("set-cookie", header);
353        }
354        Ok(response)
355    }
356}