aster_forge_external_auth/providers/
oidc.rs1use 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#[derive(Default)]
75pub struct OidcProviderDriver;
76
77impl OidcProviderDriver {
78 #[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}