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::{HeaderValue, StatusCode, header};
12use axum::response::IntoResponse;
13use axum::{Json, response::Response};
14use tracing::{info, warn};
15
16use crate::error::{AuthError, AuthErrorKind};
17use crate::redirect_uri::is_allowed_redirect_uri;
18use crate::state::AuthState;
19use crate::types::{ClientRegistrationRequest, ClientRegistrationResponse, RegisteredClient};
20use crate::util::{now_unix, 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    };
73    state.store.register_client(client.clone()).await?;
74    info!(
75        client_id = %client.client_id,
76        redirect_uri_count = client.redirect_uris.len(),
77        redirect_uris = ?client.redirect_uris,
78        "oauth client registration accepted"
79    );
80    Ok(Json(ClientRegistrationResponse {
81        client_id: client.client_id,
82        redirect_uris: client.redirect_uris,
83        token_endpoint_auth_method: "none".to_string(),
84        application_type,
85    }))
86}
87
88/// RFC 7591 §3.2.2 requires `/register` errors to be reported as HTTP 400
89/// with a `{"error": ..., "error_description": ...}` body using one of the
90/// RFC's defined error codes — unlike the generic `AuthError` ->
91/// `IntoResponse` impl in `error.rs`, which returns 422 with a
92/// `{"kind", "message"}` body. This is `register_client`'s dedicated error
93/// type, mirroring `TokenEndpointError` in `token.rs` for the `/token`
94/// endpoint (RFC 6749 §5.2).
95pub enum RegistrationError {
96    /// A `redirect_uris` entry failed validation (RFC 7591 §3.2.2).
97    InvalidRedirectUri(String),
98    /// `application_type` (or another client-metadata field) failed
99    /// validation.
100    InvalidClientMetadata(String),
101    /// Any other failure surfaced from shared auth infrastructure (rate
102    /// limiting, storage). Status codes are preserved from `AuthError`'s own
103    /// semantics, but the response body still uses the RFC 7591
104    /// `error`/`error_description` shape for consistency within this
105    /// endpoint's responses.
106    Auth(AuthError),
107}
108
109impl From<AuthError> for RegistrationError {
110    fn from(error: AuthError) -> Self {
111        Self::Auth(error)
112    }
113}
114
115impl RegistrationError {
116    fn oauth_error(&self) -> &'static str {
117        match self {
118            Self::InvalidRedirectUri(_) => "invalid_redirect_uri",
119            Self::InvalidClientMetadata(_) => "invalid_client_metadata",
120            Self::Auth(AuthError::RateLimited { .. }) => "temporarily_unavailable",
121            // No RFC 7591 error code maps cleanly onto the remaining
122            // AuthError variants (rate limiting aside); `invalid_client_metadata`
123            // is the closest registration-scoped fallback so every `/register`
124            // response still carries an RFC-defined code.
125            Self::Auth(_) => "invalid_client_metadata",
126        }
127    }
128
129    fn log_kind(&self) -> &'static str {
130        match self {
131            Self::InvalidRedirectUri(_) => "invalid_redirect_uri",
132            Self::InvalidClientMetadata(_) => "invalid_client_metadata",
133            Self::Auth(error) => error.kind(),
134        }
135    }
136
137    /// The two RFC 7591-specific variants always answer 400 per §3.2.2. The
138    /// `Auth(_)` passthrough intentionally mirrors `AuthError`'s own private
139    /// `status()` mapping in `error.rs` verbatim rather than introducing a
140    /// registration-specific remap — the task for this endpoint is only to
141    /// change the *body shape* for those errors (`error`/`error_description`
142    /// instead of `kind`/`message`), not their existing status codes.
143    fn status(&self) -> StatusCode {
144        match self {
145            Self::InvalidRedirectUri(_) | Self::InvalidClientMetadata(_) => StatusCode::BAD_REQUEST,
146            Self::Auth(AuthError::InvalidGrant(_)) => StatusCode::BAD_REQUEST,
147            Self::Auth(AuthError::AuthFailed(_) | AuthError::InvalidAccessToken) => {
148                StatusCode::UNAUTHORIZED
149            }
150            Self::Auth(AuthError::Validation(_)) => StatusCode::UNPROCESSABLE_ENTITY,
151            Self::Auth(AuthError::Network(_) | AuthError::Server(_)) => StatusCode::BAD_GATEWAY,
152            Self::Auth(AuthError::RateLimited { .. }) => StatusCode::TOO_MANY_REQUESTS,
153            Self::Auth(
154                AuthError::Config(_)
155                | AuthError::Storage(_)
156                | AuthError::Decode(_)
157                | AuthError::InsecurePermissions { .. },
158            ) => StatusCode::INTERNAL_SERVER_ERROR,
159        }
160    }
161
162    fn description(&self) -> String {
163        match self {
164            Self::InvalidRedirectUri(message) | Self::InvalidClientMetadata(message) => {
165                message.clone()
166            }
167            Self::Auth(error) => error.to_string(),
168        }
169    }
170
171    fn retry_after_ms(&self) -> Option<u64> {
172        match self {
173            Self::Auth(AuthError::RateLimited { retry_after_ms, .. }) => Some(*retry_after_ms),
174            _ => None,
175        }
176    }
177}
178
179impl IntoResponse for RegistrationError {
180    fn into_response(self) -> Response {
181        let status = self.status();
182        let log_kind = self.log_kind();
183        let retry_after_ms = self.retry_after_ms();
184        let body = Json(serde_json::json!({
185            "error": self.oauth_error(),
186            "error_description": self.description(),
187        }));
188        let mut response = (status, body).into_response();
189        response.extensions_mut().insert(AuthErrorKind(log_kind));
190        response
191            .headers_mut()
192            .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
193        response
194            .headers_mut()
195            .insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
196        if let Some(retry_after_ms) = retry_after_ms
197            && let Ok(value) = HeaderValue::from_str(&(retry_after_ms / 1_000).max(1).to_string())
198        {
199            response.headers_mut().insert(header::RETRY_AFTER, value);
200        }
201        response
202    }
203}
204
205/// Filter `candidate_redirect_uris` down to those that pass the same
206/// loopback/native-app-scheme/operator-allowlist check DCR-registered
207/// clients are held to via [`is_allowed_redirect_uri`].
208///
209/// CIMD lets a client skip the DCR round-trip, not the redirect-URI trust
210/// boundary. `client_id` is an arbitrary attacker-hosted URL, which means
211/// the attacker also controls the JSON body served there — including
212/// `redirect_uris`. Trusting a CIMD document's `redirect_uris` outright
213/// would let any public HTTPS server declare
214/// `redirect_uris: ["https://attacker.evil/steal-code"]` and have it
215/// honored, making CIMD strictly weaker than DCR at exactly the point DCR
216/// exists to protect. This function is a pure, dependency-free filter so
217/// it's testable without any network/fetch involved.
218pub(crate) fn allowlist_redirect_uris(
219    candidate_redirect_uris: &[String],
220    allowed_patterns: &[String],
221) -> Vec<String> {
222    candidate_redirect_uris
223        .iter()
224        .filter(|uri| is_allowed_redirect_uri(uri, allowed_patterns))
225        .cloned()
226        .collect()
227}
228
229/// Filter a fetched CIMD document's `redirect_uris` through
230/// [`allowlist_redirect_uris`] and turn an empty result into the
231/// appropriate rejection. Split out from [`resolve_client_redirect_uris`]
232/// as a pure function (no fetch, no I/O) so this decision is unit-testable
233/// directly: `resolve_client_redirect_uris` itself can only be exercised
234/// end-to-end through a real CIMD fetch, which requires a public https host
235/// this crate's test suite has no way to provide.
236pub(crate) fn allowed_uris_from_cimd_document(
237    document: &crate::cimd::document::ClientMetadataDocument,
238    client_id: &str,
239    client_state_id: &str,
240    allowed_patterns: &[String],
241) -> Result<Vec<String>, AuthError> {
242    let allowed = allowlist_redirect_uris(&document.redirect_uris, allowed_patterns);
243    if allowed.is_empty() {
244        warn!(
245            client_id = %client_id,
246            client_state_id = %client_state_id,
247            "oauth authorize rejected: CIMD document declares no allowlisted redirect_uris"
248        );
249        return Err(AuthError::Validation(
250            "client_id metadata document declares no allowed redirect_uris".to_string(),
251        ));
252    }
253    Ok(allowed)
254}
255
256/// Resolve the set of trusted `redirect_uris` for `client_id`, either via
257/// the DCR-registered-clients table or, for an `https://`-shaped
258/// `client_id`, by fetching and validating its CIMD document (see
259/// [`crate::cimd`]) and filtering its declared `redirect_uris` through
260/// [`allowed_uris_from_cimd_document`].
261pub(crate) async fn resolve_client_redirect_uris(
262    state: &AuthState,
263    client_id: &str,
264    client_state_id: &str,
265) -> Result<Vec<String>, AuthError> {
266    if crate::cimd::document::is_cimd_client_id(client_id) {
267        let document =
268            crate::cimd::document::fetch_and_validate_client_metadata(&state.cimd_cache, client_id)
269                .await
270                .map_err(|error| {
271                    warn!(
272                        client_id = %client_id,
273                        client_state_id = %client_state_id,
274                        kind = error.kind(),
275                        error = %error,
276                        "oauth authorize rejected: CIMD document fetch/validation failed"
277                    );
278                    // Deliberately generic: the detailed CimdError string (which can
279                    // reveal e.g. "resolved only to private addresses" vs "does not
280                    // exist") is logged above but NOT returned to the anonymous
281                    // /authorize caller, to avoid an internal-network-topology
282                    // mapping oracle.
283                    AuthError::Validation(
284                        "client_id metadata document is invalid or unreachable".to_string(),
285                    )
286                })?;
287        return allowed_uris_from_cimd_document(
288            &document,
289            client_id,
290            client_state_id,
291            &state.config.allowed_client_redirect_uris,
292        );
293    }
294
295    let client = state.store.find_client(client_id).await?.ok_or_else(|| {
296        warn!(
297            client_id = %client_id,
298            client_state_id = %client_state_id,
299            "oauth authorize rejected: unknown client_id"
300        );
301        AuthError::InvalidGrant("unknown client_id".to_string())
302    })?;
303    Ok(client.redirect_uris)
304}