aster_forge_external_auth/providers/
oidc.rs

1//! Generic `OpenID` Connect provider driver.
2//!
3//! The driver uses OIDC discovery, PKCE, nonce validation, and ID-token verification through the
4//! `openidconnect` crate. Dedicated OIDC providers can reuse its client-building and profile
5//! extraction helpers while applying their own issuer and claim defaults.
6
7use std::borrow::Cow;
8use std::future::Future;
9use std::pin::Pin;
10
11use async_trait::async_trait;
12use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata};
13use openidconnect::{
14    AsyncHttpClient, AuthorizationCode, ClientId, ClientSecret, CsrfToken, HttpRequest,
15    HttpResponse, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
16};
17use openidconnect::{EndpointMaybeSet, EndpointNotSet, EndpointSet};
18
19use crate::outbound_http_user_agent;
20use crate::types::{ExternalAuthProtocol, ExternalAuthProviderKind};
21use crate::{ExternalAuthError, MapExternalAuthErr, Result};
22
23use crate::driver::{
24    ExternalAuthAuthorizationStart, ExternalAuthCallback, ExternalAuthProfile,
25    ExternalAuthProviderConfig, ExternalAuthProviderDescriptor, ExternalAuthProviderDriver,
26    ExternalAuthProviderTestCheck, ExternalAuthProviderTestResult,
27};
28
29const OIDC_ISSUER_MAX_LEN: usize = 512;
30const OIDC_SUBJECT_MAX_LEN: usize = 255;
31const OIDC_SNAPSHOT_MAX_LEN: usize = 255;
32pub(super) const OIDC_RESPONSE_MAX_BYTES: usize = 1024 * 1024;
33
34pub(super) struct OidcHttpClient {
35    client: reqwest::Client,
36}
37
38impl OidcHttpClient {
39    #[cfg(feature = "microsoft")]
40    pub(super) fn reqwest(&self) -> &reqwest::Client {
41        &self.client
42    }
43}
44
45impl<'client> AsyncHttpClient<'client> for OidcHttpClient {
46    type Error = aster_forge_http::BufferedHttpError;
47    type Future = Pin<
48        Box<
49            dyn Future<Output = std::result::Result<HttpResponse, Self::Error>>
50                + Send
51                + Sync
52                + 'client,
53        >,
54    >;
55
56    fn call(&'client self, request: HttpRequest) -> Self::Future {
57        Box::pin(aster_forge_http::execute_reqwest_buffered_limited(
58            &self.client,
59            request,
60            OIDC_RESPONSE_MAX_BYTES,
61        ))
62    }
63}
64pub(super) type OidcClient = CoreClient<
65    EndpointSet,
66    EndpointNotSet,
67    EndpointNotSet,
68    EndpointNotSet,
69    EndpointMaybeSet,
70    EndpointMaybeSet,
71>;
72
73/// Generic `OpenID` Connect provider driver.
74#[derive(Default)]
75pub struct OidcProviderDriver;
76
77impl OidcProviderDriver {
78    /// Creates a generic `OpenID` Connect provider driver.
79    #[must_use]
80    pub fn new() -> Self {
81        Self
82    }
83}
84
85#[async_trait]
86impl ExternalAuthProviderDriver for OidcProviderDriver {
87    fn kind(&self) -> ExternalAuthProviderKind {
88        ExternalAuthProviderKind::Oidc
89    }
90
91    fn descriptor(&self) -> ExternalAuthProviderDescriptor {
92        ExternalAuthProviderDescriptor {
93            kind: ExternalAuthProviderKind::Oidc,
94            protocol: ExternalAuthProtocol::Oidc,
95            display_name: "OpenID Connect",
96            description: "OpenID Connect authorization-code sign-in with discovery, PKCE, nonce and ID token validation.",
97            default_scopes: "openid email profile",
98            issuer_url_required: true,
99            manual_endpoint_configuration_supported: false,
100            authorization_url_required: false,
101            token_url_required: false,
102            userinfo_url_required: false,
103            supports_discovery: true,
104            supports_pkce: true,
105            supports_email_verified_claim: true,
106        }
107    }
108
109    async fn start_authorization(
110        &self,
111        provider: &ExternalAuthProviderConfig,
112        redirect_uri: &str,
113    ) -> Result<ExternalAuthAuthorizationStart> {
114        let client = build_client(provider, redirect_uri).await?;
115        Ok(start_authorization_with_oidc_client(provider, &client))
116    }
117
118    async fn exchange_callback(
119        &self,
120        provider: &ExternalAuthProviderConfig,
121        callback: ExternalAuthCallback,
122    ) -> Result<ExternalAuthProfile> {
123        let nonce = callback
124            .nonce
125            .ok_or_else(|| ExternalAuthError::database_operation("stored OIDC nonce is missing"))?;
126        let pkce_verifier = callback.pkce_verifier.ok_or_else(|| {
127            ExternalAuthError::database_operation("stored OIDC PKCE verifier is missing")
128        })?;
129        let client = build_client(provider, &callback.redirect_uri).await?;
130        let http_client = oidc_http_client(provider)?;
131        let token_request = client
132            .exchange_code(AuthorizationCode::new(callback.code))
133            .map_external_auth_err_ctx(
134                "OIDC provider metadata missing token endpoint",
135                ExternalAuthError::config_error,
136            )?;
137        let token_response = token_request
138            .set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier))
139            .set_redirect_uri(Cow::Owned(
140                RedirectUrl::new(callback.redirect_uri.clone()).map_external_auth_err_ctx(
141                    "invalid stored OIDC redirect URI",
142                    ExternalAuthError::database_operation,
143                )?,
144            ))
145            .request_async(&http_client)
146            .await
147            .map_external_auth_err_ctx(
148                "OIDC token exchange failed",
149                ExternalAuthError::auth_invalid_credentials,
150            )?;
151
152        let id_token = token_response.extra_fields().id_token().ok_or_else(|| {
153            ExternalAuthError::auth_invalid_credentials("OIDC token response missing id_token")
154        })?;
155        let verifier = client.id_token_verifier();
156        let nonce = Nonce::new(nonce);
157        let claims = id_token
158            .claims(&verifier, &nonce)
159            .map_external_auth_err_ctx(
160                "OIDC ID token verification failed",
161                ExternalAuthError::auth_invalid_credentials,
162            )?;
163        let profile = profile_from_id_token(claims)?;
164        if profile.identity_namespace != provider.require_issuer_url()? {
165            return Err(ExternalAuthError::auth_invalid_credentials(
166                "OIDC issuer does not match configured provider",
167            ));
168        }
169        Ok(profile)
170    }
171
172    async fn test_provider(
173        &self,
174        provider: &ExternalAuthProviderConfig,
175    ) -> Result<ExternalAuthProviderTestResult> {
176        let metadata = discover_provider(provider).await?;
177        let token_endpoint = metadata.token_endpoint().ok_or_else(|| {
178            ExternalAuthError::validation_error("OIDC discovery metadata missing token_endpoint")
179        })?;
180        let authorization_endpoint = metadata.authorization_endpoint().as_str().to_string();
181        let token_endpoint = token_endpoint.as_str().to_string();
182        let jwks_key_count = metadata.jwks().keys().len();
183        Ok(ExternalAuthProviderTestResult {
184            provider: self.descriptor().display_name.to_string(),
185            issuer: Some(metadata.issuer().as_str().to_string()),
186            authorization_endpoint: Some(authorization_endpoint),
187            token_endpoint: Some(token_endpoint),
188            userinfo_endpoint: metadata
189                .userinfo_endpoint()
190                .map(|url| url.as_str().to_string()),
191            jwks_key_count: Some(jwks_key_count),
192            checks: vec![
193                ExternalAuthProviderTestCheck {
194                    name: "discovery".to_string(),
195                    success: true,
196                    message: "OIDC discovery metadata was loaded".to_string(),
197                },
198                ExternalAuthProviderTestCheck {
199                    name: "jwks".to_string(),
200                    success: true,
201                    message: format!("JWKS contains {jwks_key_count} key(s)"),
202                },
203            ],
204        })
205    }
206}
207
208pub(super) fn start_authorization_with_oidc_client(
209    provider: &ExternalAuthProviderConfig,
210    client: &OidcClient,
211) -> ExternalAuthAuthorizationStart {
212    let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
213
214    let mut request = client
215        .authorize_url(
216            CoreAuthenticationFlow::AuthorizationCode,
217            CsrfToken::new_random,
218            Nonce::new_random,
219        )
220        .set_pkce_challenge(pkce_challenge);
221
222    for scope in provider.scopes.split_whitespace() {
223        if scope != "openid" {
224            request = request.add_scope(Scope::new(scope.to_string()));
225        }
226    }
227
228    let (authorization_url, csrf_state, nonce) = request.url();
229    ExternalAuthAuthorizationStart {
230        authorization_url: authorization_url.to_string(),
231        state: csrf_state.secret().clone(),
232        nonce: Some(nonce.secret().clone()),
233        pkce_verifier: Some(pkce_verifier.secret().clone()),
234    }
235}
236
237pub(super) fn oidc_http_client(provider: &ExternalAuthProviderConfig) -> Result<OidcHttpClient> {
238    let client = reqwest::ClientBuilder::new()
239        .redirect(reqwest::redirect::Policy::none())
240        .timeout(std::time::Duration::from_secs(15))
241        .user_agent(outbound_http_user_agent(provider))
242        .build()
243        .map_external_auth_err_ctx(
244            "failed to build OIDC HTTP client",
245            ExternalAuthError::internal_error,
246        )?;
247    Ok(OidcHttpClient { client })
248}
249
250pub(super) async fn build_client(
251    provider: &ExternalAuthProviderConfig,
252    redirect_uri: &str,
253) -> Result<OidcClient> {
254    let http_client = oidc_http_client(provider)?;
255    let issuer = IssuerUrl::new(provider.require_issuer_url()?.to_string())
256        .map_external_auth_err_ctx(
257            "invalid OIDC issuer URL",
258            ExternalAuthError::validation_error,
259        )?;
260    let metadata = CoreProviderMetadata::discover_async(issuer, &http_client)
261        .await
262        .map_external_auth_err_ctx("OIDC discovery failed", ExternalAuthError::validation_error)?;
263    let client_secret = provider
264        .client_secret
265        .clone()
266        .filter(|secret| !secret.is_empty())
267        .map(ClientSecret::new);
268    let redirect_uri = RedirectUrl::new(redirect_uri.to_string()).map_external_auth_err_ctx(
269        "invalid OIDC redirect URI",
270        ExternalAuthError::validation_error,
271    )?;
272    Ok(CoreClient::from_provider_metadata(
273        metadata,
274        ClientId::new(provider.client_id.clone()),
275        client_secret,
276    )
277    .set_redirect_uri(redirect_uri))
278}
279
280pub(super) async fn discover_provider(
281    provider: &ExternalAuthProviderConfig,
282) -> Result<CoreProviderMetadata> {
283    let http_client = oidc_http_client(provider)?;
284    let issuer = IssuerUrl::new(provider.require_issuer_url()?.to_string())
285        .map_external_auth_err_ctx(
286            "invalid OIDC issuer URL",
287            ExternalAuthError::validation_error,
288        )?;
289    CoreProviderMetadata::discover_async(issuer, &http_client)
290        .await
291        .map_external_auth_err_ctx("OIDC discovery failed", ExternalAuthError::validation_error)
292}
293
294fn validate_oidc_required_claim(value: &str, field: &str, max_len: usize) -> Result<String> {
295    if value.is_empty() || value.len() > max_len || value.chars().any(char::is_control) {
296        return Err(ExternalAuthError::auth_invalid_credentials(format!(
297            "{field} claim is invalid"
298        )));
299    }
300    Ok(value.to_string())
301}
302
303fn truncate_to_utf8_boundary(value: &str, max_len: usize) -> String {
304    if value.len() <= max_len {
305        return value.to_string();
306    }
307    let mut end = max_len;
308    while !value.is_char_boundary(end) {
309        end -= 1;
310    }
311    value[..end].to_string()
312}
313
314fn normalize_optional_snapshot(value: Option<String>) -> Option<String> {
315    value
316        .map(|value| {
317            value
318                .chars()
319                .filter(|ch| !ch.is_control())
320                .collect::<String>()
321        })
322        .map(|value| value.trim().to_string())
323        .filter(|value| !value.is_empty())
324        .map(|value| truncate_to_utf8_boundary(&value, OIDC_SNAPSHOT_MAX_LEN))
325}
326
327pub(super) fn profile_from_id_token(
328    claims: &openidconnect::core::CoreIdTokenClaims,
329) -> Result<ExternalAuthProfile> {
330    let display_name = normalize_optional_snapshot(
331        claims
332            .name()
333            .and_then(|claim| claim.get(None))
334            .map(|name| name.as_str().to_string()),
335    );
336    let preferred_username = normalize_optional_snapshot(
337        claims
338            .preferred_username()
339            .map(|username| username.as_str().to_string()),
340    );
341    let email = claims
342        .email()
343        .map(|email| email.as_str().trim().to_string())
344        .filter(|email| !email.is_empty());
345    if let Some(email) = email.as_deref() {
346        aster_forge_validation::email::validate_email(email).map_err(|_| {
347            ExternalAuthError::auth_invalid_credentials("OIDC email claim is invalid")
348        })?;
349    }
350
351    Ok(ExternalAuthProfile {
352        identity_namespace: validate_oidc_required_claim(
353            claims.issuer().as_str(),
354            "OIDC issuer",
355            OIDC_ISSUER_MAX_LEN,
356        )?,
357        subject: validate_oidc_required_claim(
358            claims.subject().as_str(),
359            "OIDC subject",
360            OIDC_SUBJECT_MAX_LEN,
361        )?,
362        email,
363        email_verified: claims.email_verified().unwrap_or(false),
364        display_name,
365        preferred_username,
366    })
367}