aster_forge_external_auth/providers/
oidc.rs1use 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#[derive(Default)]
44pub struct OidcProviderDriver;
45
46impl OidcProviderDriver {
47 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}