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}