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 crate::config::AuthConfig;
18use crate::state::AppState;
19
20pub struct OAuthTokenInfo {
21 pub did: String,
22 pub token_id: String,
23 pub client_id: String,
24 pub scope: Option<String>,
25 pub dpop_jkt: Option<String>,
26}
27
28pub struct VerifyResult {
29 pub did: String,
30 pub token_id: String,
31 pub client_id: String,
32 pub scope: Option<String>,
33}
34
35pub async fn verify_oauth_access_token(
36 pool: &PgPool,
37 access_token: &str,
38 dpop_proof: Option<&str>,
39 http_method: &str,
40 http_uri: &str,
41) -> Result<VerifyResult, OAuthError> {
42 let token_info = extract_oauth_token_info(access_token)?;
43 let token_data = db::get_token_by_id(pool, &token_info.token_id)
44 .await?
45 .ok_or_else(|| OAuthError::InvalidToken("Token not found or revoked".to_string()))?;
46 let now = chrono::Utc::now();
47 if token_data.expires_at < now {
48 return Err(OAuthError::InvalidToken("Token has expired".to_string()));
49 }
50 if let Some(expected_jkt) = &token_data.parameters.dpop_jkt {
51 let proof = dpop_proof
52 .ok_or_else(|| OAuthError::UseDpopNonce("DPoP proof required".to_string()))?;
53 let config = AuthConfig::get();
54 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
55 let access_token_hash = compute_ath(access_token);
56 let result =
57 verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?;
58 if !db::check_and_record_dpop_jti(pool, &result.jti).await? {
59 return Err(OAuthError::InvalidDpopProof(
60 "DPoP proof has already been used".to_string(),
61 ));
62 }
63 if &result.jkt != expected_jkt {
64 return Err(OAuthError::InvalidDpopProof(
65 "DPoP key binding mismatch".to_string(),
66 ));
67 }
68 }
69 Ok(VerifyResult {
70 did: token_data.did,
71 token_id: token_info.token_id,
72 client_id: token_data.client_id,
73 scope: token_data.scope,
74 })
75}
76
77pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthError> {
78 let parts: Vec<&str> = token.split('.').collect();
79 if parts.len() != 3 {
80 return Err(OAuthError::InvalidToken("Invalid token format".to_string()));
81 }
82 let header_bytes = URL_SAFE_NO_PAD
83 .decode(parts[0])
84 .map_err(|_| OAuthError::InvalidToken("Invalid token encoding".to_string()))?;
85 let header: serde_json::Value = serde_json::from_slice(&header_bytes)
86 .map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?;
87 if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") {
88 return Err(OAuthError::InvalidToken(
89 "Not an OAuth access token".to_string(),
90 ));
91 }
92 if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") {
93 return Err(OAuthError::InvalidToken(
94 "Unsupported algorithm".to_string(),
95 ));
96 }
97 let config = AuthConfig::get();
98 let secret = config.jwt_secret();
99 let signing_input = format!("{}.{}", parts[0], parts[1]);
100 let provided_sig = URL_SAFE_NO_PAD
101 .decode(parts[2])
102 .map_err(|_| OAuthError::InvalidToken("Invalid signature encoding".to_string()))?;
103 type HmacSha256 = Hmac<Sha256>;
104 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
105 .map_err(|_| OAuthError::ServerError("HMAC initialization failed".to_string()))?;
106 mac.update(signing_input.as_bytes());
107 let expected_sig = mac.finalize().into_bytes();
108 if !bool::from(expected_sig.ct_eq(&provided_sig)) {
109 return Err(OAuthError::InvalidToken(
110 "Invalid token signature".to_string(),
111 ));
112 }
113 let payload_bytes = URL_SAFE_NO_PAD
114 .decode(parts[1])
115 .map_err(|_| OAuthError::InvalidToken("Invalid payload encoding".to_string()))?;
116 let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
117 .map_err(|_| OAuthError::InvalidToken("Invalid token payload".to_string()))?;
118 let exp = payload
119 .get("exp")
120 .and_then(|e| e.as_i64())
121 .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?;
122 let now = chrono::Utc::now().timestamp();
123 if exp < now {
124 return Err(OAuthError::InvalidToken("Token has expired".to_string()));
125 }
126 let token_id = payload
127 .get("jti")
128 .and_then(|j| j.as_str())
129 .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))?
130 .to_string();
131 let did = payload
132 .get("sub")
133 .and_then(|s| s.as_str())
134 .ok_or_else(|| OAuthError::InvalidToken("Missing sub claim".to_string()))?
135 .to_string();
136 let scope = payload
137 .get("scope")
138 .and_then(|s| s.as_str())
139 .map(|s| s.to_string());
140 let dpop_jkt = payload
141 .get("cnf")
142 .and_then(|c| c.get("jkt"))
143 .and_then(|j| j.as_str())
144 .map(|s| s.to_string());
145 let client_id = payload
146 .get("client_id")
147 .and_then(|c| c.as_str())
148 .map(|s| s.to_string())
149 .unwrap_or_default();
150 Ok(OAuthTokenInfo {
151 did,
152 token_id,
153 client_id,
154 scope,
155 dpop_jkt,
156 })
157}
158
159fn compute_ath(access_token: &str) -> String {
160 use sha2::Digest;
161 let mut hasher = Sha256::new();
162 hasher.update(access_token.as_bytes());
163 let hash = hasher.finalize();
164 URL_SAFE_NO_PAD.encode(hash)
165}
166
167pub fn generate_dpop_nonce() -> String {
168 let config = AuthConfig::get();
169 let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
170 verifier.generate_nonce()
171}
172
173pub struct OAuthUser {
174 pub did: String,
175 pub client_id: Option<String>,
176 pub scope: Option<String>,
177 pub is_oauth: bool,
178}
179
180pub struct OAuthAuthError {
181 pub status: StatusCode,
182 pub error: String,
183 pub message: String,
184 pub dpop_nonce: Option<String>,
185}
186
187impl IntoResponse for OAuthAuthError {
188 fn into_response(self) -> Response {
189 let mut response = (
190 self.status,
191 Json(json!({
192 "error": self.error,
193 "message": self.message
194 })),
195 )
196 .into_response();
197 if let Some(nonce) = self.dpop_nonce {
198 response
199 .headers_mut()
200 .insert("DPoP-Nonce", nonce.parse().unwrap());
201 }
202 response
203 }
204}
205
206impl FromRequestParts<AppState> for OAuthUser {
207 type Rejection = OAuthAuthError;
208
209 async fn from_request_parts(
210 parts: &mut Parts,
211 state: &AppState,
212 ) -> Result<Self, Self::Rejection> {
213 let auth_header = parts
214 .headers
215 .get("Authorization")
216 .and_then(|v| v.to_str().ok())
217 .ok_or_else(|| OAuthAuthError {
218 status: StatusCode::UNAUTHORIZED,
219 error: "AuthenticationRequired".to_string(),
220 message: "Authorization header required".to_string(),
221 dpop_nonce: None,
222 })?;
223 let auth_header_trimmed = auth_header.trim();
224 let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7
225 && auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ")
226 {
227 (auth_header_trimmed[7..].trim(), false)
228 } else if auth_header_trimmed.len() >= 5
229 && auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ")
230 {
231 (auth_header_trimmed[5..].trim(), true)
232 } else {
233 return Err(OAuthAuthError {
234 status: StatusCode::UNAUTHORIZED,
235 error: "InvalidRequest".to_string(),
236 message: "Invalid authorization scheme".to_string(),
237 dpop_nonce: None,
238 });
239 };
240 let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok());
241 if let Ok(result) = try_legacy_auth(&state.db, token).await {
242 return Ok(OAuthUser {
243 did: result.did,
244 client_id: None,
245 scope: None,
246 is_oauth: false,
247 });
248 }
249 let http_method = parts.method.as_str();
250 let http_uri = parts.uri.to_string();
251 match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await
252 {
253 Ok(result) => Ok(OAuthUser {
254 did: result.did,
255 client_id: Some(result.client_id),
256 scope: result.scope,
257 is_oauth: true,
258 }),
259 Err(OAuthError::UseDpopNonce(nonce)) => Err(OAuthAuthError {
260 status: StatusCode::UNAUTHORIZED,
261 error: "use_dpop_nonce".to_string(),
262 message: "DPoP nonce required".to_string(),
263 dpop_nonce: Some(nonce),
264 }),
265 Err(OAuthError::InvalidDpopProof(msg)) => {
266 let nonce = generate_dpop_nonce();
267 Err(OAuthAuthError {
268 status: StatusCode::UNAUTHORIZED,
269 error: "invalid_dpop_proof".to_string(),
270 message: msg,
271 dpop_nonce: Some(nonce),
272 })
273 }
274 Err(e) => {
275 let nonce = if is_dpop_token {
276 Some(generate_dpop_nonce())
277 } else {
278 None
279 };
280 Err(OAuthAuthError {
281 status: StatusCode::UNAUTHORIZED,
282 error: "AuthenticationFailed".to_string(),
283 message: format!("{:?}", e),
284 dpop_nonce: nonce,
285 })
286 }
287 }
288 }
289}
290
291struct LegacyAuthResult {
292 did: String,
293}
294
295async fn try_legacy_auth(pool: &PgPool, token: &str) -> Result<LegacyAuthResult, ()> {
296 match crate::auth::validate_bearer_token(pool, token).await {
297 Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did }),
298 _ => Err(()),
299 }
300}