Skip to main content

didbot_serve/oauth/
error.rs

1//! The error shape OAuth's own endpoints use.
2//!
3//! `crate::error::ApiError` is atproto XRPC's `{"error","message"}` body.
4//! PAR, `authorize` and `token` are not XRPC procedures — they are OAuth 2.1
5//! endpoints, and RFC 6749 §5.2 defines their own wire shape:
6//! `{"error","error_description"}`, drawn from a fixed vocabulary
7//! (`invalid_request`, `invalid_client`, `invalid_grant`, ...). A client
8//! library written against the OAuth spec switches on that vocabulary, not on
9//! this project's own error names, so reusing [`crate::error::ApiError`] here
10//! would answer a spec-defined request with a shape no OAuth client expects.
11
12use axum::http::{HeaderValue, StatusCode};
13use axum::response::{IntoResponse, Response};
14use axum::Json;
15use serde::Serialize;
16
17/// One of the `error` values RFC 6749 and its extensions define, restricted
18/// to the ones this server's endpoints actually raise.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum OAuthErrorCode {
21    /// A required parameter is missing, malformed, or repeated.
22    InvalidRequest,
23    /// The client is not recognised, or its metadata does not check out.
24    InvalidClient,
25    /// The authorization code, refresh token, or PAR `request_uri` presented
26    /// is unknown, expired, already used, or was issued to a different
27    /// client.
28    InvalidGrant,
29    /// This client is not permitted to use the grant type it asked for.
30    UnauthorizedClient,
31    /// `grant_type` named something this server does not implement.
32    UnsupportedGrantType,
33    /// `response_type` named something other than `code`.
34    UnsupportedResponseType,
35    /// The requested scope is outside what this token may be issued —
36    /// including every refusal `plan/scope-policy.md`'s ceiling will
37    /// eventually make, and every one it makes today by not existing yet.
38    InvalidScope,
39    /// The resource owner (or, here, the policy standing in for one) refused
40    /// the request. This is the consent seam's own refusal.
41    AccessDenied,
42    /// Something on this server's side, not the caller's request.
43    ServerError,
44    /// The DPoP proof is missing or does not check out (RFC 9449 §5).
45    InvalidDpopProof,
46    /// The DPoP proof carries no nonce this server accepts (RFC 9449 §8).
47    /// The answer names the nonce to retry with in `DPoP-Nonce`.
48    UseDpopNonce,
49}
50
51impl OAuthErrorCode {
52    pub(crate) fn as_str(self) -> &'static str {
53        match self {
54            OAuthErrorCode::InvalidRequest => "invalid_request",
55            OAuthErrorCode::InvalidClient => "invalid_client",
56            OAuthErrorCode::InvalidGrant => "invalid_grant",
57            OAuthErrorCode::UnauthorizedClient => "unauthorized_client",
58            OAuthErrorCode::UnsupportedGrantType => "unsupported_grant_type",
59            OAuthErrorCode::UnsupportedResponseType => "unsupported_response_type",
60            OAuthErrorCode::InvalidScope => "invalid_scope",
61            OAuthErrorCode::AccessDenied => "access_denied",
62            OAuthErrorCode::ServerError => "server_error",
63            OAuthErrorCode::InvalidDpopProof => "invalid_dpop_proof",
64            OAuthErrorCode::UseDpopNonce => "use_dpop_nonce",
65        }
66    }
67
68    /// The HTTP status RFC 6749 pairs with this error, at the token endpoint.
69    /// `authorize` and PAR redirect or refuse a subset of these; see each
70    /// module's own mapping.
71    fn status(self) -> StatusCode {
72        match self {
73            OAuthErrorCode::InvalidClient => StatusCode::UNAUTHORIZED,
74            OAuthErrorCode::AccessDenied => StatusCode::FORBIDDEN,
75            OAuthErrorCode::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
76            _ => StatusCode::BAD_REQUEST,
77        }
78    }
79}
80
81#[derive(Debug, Clone, Serialize)]
82struct OAuthErrorBody {
83    error: &'static str,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    error_description: Option<String>,
86}
87
88/// An OAuth-shaped failure: `{"error", "error_description"}`, per RFC 6749.
89#[derive(Debug, Clone)]
90pub struct OAuthError {
91    status: StatusCode,
92    body: OAuthErrorBody,
93    /// The nonce a `use_dpop_nonce` refusal hands the client in
94    /// `DPoP-Nonce`.
95    dpop_nonce: Option<String>,
96}
97
98impl OAuthError {
99    /// Builds a failure from its RFC 6749 error code and a human sentence.
100    ///
101    /// `description` is never a secret and never the caller's own submitted
102    /// content verbatim, for the same reason `ApiError`'s messages are safe to
103    /// return: this project puts no credential or key material into a
104    /// `Display` impl that a wire error could quote.
105    pub fn new(code: OAuthErrorCode, description: impl Into<String>) -> Self {
106        Self {
107            status: code.status(),
108            body: OAuthErrorBody {
109                error: code.as_str(),
110                error_description: Some(description.into()),
111            },
112            dpop_nonce: None,
113        }
114    }
115
116    /// The `429` the unauthenticated OAuth endpoints answer a caller over
117    /// its rate with. RFC 6749 has no code for it, so this is
118    /// `invalid_request` with a description naming the endpoint.
119    pub fn rate_limited(endpoint: &str) -> Self {
120        let mut refused = Self::new(
121            OAuthErrorCode::InvalidRequest,
122            format!("too many {endpoint} requests from this caller; slow down"),
123        );
124        refused.status = StatusCode::TOO_MANY_REQUESTS;
125        refused
126    }
127
128    /// The `error` value the client is sent.
129    pub(crate) fn error(&self) -> &'static str {
130        self.body.error
131    }
132
133    /// The `error_description` the client is sent.
134    pub(crate) fn description(&self) -> &str {
135        self.body.error_description.as_deref().unwrap_or_default()
136    }
137
138    /// This refusal, handing the client `nonce` in `DPoP-Nonce` to sign its
139    /// retry with (RFC 9449 §8).
140    pub fn with_dpop_nonce(mut self, nonce: impl Into<String>) -> Self {
141        self.dpop_nonce = Some(nonce.into());
142        self
143    }
144}
145
146impl IntoResponse for OAuthError {
147    fn into_response(self) -> Response {
148        let mut response = (self.status, Json(self.body)).into_response();
149        if let Some(value) = self
150            .dpop_nonce
151            .and_then(|nonce| HeaderValue::from_str(&nonce).ok())
152        {
153            response
154                .headers_mut()
155                .insert(crate::oauth::dpop::DPOP_NONCE, value);
156        }
157        response
158    }
159}