Skip to main content

soma_auth/
registration.rs

1//! Client registration and redirect-URI resolution: RFC 7591 Dynamic Client
2//! Registration (`POST /register`) and the redirect_uri trust boundary
3//! shared by DCR-registered clients and CIMD `client_id`s (see
4//! [`crate::cimd`]). Split out of `authorize.rs` to keep that module under
5//! the repo's file-size contract — `authorize()` itself still lives there
6//! and calls `resolve_client_redirect_uris` from here.
7
8use 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    // RFC 7591 / OIDC application_type. Accept the two registered values and
52    // default to "web" when omitted; reject anything else so misconfigured
53    // clients fail loudly rather than silently registering an unknown type.
54    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
90/// RFC 7591 §3.2.2 requires `/register` errors to be reported as HTTP 400
91/// with a `{"error": ..., "error_description": ...}` body using one of the
92/// RFC's defined error codes — unlike the generic `AuthError` ->
93/// `IntoResponse` impl in `error.rs`, which returns 422 with a
94/// `{"kind", "message"}` body. This is `register_client`'s dedicated error
95/// type, mirroring `TokenEndpointError` in `token.rs` for the `/token`
96/// endpoint (RFC 6749 §5.2).
97pub enum RegistrationError {
98    /// A `redirect_uris` entry failed validation (RFC 7591 §3.2.2).
99    InvalidRedirectUri(String),
100    /// `application_type` (or another client-metadata field) failed
101    /// validation.
102    InvalidClientMetadata(String),
103    /// Any other failure surfaced from shared auth infrastructure (rate
104    /// limiting, storage). Status codes are preserved from `AuthError`'s own
105    /// semantics, but the response body still uses the RFC 7591
106    /// `error`/`error_description` shape for consistency within this
107    /// endpoint's responses.
108    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            // No RFC 7591 error code maps cleanly onto the remaining
124            // AuthError variants (rate limiting aside); `invalid_client_metadata`
125            // is the closest registration-scoped fallback so every `/register`
126            // response still carries an RFC-defined code.
127            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    /// The two RFC 7591-specific variants always answer 400 per §3.2.2. The
140    /// `Auth(_)` passthrough intentionally mirrors `AuthError`'s own private
141    /// `status()` mapping in `error.rs` verbatim rather than introducing a
142    /// registration-specific remap — the task for this endpoint is only to
143    /// change the *body shape* for those errors (`error`/`error_description`
144    /// instead of `kind`/`message`), not their existing status codes.
145    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
195/// Filter `candidate_redirect_uris` down to those that pass the same
196/// loopback/native-app-scheme/operator-allowlist check DCR-registered
197/// clients are held to via [`is_allowed_redirect_uri`].
198///
199/// CIMD lets a client skip the DCR round-trip, not the redirect-URI trust
200/// boundary. `client_id` is an arbitrary attacker-hosted URL, which means
201/// the attacker also controls the JSON body served there — including
202/// `redirect_uris`. Trusting a CIMD document's `redirect_uris` outright
203/// would let any public HTTPS server declare
204/// `redirect_uris: ["https://attacker.evil/steal-code"]` and have it
205/// honored, making CIMD strictly weaker than DCR at exactly the point DCR
206/// exists to protect. This function is a pure, dependency-free filter so
207/// it's testable without any network/fetch involved.
208pub(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
219/// Filter a fetched CIMD document's `redirect_uris` through
220/// [`allowlist_redirect_uris`] and turn an empty result into the
221/// appropriate rejection. Split out from [`resolve_client_redirect_uris`]
222/// as a pure function (no fetch, no I/O) so this decision is unit-testable
223/// directly: `resolve_client_redirect_uris` itself can only be exercised
224/// end-to-end through a real CIMD fetch, which requires a public https host
225/// this crate's test suite has no way to provide.
226pub(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
246/// Where a `client_id` was resolved from, carrying that source's own
247/// payload.
248///
249/// This is the single place the CIMD-vs-DCR-store decision is made (see
250/// [`resolve_client_source`]). The two public resolvers — [`resolve_client`]
251/// for `/token` and [`resolve_client_redirect_uris`] for `/authorize` — both
252/// branch on this enum instead of re-testing
253/// [`crate::cimd::document::is_cimd_client_id`] themselves, so the two
254/// endpoints can never disagree about whether a given `client_id` resolves.
255///
256/// The CIMD variant deliberately carries the raw
257/// [`crate::cimd::document::ClientMetadataDocument`] rather than an already
258/// converted [`RegisteredClient`]: `/authorize` must run the document's
259/// `redirect_uris` through [`allowed_uris_from_cimd_document`], which is a
260/// pure, separately unit-tested function over the document itself.
261enum ResolvedClientSource {
262    /// `client_id` is a CIMD URL and its metadata document was fetched and
263    /// validated.
264    Cimd(crate::cimd::document::ClientMetadataDocument),
265    /// `client_id` is an opaque DCR-issued token; `None` when the clients
266    /// table has no such row. Turning that `None` into a caller-appropriate
267    /// answer is each resolver's own job — `/token` treats it as `Ok(None)`,
268    /// `/authorize` as `Err(InvalidGrant)`.
269    Registered(Option<RegisteredClient>),
270}
271
272/// The shared CIMD-vs-store branch behind [`resolve_client`] and
273/// [`resolve_client_redirect_uris`].
274///
275/// `on_cimd_error` runs before the `CimdError` is collapsed into the
276/// deliberately generic [`AuthError::Validation`] both callers return, so a
277/// caller that wants the detailed failure in its logs (the `/authorize` path
278/// does; the `/token` path does not) can record it without the detail ever
279/// reaching the anonymous HTTP caller.
280async 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                    // Deliberately generic: the detailed CimdError string (which can
292                    // reveal e.g. "resolved only to private addresses" vs "does not
293                    // exist") is only ever exposed through `on_cimd_error`, NOT
294                    // returned to the anonymous caller, to avoid an
295                    // internal-network-topology mapping oracle.
296                    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
307/// Resolve complete client authentication metadata from DCR or CIMD.
308///
309/// Consumed by `token_client_auth::authenticate_oauth_client` to decide which
310/// `token_endpoint_auth_method` a client must satisfy at `/token`.
311///
312/// An unknown `client_id` is `Ok(None)`, not an error: `/token`'s client
313/// authentication has its own not-found handling and error shape (RFC 6749
314/// §5.2), unlike `/authorize` — see [`resolve_client_redirect_uris`].
315pub(crate) async fn resolve_client(
316    state: &AuthState,
317    client_id: &str,
318) -> Result<Option<RegisteredClient>, AuthError> {
319    // No logging hook: unlike /authorize, this path deliberately stays quiet
320    // about CIMD fetch failures.
321    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
333/// Resolve the set of trusted `redirect_uris` for `client_id`, either via
334/// the DCR-registered-clients table or, for an `https://`-shaped
335/// `client_id`, by fetching and validating its CIMD document (see
336/// [`crate::cimd`]) and filtering its declared `redirect_uris` through
337/// [`allowed_uris_from_cimd_document`].
338///
339/// Shares [`resolve_client_source`] with [`resolve_client`] so both
340/// endpoints agree on whether a `client_id` resolves at all; what differs
341/// is only what each does afterwards. Here an unknown `client_id` is a
342/// logged [`AuthError::InvalidGrant`] rather than `Ok(None)`, because
343/// `/authorize` has no later step that could handle "unknown".
344pub(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    /// A DCR-issued `client_id` is an opaque `random_token(18)` value, so it
390    /// can never start with `https://` and always takes the store branch.
391    const OPAQUE_CLIENT_ID: &str = "opaque-dcr-client-id";
392    /// An `https://` `client_id` always takes the CIMD branch. This one is
393    /// rejected by `validate_url_shape`'s private-address guard before any
394    /// DNS or network I/O happens, so the test stays hermetic.
395    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    /// The regression this pairing exists to prevent: `/token` resolves a
431    /// client through `resolve_client` and `/authorize` through
432    /// `resolve_client_redirect_uris`. If those two ever branch differently
433    /// on CIMD-vs-store, a `client_id` could authenticate at one endpoint
434    /// and be unknown at the other. Both now share
435    /// `resolve_client_source`, so assert they answer the same
436    /// resolves/does-not-resolve verdict for the same `client_id` -- while
437    /// still expressing that verdict in each endpoint's own shape.
438    #[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        // /token: unknown is `Ok(None)`, left for client authentication to
472        // report in RFC 6749 section 5.2 shape.
473        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        // /authorize: the same verdict, but reported as InvalidGrant because
479        // there is no later step that could handle "unknown".
480        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    /// Both resolvers must route an `https://` `client_id` down the CIMD
490    /// branch, and both must collapse a CIMD failure into the same
491    /// deliberately generic message -- the detailed `CimdError` is for logs
492    /// only.
493    #[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}