this repo has no description
1use axum::{
2 Json,
3 extract::FromRequestParts,
4 http::{StatusCode, request::Parts},
5 response::{IntoResponse, Response},
6};
7use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
8use hmac::{Hmac, Mac};
9use serde_json::json;
10use sha2::Sha256;
11use sqlx::PgPool;
12use subtle::ConstantTimeEq;
13
14use super::OAuthError;
15use super::db;
16use super::dpop::DPoPVerifier;
17use super::scopes::ScopePermissions;
18use crate::config::AuthConfig;
19use crate::state::AppState;
20
21pub struct OAuthTokenInfo {
22 pub did: String,
23 pub token_id: String,
24 pub client_id: String,
25 pub scope: Option<String>,
26 pub dpop_jkt: Option<String>,
27 pub controller_did: Option<String>,
28}
29
30pub struct VerifyResult {
31 pub did: String,
32 pub token_id: String,
33 pub client_id: String,
34 pub scope: Option<String>,
35}
36
37pub async fn verify_oauth_access_token(
38 pool: &PgPool,
39 access_token: &str,
40 dpop_proof: Option<&str>,
41 http_method: &str,
42 http_uri: &str,
43) -> Result<VerifyResult, OAuthError> {
44 let token_info = extract_oauth_token_info(access_token)?;
45 tracing::debug!(
46 token_id = %token_info.token_id,
47 has_dpop_proof = dpop_proof.is_some(),
48 "Verifying OAuth access token"
49 );
50 let token_data = db::get_token_by_id(pool, &token_info.token_id)
51 .await?
52 .ok_or_else(|| {
53 tracing::warn!(token_id = %token_info.token_id, "Token not found in database");
54 OAuthError::InvalidToken("Token not found or revoked".to_string())
55 })?;
56 let now = chrono::Utc::now();
57 if token_data.expires_at < now {
58 return Err(OAuthError::ExpiredToken(
59 "Token session has expired".to_string(),
60 ));
61 }
62 if let Some(expected_jkt) = &token_data.parameters.dpop_jkt {
63 tracing::debug!(expected_jkt = %expected_jkt, "Token requires DPoP");
64 let proof = dpop_proof.ok_or_else(|| {
65 tracing::warn!("DPoP proof required but not provided");
66 OAuthError::UseDpopNonce("DPoP proof required".to_string())
67 })?;
68 let config = AuthConfig::get();
69 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
70 let access_token_hash = compute_ath(access_token);
71 let result = verifier
72 .verify_proof(proof, http_method, http_uri, Some(&access_token_hash))
73 .map_err(|e| {
74 tracing::warn!(error = ?e, http_method = %http_method, http_uri = %http_uri, "DPoP proof verification failed");
75 e
76 })?;
77 if !db::check_and_record_dpop_jti(pool, &result.jti).await? {
78 return Err(OAuthError::InvalidDpopProof(
79 "DPoP proof has already been used".to_string(),
80 ));
81 }
82 if result.jkt.as_str() != expected_jkt {
83 return Err(OAuthError::InvalidDpopProof(
84 "DPoP key binding mismatch".to_string(),
85 ));
86 }
87 }
88 Ok(VerifyResult {
89 did: token_data.did,
90 token_id: token_info.token_id,
91 client_id: token_data.client_id,
92 scope: token_data.scope,
93 })
94}
95
96pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthError> {
97 let parts: Vec<&str> = token.split('.').collect();
98 if parts.len() != 3 {
99 return Err(OAuthError::InvalidToken("Invalid token format".to_string()));
100 }
101 let header_bytes = URL_SAFE_NO_PAD
102 .decode(parts[0])
103 .map_err(|_| OAuthError::InvalidToken("Invalid token encoding".to_string()))?;
104 let header: serde_json::Value = serde_json::from_slice(&header_bytes)
105 .map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?;
106 if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") {
107 return Err(OAuthError::InvalidToken(
108 "Not an OAuth access token".to_string(),
109 ));
110 }
111 if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") {
112 return Err(OAuthError::InvalidToken(
113 "Unsupported algorithm".to_string(),
114 ));
115 }
116 let config = AuthConfig::get();
117 let secret = config.jwt_secret();
118 let signing_input = format!("{}.{}", parts[0], parts[1]);
119 let provided_sig = URL_SAFE_NO_PAD
120 .decode(parts[2])
121 .map_err(|_| OAuthError::InvalidToken("Invalid signature encoding".to_string()))?;
122 type HmacSha256 = Hmac<Sha256>;
123 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
124 .map_err(|_| OAuthError::ServerError("HMAC initialization failed".to_string()))?;
125 mac.update(signing_input.as_bytes());
126 let expected_sig = mac.finalize().into_bytes();
127 if !bool::from(expected_sig.ct_eq(&provided_sig)) {
128 return Err(OAuthError::InvalidToken(
129 "Invalid token signature".to_string(),
130 ));
131 }
132 let payload_bytes = URL_SAFE_NO_PAD
133 .decode(parts[1])
134 .map_err(|_| OAuthError::InvalidToken("Invalid payload encoding".to_string()))?;
135 let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
136 .map_err(|_| OAuthError::InvalidToken("Invalid token payload".to_string()))?;
137 let exp = payload
138 .get("exp")
139 .and_then(|e| e.as_i64())
140 .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?;
141 let now = chrono::Utc::now().timestamp();
142 if exp < now {
143 return Err(OAuthError::ExpiredToken("Token has expired".to_string()));
144 }
145 let token_id = payload
146 .get("jti")
147 .and_then(|j| j.as_str())
148 .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))?
149 .to_string();
150 let did = payload
151 .get("sub")
152 .and_then(|s| s.as_str())
153 .ok_or_else(|| OAuthError::InvalidToken("Missing sub claim".to_string()))?
154 .to_string();
155 let scope = payload
156 .get("scope")
157 .and_then(|s| s.as_str())
158 .map(|s| s.to_string());
159 let dpop_jkt = payload
160 .get("cnf")
161 .and_then(|c| c.get("jkt"))
162 .and_then(|j| j.as_str())
163 .map(|s| s.to_string());
164 let client_id = payload
165 .get("client_id")
166 .and_then(|c| c.as_str())
167 .map(|s| s.to_string())
168 .unwrap_or_default();
169 let controller_did = payload
170 .get("act")
171 .and_then(|a| a.get("sub"))
172 .and_then(|s| s.as_str())
173 .map(|s| s.to_string());
174 Ok(OAuthTokenInfo {
175 did,
176 token_id,
177 client_id,
178 scope,
179 dpop_jkt,
180 controller_did,
181 })
182}
183
184fn compute_ath(access_token: &str) -> String {
185 use sha2::Digest;
186 let mut hasher = Sha256::new();
187 hasher.update(access_token.as_bytes());
188 let hash = hasher.finalize();
189 URL_SAFE_NO_PAD.encode(hash)
190}
191
192pub fn generate_dpop_nonce() -> String {
193 let config = AuthConfig::get();
194 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
195 verifier.generate_nonce()
196}
197
198pub struct OAuthUser {
199 pub did: String,
200 pub client_id: Option<String>,
201 pub scope: Option<String>,
202 pub is_oauth: bool,
203 pub permissions: ScopePermissions,
204}
205
206pub struct OAuthAuthError {
207 pub status: StatusCode,
208 pub error: String,
209 pub message: String,
210 pub dpop_nonce: Option<String>,
211 pub www_authenticate: Option<String>,
212}
213
214impl IntoResponse for OAuthAuthError {
215 fn into_response(self) -> Response {
216 let mut response = (
217 self.status,
218 Json(json!({
219 "error": self.error,
220 "message": self.message
221 })),
222 )
223 .into_response();
224 if let Some(nonce) = self.dpop_nonce {
225 response
226 .headers_mut()
227 .insert("DPoP-Nonce", nonce.parse().unwrap());
228 }
229 if let Some(www_auth) = self.www_authenticate {
230 response
231 .headers_mut()
232 .insert("WWW-Authenticate", www_auth.parse().unwrap());
233 }
234 response
235 }
236}
237
238impl FromRequestParts<AppState> for OAuthUser {
239 type Rejection = OAuthAuthError;
240
241 async fn from_request_parts(
242 parts: &mut Parts,
243 state: &AppState,
244 ) -> Result<Self, Self::Rejection> {
245 let auth_header = parts
246 .headers
247 .get("Authorization")
248 .and_then(|v| v.to_str().ok())
249 .ok_or_else(|| OAuthAuthError {
250 status: StatusCode::UNAUTHORIZED,
251 error: "AuthenticationRequired".to_string(),
252 message: "Authorization header required".to_string(),
253 dpop_nonce: None,
254 www_authenticate: None,
255 })?;
256 let auth_header_trimmed = auth_header.trim();
257 let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7
258 && auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ")
259 {
260 (auth_header_trimmed[7..].trim(), false)
261 } else if auth_header_trimmed.len() >= 5
262 && auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ")
263 {
264 (auth_header_trimmed[5..].trim(), true)
265 } else {
266 return Err(OAuthAuthError {
267 status: StatusCode::UNAUTHORIZED,
268 error: "InvalidRequest".to_string(),
269 message: "Invalid authorization scheme".to_string(),
270 dpop_nonce: None,
271 www_authenticate: None,
272 });
273 };
274 let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok());
275 if let Ok(result) = try_legacy_auth(&state.db, token).await {
276 return Ok(OAuthUser {
277 did: result.did,
278 client_id: None,
279 scope: None,
280 is_oauth: false,
281 permissions: ScopePermissions::default(),
282 });
283 }
284 let http_method = parts.method.as_str();
285 let http_uri = crate::util::build_full_url(&parts.uri.to_string());
286 match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await
287 {
288 Ok(result) => {
289 let permissions = ScopePermissions::from_scope_string(result.scope.as_deref());
290 Ok(OAuthUser {
291 did: result.did,
292 client_id: Some(result.client_id),
293 scope: result.scope,
294 is_oauth: true,
295 permissions,
296 })
297 }
298 Err(OAuthError::UseDpopNonce(nonce)) => Err(OAuthAuthError {
299 status: StatusCode::UNAUTHORIZED,
300 error: "use_dpop_nonce".to_string(),
301 message: "DPoP nonce required".to_string(),
302 dpop_nonce: Some(nonce),
303 www_authenticate: Some("DPoP error=\"use_dpop_nonce\"".to_string()),
304 }),
305 Err(OAuthError::InvalidDpopProof(msg)) => {
306 let nonce = generate_dpop_nonce();
307 Err(OAuthAuthError {
308 status: StatusCode::UNAUTHORIZED,
309 error: "invalid_dpop_proof".to_string(),
310 message: msg,
311 dpop_nonce: Some(nonce),
312 www_authenticate: None,
313 })
314 }
315 Err(OAuthError::ExpiredToken(msg)) => {
316 let nonce = if is_dpop_token {
317 Some(generate_dpop_nonce())
318 } else {
319 None
320 };
321 let scheme = if is_dpop_token { "DPoP" } else { "Bearer" };
322 let www_auth = format!(
323 "{} error=\"invalid_token\", error_description=\"{}\"",
324 scheme, msg
325 );
326 Err(OAuthAuthError {
327 status: StatusCode::UNAUTHORIZED,
328 error: "ExpiredToken".to_string(),
329 message: msg,
330 dpop_nonce: nonce,
331 www_authenticate: Some(www_auth),
332 })
333 }
334 Err(OAuthError::InvalidToken(msg)) => {
335 let nonce = if is_dpop_token {
336 Some(generate_dpop_nonce())
337 } else {
338 None
339 };
340 let scheme = if is_dpop_token { "DPoP" } else { "Bearer" };
341 let www_auth = format!(
342 "{} error=\"invalid_token\", error_description=\"{}\"",
343 scheme, msg
344 );
345 Err(OAuthAuthError {
346 status: StatusCode::UNAUTHORIZED,
347 error: "InvalidToken".to_string(),
348 message: msg,
349 dpop_nonce: nonce,
350 www_authenticate: Some(www_auth),
351 })
352 }
353 Err(e) => {
354 let nonce = if is_dpop_token {
355 Some(generate_dpop_nonce())
356 } else {
357 None
358 };
359 Err(OAuthAuthError {
360 status: StatusCode::UNAUTHORIZED,
361 error: "AuthenticationFailed".to_string(),
362 message: format!("{:?}", e),
363 dpop_nonce: nonce,
364 www_authenticate: None,
365 })
366 }
367 }
368 }
369}
370
371struct LegacyAuthResult {
372 did: String,
373}
374
375async fn try_legacy_auth(pool: &PgPool, token: &str) -> Result<LegacyAuthResult, ()> {
376 match crate::auth::validate_bearer_token(pool, token).await {
377 Ok(user) if !user.is_oauth => Ok(LegacyAuthResult {
378 did: user.did.to_string(),
379 }),
380 _ => Err(()),
381 }
382}