1use std::net::SocketAddr;
9
10use axum::extract::{ConnectInfo, State};
11use axum::http::StatusCode;
12use axum::response::IntoResponse;
13use axum::{Json, response::Response};
14use tracing::{info, warn};
15
16use crate::error::AuthError;
17use crate::redirect_uri::is_allowed_redirect_uri;
18use crate::state::AuthState;
19use crate::types::{ClientRegistrationRequest, ClientRegistrationResponse, RegisteredClient};
20use crate::util::{now_unix, oauth_error_response, random_token, remote_ip};
21
22pub async fn register_client(
23 State(state): State<AuthState>,
24 ConnectInfo(addr): ConnectInfo<SocketAddr>,
25 Json(request): Json<ClientRegistrationRequest>,
26) -> Result<Json<ClientRegistrationResponse>, RegistrationError> {
27 state.check_register_rate_limit(remote_ip(addr)).await?;
28 if request.redirect_uris.is_empty() {
29 warn!("oauth register rejected: no redirect URIs provided");
30 return Err(
31 AuthError::Validation("at least one redirect URI is required".to_string()).into(),
32 );
33 }
34 let native_callback_endpoint = crate::metadata::native_callback_endpoint(&state);
35 for redirect_uri in &request.redirect_uris {
36 if redirect_uri != &native_callback_endpoint
37 && !is_allowed_redirect_uri(redirect_uri, &state.config.allowed_client_redirect_uris)
38 {
39 warn!(
40 redirect_uri = %redirect_uri,
41 native_callback_endpoint = %native_callback_endpoint,
42 allowed_patterns = ?state.config.allowed_client_redirect_uris,
43 "oauth register rejected: redirect URI is not in the allowlist, native callback, or loopback set"
44 );
45 return Err(RegistrationError::InvalidRedirectUri(format!(
46 "redirect URI `{redirect_uri}` must target a loopback host, match the native callback endpoint, or match an allowed redirect pattern"
47 )));
48 }
49 }
50
51 let application_type = match request.application_type.as_deref() {
55 None | Some("web") => "web".to_string(),
56 Some("native") => "native".to_string(),
57 Some(other) => {
58 warn!(
59 application_type = %other,
60 "oauth register rejected: unsupported application_type"
61 );
62 return Err(RegistrationError::InvalidClientMetadata(format!(
63 "application_type `{other}` is not supported; use `web` or `native`"
64 )));
65 }
66 };
67
68 let client = RegisteredClient {
69 client_id: random_token(18)?,
70 redirect_uris: request.redirect_uris,
71 created_at: now_unix(),
72 token_endpoint_auth_method: "none".to_string(),
73 jwks: None,
74 };
75 state.store.register_client(client.clone()).await?;
76 info!(
77 client_id = %client.client_id,
78 redirect_uri_count = client.redirect_uris.len(),
79 redirect_uris = ?client.redirect_uris,
80 "oauth client registration accepted"
81 );
82 Ok(Json(ClientRegistrationResponse {
83 client_id: client.client_id,
84 redirect_uris: client.redirect_uris,
85 token_endpoint_auth_method: "none".to_string(),
86 application_type,
87 }))
88}
89
90pub enum RegistrationError {
98 InvalidRedirectUri(String),
100 InvalidClientMetadata(String),
103 Auth(AuthError),
109}
110
111impl From<AuthError> for RegistrationError {
112 fn from(error: AuthError) -> Self {
113 Self::Auth(error)
114 }
115}
116
117impl RegistrationError {
118 fn oauth_error(&self) -> &'static str {
119 match self {
120 Self::InvalidRedirectUri(_) => "invalid_redirect_uri",
121 Self::InvalidClientMetadata(_) => "invalid_client_metadata",
122 Self::Auth(AuthError::RateLimited { .. }) => "temporarily_unavailable",
123 Self::Auth(_) => "invalid_client_metadata",
128 }
129 }
130
131 fn log_kind(&self) -> &'static str {
132 match self {
133 Self::InvalidRedirectUri(_) => "invalid_redirect_uri",
134 Self::InvalidClientMetadata(_) => "invalid_client_metadata",
135 Self::Auth(error) => error.kind(),
136 }
137 }
138
139 fn status(&self) -> StatusCode {
146 match self {
147 Self::InvalidRedirectUri(_) | Self::InvalidClientMetadata(_) => StatusCode::BAD_REQUEST,
148 Self::Auth(AuthError::InvalidGrant(_) | AuthError::InvalidScope(_)) => {
149 StatusCode::BAD_REQUEST
150 }
151 Self::Auth(AuthError::AuthFailed(_) | AuthError::InvalidAccessToken) => {
152 StatusCode::UNAUTHORIZED
153 }
154 Self::Auth(AuthError::Validation(_)) => StatusCode::UNPROCESSABLE_ENTITY,
155 Self::Auth(AuthError::Network(_) | AuthError::Server(_)) => StatusCode::BAD_GATEWAY,
156 Self::Auth(AuthError::RateLimited { .. }) => StatusCode::TOO_MANY_REQUESTS,
157 Self::Auth(
158 AuthError::Config(_)
159 | AuthError::Storage(_)
160 | AuthError::Decode(_)
161 | AuthError::InsecurePermissions { .. },
162 ) => StatusCode::INTERNAL_SERVER_ERROR,
163 }
164 }
165
166 fn description(&self) -> String {
167 match self {
168 Self::InvalidRedirectUri(message) | Self::InvalidClientMetadata(message) => {
169 message.clone()
170 }
171 Self::Auth(error) => error.to_string(),
172 }
173 }
174
175 fn retry_after_ms(&self) -> Option<u64> {
176 match self {
177 Self::Auth(AuthError::RateLimited { retry_after_ms, .. }) => Some(*retry_after_ms),
178 _ => None,
179 }
180 }
181}
182
183impl IntoResponse for RegistrationError {
184 fn into_response(self) -> Response {
185 oauth_error_response(
186 self.status(),
187 self.oauth_error(),
188 self.description(),
189 self.log_kind(),
190 self.retry_after_ms(),
191 )
192 }
193}
194
195pub(crate) fn allowlist_redirect_uris(
209 candidate_redirect_uris: &[String],
210 allowed_patterns: &[String],
211) -> Vec<String> {
212 candidate_redirect_uris
213 .iter()
214 .filter(|uri| is_allowed_redirect_uri(uri, allowed_patterns))
215 .cloned()
216 .collect()
217}
218
219pub(crate) fn allowed_uris_from_cimd_document(
227 document: &crate::cimd::document::ClientMetadataDocument,
228 client_id: &str,
229 client_state_id: &str,
230 allowed_patterns: &[String],
231) -> Result<Vec<String>, AuthError> {
232 let allowed = allowlist_redirect_uris(&document.redirect_uris, allowed_patterns);
233 if allowed.is_empty() {
234 warn!(
235 client_id = %client_id,
236 client_state_id = %client_state_id,
237 "oauth authorize rejected: CIMD document declares no allowlisted redirect_uris"
238 );
239 return Err(AuthError::Validation(
240 "client_id metadata document declares no allowed redirect_uris".to_string(),
241 ));
242 }
243 Ok(allowed)
244}
245
246enum ResolvedClientSource {
262 Cimd(crate::cimd::document::ClientMetadataDocument),
265 Registered(Option<RegisteredClient>),
270}
271
272async fn resolve_client_source(
281 state: &AuthState,
282 client_id: &str,
283 on_cimd_error: impl FnOnce(&crate::cimd::document::CimdError),
284) -> Result<ResolvedClientSource, AuthError> {
285 if crate::cimd::document::is_cimd_client_id(client_id) {
286 let document =
287 crate::cimd::document::fetch_and_validate_client_metadata(&state.cimd_cache, client_id)
288 .await
289 .map_err(|error| {
290 on_cimd_error(&error);
291 AuthError::Validation(
297 "client_id metadata document is invalid or unreachable".to_string(),
298 )
299 })?;
300 return Ok(ResolvedClientSource::Cimd(document));
301 }
302 Ok(ResolvedClientSource::Registered(
303 state.store.find_client(client_id).await?,
304 ))
305}
306
307pub(crate) async fn resolve_client(
316 state: &AuthState,
317 client_id: &str,
318) -> Result<Option<RegisteredClient>, AuthError> {
319 match resolve_client_source(state, client_id, |_| {}).await? {
322 ResolvedClientSource::Cimd(document) => Ok(Some(RegisteredClient {
323 client_id: document.client_id,
324 redirect_uris: document.redirect_uris,
325 created_at: 0,
326 token_endpoint_auth_method: document.token_endpoint_auth_method,
327 jwks: document.jwks,
328 })),
329 ResolvedClientSource::Registered(client) => Ok(client),
330 }
331}
332
333pub(crate) async fn resolve_client_redirect_uris(
345 state: &AuthState,
346 client_id: &str,
347 client_state_id: &str,
348) -> Result<Vec<String>, AuthError> {
349 let source = resolve_client_source(state, client_id, |error| {
350 warn!(
351 client_id = %client_id,
352 client_state_id = %client_state_id,
353 kind = error.kind(),
354 error = %error,
355 "oauth authorize rejected: CIMD document fetch/validation failed"
356 );
357 })
358 .await?;
359
360 match source {
361 ResolvedClientSource::Cimd(document) => allowed_uris_from_cimd_document(
362 &document,
363 client_id,
364 client_state_id,
365 &state.config.allowed_client_redirect_uris,
366 ),
367 ResolvedClientSource::Registered(Some(client)) => Ok(client.redirect_uris),
368 ResolvedClientSource::Registered(None) => {
369 warn!(
370 client_id = %client_id,
371 client_state_id = %client_state_id,
372 "oauth authorize rejected: unknown client_id"
373 );
374 Err(AuthError::InvalidGrant("unknown client_id".to_string()))
375 }
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use std::time::Duration;
382
383 use tempfile::tempdir;
384 use url::Url;
385
386 use super::*;
387 use crate::config::{AuthConfig, AuthMode, GoogleConfig};
388
389 const OPAQUE_CLIENT_ID: &str = "opaque-dcr-client-id";
392 const CIMD_CLIENT_ID: &str = "https://127.0.0.1/client-metadata.json";
396
397 async fn test_state() -> AuthState {
398 let dir = Box::leak(Box::new(tempdir().expect("tempdir")));
399 AuthState::new(AuthConfig {
400 mode: AuthMode::OAuth,
401 public_url: Some(Url::parse("https://lab.example.com").expect("url")),
402 sqlite_path: dir.path().join("auth.db"),
403 key_path: dir.path().join("auth.pem"),
404 bootstrap_secret: None,
405 allowed_client_redirect_uris: Vec::new(),
406 admin_email: "user@example.com".to_string(),
407 google: GoogleConfig {
408 client_id: "client-id".to_string(),
409 client_secret: "client-secret".to_string(),
410 callback_path: "/auth/google/callback".to_string(),
411 scopes: vec![
412 "openid".to_string(),
413 "email".to_string(),
414 "profile".to_string(),
415 ],
416 },
417 access_token_ttl: Duration::from_secs(3600),
418 refresh_token_ttl: Duration::from_secs(3600),
419 auth_code_ttl: Duration::from_secs(300),
420 register_requests_per_minute: 10,
421 authorize_requests_per_minute: 20,
422 max_pending_oauth_states: 1024,
423 default_provider: "google".to_string(),
424 ..AuthConfig::default()
425 })
426 .await
427 .expect("auth state")
428 }
429
430 #[tokio::test]
439 async fn both_resolvers_agree_a_registered_client_resolves() {
440 let state = test_state().await;
441 let registered = RegisteredClient {
442 client_id: OPAQUE_CLIENT_ID.to_string(),
443 redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
444 created_at: 0,
445 token_endpoint_auth_method: "none".to_string(),
446 jwks: None,
447 };
448 state
449 .store
450 .register_client(registered.clone())
451 .await
452 .expect("register client");
453
454 let client = resolve_client(&state, OPAQUE_CLIENT_ID)
455 .await
456 .expect("resolve_client")
457 .expect("registered client resolves at /token");
458 let redirect_uris = resolve_client_redirect_uris(&state, OPAQUE_CLIENT_ID, "state-id")
459 .await
460 .expect("registered client resolves at /authorize");
461
462 assert_eq!(client.client_id, OPAQUE_CLIENT_ID);
463 assert_eq!(client.redirect_uris, registered.redirect_uris);
464 assert_eq!(redirect_uris, registered.redirect_uris);
465 }
466
467 #[tokio::test]
468 async fn both_resolvers_agree_an_unknown_client_does_not_resolve() {
469 let state = test_state().await;
470
471 let token_side = resolve_client(&state, OPAQUE_CLIENT_ID)
474 .await
475 .expect("resolve_client does not error on unknown clients");
476 assert!(token_side.is_none());
477
478 let authorize_side = resolve_client_redirect_uris(&state, OPAQUE_CLIENT_ID, "state-id")
481 .await
482 .expect_err("unknown client_id must fail /authorize");
483 match authorize_side {
484 AuthError::InvalidGrant(message) => assert_eq!(message, "unknown client_id"),
485 other => panic!("expected InvalidGrant, got {other:?}"),
486 }
487 }
488
489 #[tokio::test]
494 async fn both_resolvers_agree_an_unreachable_cimd_client_does_not_resolve() {
495 let state = test_state().await;
496
497 let token_side = resolve_client(&state, CIMD_CLIENT_ID)
498 .await
499 .expect_err("unreachable CIMD client_id must fail /token");
500 let authorize_side = resolve_client_redirect_uris(&state, CIMD_CLIENT_ID, "state-id")
501 .await
502 .expect_err("unreachable CIMD client_id must fail /authorize");
503
504 for error in [token_side, authorize_side] {
505 match error {
506 AuthError::Validation(message) => assert_eq!(
507 message,
508 "client_id metadata document is invalid or unreachable",
509 ),
510 other => panic!("expected Validation, got {other:?}"),
511 }
512 }
513 }
514}