Skip to main content

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