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 use client_assertion::OAuthPrivateKeyJwtOptions;
11
12pub(crate) mod logout;
13
14mod account_cookie;
15
16pub mod encryption;
17
18pub(crate) mod handlers;
19
20pub(crate) mod id_token;
21
22mod providers;
23
24pub(crate) mod state;
25
26mod types;
27use alibi_core::AuthResult;
28use alibi_core::{AuthContext, AuthPlugin, AuthRoute};
29use alibi_core::{AuthRequest, AuthResponse, HttpMethod};
30use async_trait::async_trait;
31pub use endpoint::OAuthRefreshResult;
32pub(crate) use handlers::{
33    OAuthProcessPolicy, OAuthSignInError, create_account_cookie_headers, process_oauth_sign_in,
34};
35pub use handlers::{oauth_callback_path, oauth_disable_sign_up_option, resolve_oauth_account_key};
36pub use id_token::{
37    HttpOAuthJwksSource, OAuthIdTokenClaimsVerifier, OAuthIdTokenConfig, OAuthJwksSelection,
38    OAuthJwksSource, OAuthNonceComparison,
39};
40pub use providers::{
41    AppleOptions, AtlassianOptions, CloudflareOptions, CognitoOptions, DropboxAccessType,
42    DropboxOptions, FacebookOptions, FigmaOptions, GenericOAuthConfig, GenericOAuthError,
43    GenericOAuthMetadata, GenericOAuthResolved, HuggingFaceOptions, KakaoOptions, KickOptions,
44    LineOptions, LinearOptions, LinkedInOptions, MicrosoftOptions, MicrosoftProfilePhotoSize,
45    NaverOptions, NotionOptions, OAuthAccountKey, OAuthAccountKeyContext, OAuthAccountKeyResolver,
46    OAuthAccountSubject, OAuthAuthorizationCodeCallback, OAuthAuthorizationCodeContext,
47    OAuthAuthorizationCodeHandler, OAuthAuthorizationPolicy, OAuthCallbackUserName,
48    OAuthCallbackUserPayload, OAuthClientAssertion, OAuthClientAssertionContext,
49    OAuthClientAssertionGetter, OAuthConfig, OAuthEndSessionConfig, OAuthIdTokenVerifier,
50    OAuthProfileMapper, OAuthProvider, OAuthRefreshContext, OAuthRefreshTokenHandler,
51    OAuthRefreshTokenParams, OAuthRefreshTokenParamsResolver, OAuthScopeEncoding, OAuthScopeOrder,
52    OAuthTokenEndpointAuth, OAuthTokenGrant, OAuthTokenSet, OAuthUserInfo, OAuthUserInfoHandler,
53    OAuthUserInfoRequest, OAuthUserInfoResponse, PayPalEnvironment, PayPalOptions, PaybinOptions,
54    PolarOptions, RailwayOptions, RedditOptions, RobloxOptions, SalesforceEnvironment,
55    SalesforceOptions, SlackOptions, SpotifyOptions, TikTokOptions, TwitchOptions, TwitterOptions,
56    VercelOptions, VkOptions, WeChatLanguage, WeChatOptions, ZoomOptions,
57};
58pub(crate) use state::{
59    CapturedOAuthServerContext, OAuthServerContext, RecoveredOAuthServerContext,
60};
61pub use types::{
62    LinkSocialRequest as OAuthLinkSocialRequest, OAuthCallbackInput, OAuthIdTokenRequest,
63    SocialSignInRequest as OAuthSocialSignInRequest,
64    SocialSignInResponse as OAuthSocialSignInResponse,
65};
66
67pub struct OAuthPlugin {
68    config: OAuthConfig,
69}
70
71impl OAuthPlugin {
72    #[must_use]
73    pub fn new() -> Self {
74        Self {
75            config: OAuthConfig::default(),
76        }
77    }
78
79    #[must_use]
80    pub const fn with_config(config: OAuthConfig) -> Self {
81        Self { config }
82    }
83
84    #[must_use]
85    pub fn add_provider(mut self, name: &str, provider: OAuthProvider) -> Self {
86        _ = self.config.providers.insert(name.to_owned(), provider);
87        self
88    }
89}
90
91impl Default for OAuthPlugin {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97#[async_trait]
98impl<S: alibi_core::AuthSchema> AuthPlugin<S> for OAuthPlugin {
99    route_openapi_metadata!(S);
100
101    fn name(&self) -> &'static str {
102        "oauth"
103    }
104
105    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
106        endpoint::definitions()
107    }
108    fn validate_endpoint(
109        &self,
110        call: &alibi_core::endpoint::EndpointCall,
111        _: &AuthContext<S>,
112    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
113        endpoint::validate(call)
114    }
115    async fn on_endpoint(
116        &self,
117        call: &alibi_core::endpoint::EndpointCall,
118        ctx: &AuthContext<S>,
119    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
120        if call.operation_id() == "handleOAuthCallback" {
121            return self.call_callback_endpoint(call, ctx).await;
122        }
123        if call.operation_id() == "linkSocialAccount" {
124            return self.call_link_endpoint(call, ctx).await;
125        }
126        if call.operation_id() == "socialSignIn" {
127            return self.call_social_endpoint(call, ctx).await;
128        }
129        self.call_account_endpoint(call, ctx).await
130    }
131
132    fn routes(&self) -> Vec<AuthRoute> {
133        vec![
134            AuthRoute::post("/sign-in/social", "social_sign_in"),
135            AuthRoute::get("/callback/{provider}", "oauth_callback")
136                .with_context_path("/callback/:id"),
137            AuthRoute::post("/callback/{provider}", "oauth_callback_post")
138                .with_context_path("/callback/:id"),
139            AuthRoute::post("/link-social", "link_social"),
140            AuthRoute::post("/get-access-token", "get_access_token"),
141            AuthRoute::post("/refresh-token", "refresh_token"),
142            AuthRoute::get("/account-info", "account_info"),
143        ]
144    }
145
146    async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
147        ctx.extensions.insert(self.config.clone());
148        Ok(())
149    }
150
151    fn allowed_media_types(&self, route: &AuthRoute) -> Vec<&'static str> {
152        if route.path == "/callback/{provider}" {
153            vec!["application/x-www-form-urlencoded", "application/json"]
154        } else {
155            vec!["application/json"]
156        }
157    }
158
159    async fn on_request(
160        &self,
161        req: &AuthRequest,
162        ctx: &AuthContext<S>,
163    ) -> AuthResult<Option<AuthResponse>> {
164        match (req.method(), req.path()) {
165            (HttpMethod::Post, "/sign-in/social") => Ok(Some(
166                handlers::handle_social_sign_in(&self.config, req, ctx).await?,
167            )),
168            (HttpMethod::Get | HttpMethod::Post, path) if path_matches_callback(path) => {
169                let provider = extract_provider_from_callback(path);
170                Ok(Some(
171                    handlers::handle_callback(&self.config, &provider, req, ctx).await?,
172                ))
173            }
174            (HttpMethod::Post, "/link-social") => Ok(Some(
175                handlers::handle_link_social(&self.config, req, ctx).await?,
176            )),
177            (HttpMethod::Post, "/get-access-token") => Ok(Some(
178                account::handle_get_access_token(&self.config, req, ctx).await?,
179            )),
180            (HttpMethod::Post, "/refresh-token") => Ok(Some(
181                account::handle_refresh_token(&self.config, req, ctx).await?,
182            )),
183            (HttpMethod::Get, "/account-info") => Ok(Some(
184                account::handle_account_info(&self.config, req, ctx).await?,
185            )),
186            _ => Ok(None),
187        }
188    }
189
190    async fn after_request(
191        &self,
192        req: &AuthRequest,
193        ctx: &AuthContext<S>,
194        mut response: AuthResponse,
195    ) -> AuthResult<AuthResponse> {
196        // Source renews an existing account JWE whenever setCookieCache emits
197        // a session cache, unless this endpoint already owns account issuance.
198        let account_name =
199            alibi_core::utils::cookie_utils::related_cookie_name(&ctx.config, "account_data");
200        let cache_name =
201            alibi_core::utils::cookie_utils::related_cookie_name(&ctx.config, "session_data");
202        let account_chunks = format!("{account_name}.");
203        let cache_chunks = format!("{cache_name}.");
204        let pending: Vec<_> = response
205            .headers
206            .get_all("set-cookie")
207            .filter_map(|raw| cookie::Cookie::parse(raw.clone()).ok())
208            .collect();
209        if !ctx.config.account.store_account_cookie
210            || pending.iter().any(|cookie| {
211                cookie.name() == account_name || cookie.name().starts_with(&account_chunks)
212            })
213            || !pending.iter().any(|cookie| {
214                (cookie.name() == cache_name || cookie.name().starts_with(&cache_chunks))
215                    && !cookie.value().is_empty()
216                    && cookie.max_age().is_none_or(|age| age.whole_seconds() != 0)
217            })
218        {
219            return Ok(response);
220        }
221        let Some((user, _)) = alibi_core::session::cookie_cache::runtime::published_session(req)
222            .or_else(|| req.session_hook_snapshot())
223        else {
224            return Ok(response);
225        };
226        let Ok(Some(account)) = handlers::decode_account_cookie(req, &ctx.config) else {
227            return Ok(response);
228        };
229        let headers = if account.user_id == user.id {
230            handlers::create_account_cookie_headers(&ctx.config, &account, req)?
231        } else {
232            alibi_core::session::cookie_cache::runtime::chunked_cookie_headers(
233                &account_name,
234                "",
235                Some(0.0),
236                &ctx.config,
237                &req.headers,
238                true,
239            )?
240        };
241        for header in headers {
242            response.headers.append("set-cookie", header);
243        }
244        Ok(response)
245    }
246}
247
248impl std::fmt::Debug for OAuthPlugin {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        f.debug_struct("OAuthPlugin").finish_non_exhaustive()
251    }
252}
253
254/// Check if the path matches `/callback/{provider}` (with optional query string).
255fn path_matches_callback(path: &str) -> bool {
256    let path_without_query = path.split('?').next().unwrap_or(path);
257    path_without_query
258        .strip_prefix("/callback/")
259        .is_some_and(|provider| !provider.is_empty() && !provider.contains('/'))
260}
261
262/// Extract the provider name from `/callback/{provider}?...`.
263fn extract_provider_from_callback(path: &str) -> String {
264    let path_without_query = path.split('?').next().unwrap_or(path);
265    path_without_query
266        .strip_prefix("/callback/")
267        .unwrap_or_default()
268        .to_owned()
269}