Skip to main content

alibi_api/plugins/oauth/
mod.rs

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