Skip to main content

max / makenotwork

20.2 KB · 596 lines History Blame Raw
1 //! OAuth2 authorization server endpoints for "Log in with Makenot.work"
2 //!
3 //! Implements Authorization Code + PKCE (RFC 7636) for desktop/mobile clients.
4 //!
5 //! See also: `/docs/developer/oauth`
6
7 use axum::{
8 extract::{Query, State},
9 http::StatusCode,
10 response::{IntoResponse, Redirect, Response},
11 routing::get,
12 Form, Json,
13 };
14 use crate::csrf::{post_csrf_skip, CsrfRouter};
15 use rand::RngCore;
16 use serde::{Deserialize, Serialize};
17 use sha2::{Digest, Sha256};
18 use tower_governor::GovernorLayer;
19 use tower_sessions::Session;
20
21 use crate::{
22 auth::{verify_password, MaybeUserVerified},
23 constants::{self, LOCKOUT_MINUTES, MAX_LOGIN_ATTEMPTS},
24 csrf,
25 db::{self, CreatorTier, SyncAppId, UserId, Username},
26 error::{AppError, Result},
27 synckit_auth,
28 templates::OAuthAuthorizeTemplate,
29 AppState,
30 };
31
32 /// Anti-timing dummy hash: ensures the user-not-found path takes the same time
33 /// as the wrong-password path (prevents user enumeration via response timing).
34 static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
35 crate::auth::hash_password("anti-timing-dummy").expect("dummy hash")
36 });
37
38 // ── Request/Response types ──
39
40 #[derive(Deserialize)]
41 pub struct AuthorizeQuery {
42 pub response_type: Option<String>,
43 pub client_id: Option<String>,
44 pub redirect_uri: Option<String>,
45 pub state: Option<String>,
46 pub code_challenge: Option<String>,
47 pub code_challenge_method: Option<String>,
48 }
49
50 #[derive(Deserialize)]
51 pub struct AuthorizeForm {
52 pub client_id: String,
53 pub redirect_uri: String,
54 pub state: String,
55 pub code_challenge: String,
56 pub code_challenge_method: String,
57 pub login: Option<String>,
58 pub password: Option<String>,
59 #[serde(rename = "_csrf")]
60 pub csrf_token: String,
61 }
62
63 #[derive(Deserialize)]
64 pub struct TokenRequest {
65 pub grant_type: String,
66 pub code: String,
67 pub redirect_uri: String,
68 pub code_verifier: String,
69 pub client_id: String,
70 /// Developer-defined SDK key. Identifies which billing slot this session's
71 /// uploads count against. Required.
72 pub key: String,
73 }
74
75 #[derive(Serialize)]
76 pub struct TokenResponse {
77 pub access_token: String,
78 pub token_type: String,
79 pub expires_in: i64,
80 pub user_id: UserId,
81 pub app_id: SyncAppId,
82 }
83
84 // ── Helpers ──
85
86 fn generate_oauth_code() -> String {
87 let mut bytes = [0u8; constants::OAUTH_CODE_LENGTH];
88 rand::rng().fill_bytes(&mut bytes);
89 hex::encode(bytes)
90 }
91
92 /// Validate that a redirect_uri is allowed.
93 ///
94 /// Localhost callbacks are always permitted. Accepts the three loopback
95 /// forms RFC 8252 §7.3 calls out:
96 /// - `http://127.0.0.1:{port}/...` (IPv4 loopback)
97 /// - `http://[::1]:{port}/...` (IPv6 loopback, bracketed)
98 /// - `http://localhost:{port}/...` (resolver-dependent, included for parity)
99 ///
100 /// Non-localhost URIs must be registered in the app's `redirect_uris` column.
101 fn is_localhost_redirect(uri: &str) -> bool {
102 for prefix in ["http://127.0.0.1:", "http://[::1]:", "http://localhost:"] {
103 if let Some(rest) = uri.strip_prefix(prefix)
104 && let Some(port_str) = rest.split('/').next()
105 && port_str.parse::<u16>().is_ok()
106 {
107 return true;
108 }
109 }
110 false
111 }
112
113 async fn validate_redirect_uri(pool: &sqlx::PgPool, app_id: db::SyncAppId, uri: &str) -> Result<bool> {
114 if is_localhost_redirect(uri) {
115 return Ok(true);
116 }
117 db::oauth::is_registered_redirect_uri(pool, app_id, uri).await
118 }
119
120 /// Render the authorize page with an error message.
121 fn render_authorize_error(
122 csrf_token: Option<String>,
123 session_user: Option<crate::auth::SessionUser>,
124 app_name: &str,
125 form: &AuthorizeForm,
126 error: &str,
127 ) -> Response {
128 OAuthAuthorizeTemplate {
129 csrf_token,
130 session_user,
131 app_name: app_name.to_string(),
132 client_id: form.client_id.clone(),
133 redirect_uri: form.redirect_uri.clone(),
134 state: form.state.clone(),
135 code_challenge: form.code_challenge.clone(),
136 code_challenge_method: form.code_challenge_method.clone(),
137 error_message: Some(error.to_string()),
138 }
139 .into_response()
140 }
141
142 // ── GET /oauth/authorize ──
143
144 #[tracing::instrument(skip_all, name = "oauth::authorize_get")]
145 async fn authorize_get(
146 State(state): State<AppState>,
147 MaybeUserVerified(session_user): MaybeUserVerified,
148 session: Session,
149 Query(params): Query<AuthorizeQuery>,
150 ) -> Result<Response> {
151 // Validate required params
152 let response_type = params.response_type.as_deref().unwrap_or("");
153 if response_type != "code" {
154 return Err(AppError::BadRequest("response_type must be 'code'".to_string()));
155 }
156
157 let client_id = params.client_id.as_deref()
158 .ok_or_else(|| AppError::BadRequest("client_id is required".to_string()))?;
159 let redirect_uri = params.redirect_uri.as_deref()
160 .ok_or_else(|| AppError::BadRequest("redirect_uri is required".to_string()))?;
161 let state_param = params.state.as_deref()
162 .ok_or_else(|| AppError::BadRequest("state is required".to_string()))?;
163 let code_challenge = params.code_challenge.as_deref()
164 .ok_or_else(|| AppError::BadRequest("code_challenge is required".to_string()))?;
165 let code_challenge_method = params.code_challenge_method.as_deref().unwrap_or("S256");
166
167 if code_challenge_method != "S256" {
168 return Err(AppError::BadRequest("code_challenge_method must be 'S256'".to_string()));
169 }
170
171 // Look up app by client_id (= sync_apps.api_key)
172 let app = db::synckit::get_sync_app_by_api_key(&state.db, client_id)
173 .await?
174 .ok_or_else(|| AppError::BadRequest("Unknown client_id".to_string()))?;
175
176 if !validate_redirect_uri(&state.db, app.id, redirect_uri).await? {
177 return Err(AppError::BadRequest("redirect_uri is not allowed".to_string()));
178 }
179
180 let csrf_token = csrf::get_or_create_token(&session).await?;
181
182 Ok(OAuthAuthorizeTemplate {
183 csrf_token: Some(csrf_token),
184 session_user,
185 app_name: app.name,
186 client_id: client_id.to_string(),
187 redirect_uri: redirect_uri.to_string(),
188 state: state_param.to_string(),
189 code_challenge: code_challenge.to_string(),
190 code_challenge_method: code_challenge_method.to_string(),
191 error_message: None,
192 }
193 .into_response())
194 }
195
196 // ── POST /oauth/authorize ──
197
198 #[tracing::instrument(skip_all, name = "oauth::authorize_post")]
199 async fn authorize_post(
200 State(state): State<AppState>,
201 MaybeUserVerified(session_user): MaybeUserVerified,
202 session: Session,
203 Form(form): Form<AuthorizeForm>,
204 ) -> Result<Response> {
205 // Validate CSRF via the consuming variant — returns the sealed witness
206 // type, so a future refactor that strips the validation call from this
207 // handler fails to compile rather than silently un-gating the mutation.
208 let _validated = csrf::validate_token_consuming(&session, &form.csrf_token).await?;
209
210 // Cap the size of attacker-controlled fields before they get persisted
211 // (state goes into the auth_codes row + the redirect URL; code_challenge
212 // is fixed-length base64url of a SHA-256). Unbounded `state` lets a
213 // malicious client store arbitrary blobs in the DB through the OAuth flow.
214 if form.state.len() > 1024 {
215 return Err(AppError::BadRequest("state parameter too long (max 1024 bytes)".to_string()));
216 }
217 // S256 challenges are exactly 43 base64url chars (no padding). Allow 44
218 // for clients that include the trailing `=`. Anything wildly larger is a
219 // malformed challenge that would never verify.
220 if form.code_challenge.len() > 44 {
221 return Err(AppError::BadRequest("code_challenge has invalid length".to_string()));
222 }
223
224 if form.code_challenge_method != "S256" {
225 return Err(AppError::BadRequest("code_challenge_method must be 'S256'".to_string()));
226 }
227
228 // Look up app
229 let app = db::synckit::get_sync_app_by_api_key(&state.db, &form.client_id)
230 .await?
231 .ok_or_else(|| AppError::BadRequest("Unknown client_id".to_string()))?;
232
233 if !validate_redirect_uri(&state.db, app.id, &form.redirect_uri).await? {
234 return Err(AppError::BadRequest("Invalid redirect_uri".to_string()));
235 }
236
237 let csrf_token = csrf::get_or_create_token(&session).await?;
238
239 // Session revocation/suspension is checked by MaybeUserVerified at extraction.
240 // For OAuth grants specifically, also require a tracking ID — legacy
241 // sessions predating session tracking must re-authenticate via password.
242 let has_tracking = session
243 .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
244 .await
245 .ok()
246 .flatten()
247 .is_some();
248 let validated_session_user = session_user.as_ref().filter(|_| has_tracking);
249
250 let user_id = if let Some(user) = validated_session_user {
251 // Already logged in via validated MNW session — skip password check
252 user.id
253 } else {
254 // Must authenticate with credentials
255 let login = form.login.as_deref().unwrap_or("");
256 let password = form.password.as_deref().unwrap_or("");
257
258 if login.is_empty() || password.is_empty() {
259 return Ok(render_authorize_error(
260 Some(csrf_token),
261 session_user,
262 &app.name,
263 &form,
264 "Username/email and password are required",
265 ));
266 }
267
268 // Find user by email or username
269 let user = if login.contains('@') {
270 let email = db::Email::new(login)
271 .map_err(|_| AppError::BadRequest("Invalid email".to_string()))?;
272 db::users::get_user_by_email(&state.db, &email).await?
273 } else {
274 let username = Username::new(login).map_err(|_| AppError::BadRequest("Invalid username".to_string()))?;
275 db::users::get_user_by_username(&state.db, &username).await?
276 };
277
278 let user = match user {
279 Some(u) => u,
280 None => {
281 // Perform a dummy hash verification to prevent timing-based user enumeration
282 let _ = verify_password("dummy", &DUMMY_HASH);
283 return Ok(render_authorize_error(
284 Some(csrf_token),
285 session_user,
286 &app.name,
287 &form,
288 "Invalid username/email or password",
289 ));
290 }
291 };
292
293 // Check lockout
294 if let Some(locked_until) = user.locked_until
295 && locked_until > chrono::Utc::now()
296 {
297 let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1;
298 return Ok(render_authorize_error(
299 Some(csrf_token),
300 session_user,
301 &app.name,
302 &form,
303 &format!("Account is locked. Try again in {} minute(s).", remaining),
304 ));
305 }
306
307 // Cap password length to prevent DoS via Argon2 on very long inputs
308 if password.len() > 128 {
309 return Ok(render_authorize_error(
310 Some(csrf_token),
311 session_user,
312 &app.name,
313 &form,
314 "Invalid username/email or password",
315 ));
316 }
317
318 // Verify password
319 if !verify_password(password, &user.password_hash)? {
320 let result = db::auth::increment_failed_login(
321 &state.db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES,
322 ).await?;
323
324 if result.just_locked {
325 return Ok(render_authorize_error(
326 Some(csrf_token),
327 session_user,
328 &app.name,
329 &form,
330 &format!(
331 "Too many failed attempts. Account locked for {} minutes.",
332 LOCKOUT_MINUTES
333 ),
334 ));
335 }
336
337 return Ok(render_authorize_error(
338 Some(csrf_token),
339 session_user,
340 &app.name,
341 &form,
342 "Invalid username/email or password",
343 ));
344 }
345
346 // Successful auth — reset failed attempts
347 db::auth::reset_failed_login(&state.db, user.id).await?;
348
349 // Block suspended or deactivated users
350 if user.is_suspended() || user.is_deactivated() {
351 return Ok(render_authorize_error(
352 Some(csrf_token),
353 session_user,
354 &app.name,
355 &form,
356 "This account is not active.",
357 ));
358 }
359
360 // If user has TOTP 2FA enabled, reject — they must log in via the main site first
361 if user.totp_enabled {
362 return Ok(render_authorize_error(
363 Some(csrf_token),
364 session_user,
365 &app.name,
366 &form,
367 "This account has two-factor authentication enabled. Please log in at makenot.work first, then return here to authorize the app.",
368 ));
369 }
370
371 user.id
372 };
373
374 // Generate authorization code
375 let code = generate_oauth_code();
376 let expires_at = chrono::Utc::now()
377 + chrono::Duration::seconds(constants::OAUTH_CODE_EXPIRY_SECS);
378
379 db::oauth::create_oauth_code(
380 &state.db,
381 &code,
382 app.id,
383 user_id,
384 &form.code_challenge,
385 &form.code_challenge_method,
386 &form.redirect_uri,
387 expires_at,
388 )
389 .await?;
390
391 // Redirect back to the app's callback with URL-encoded parameters
392 let separator = if form.redirect_uri.contains('?') { "&" } else { "?" };
393 let redirect_url = format!(
394 "{}{}code={}&state={}",
395 form.redirect_uri,
396 separator,
397 urlencoding::encode(&code),
398 urlencoding::encode(&form.state),
399 );
400
401 Ok(Redirect::to(&redirect_url).into_response())
402 }
403
404 // ── POST /oauth/token ──
405
406 #[tracing::instrument(skip_all, name = "oauth::token_exchange")]
407 async fn token_exchange(
408 State(state): State<AppState>,
409 axum::Form(req): axum::Form<TokenRequest>,
410 ) -> Result<impl IntoResponse> {
411 if req.grant_type != "authorization_code" {
412 return Err(AppError::BadRequest("grant_type must be 'authorization_code'".to_string()));
413 }
414
415 crate::validation::validate_synckit_key(&req.key)?;
416
417 let secret = state
418 .config
419 .synckit_jwt_secret
420 .as_deref()
421 .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?;
422
423 // Atomically consume code (must exist, not expired, not used).
424 // Single UPDATE...RETURNING prevents TOCTOU race on concurrent requests.
425 let oauth_code = db::oauth::consume_oauth_code(&state.db, &req.code)
426 .await?
427 .ok_or(AppError::BadRequest("Invalid or expired authorization code".to_string()))?;
428
429 // Verify client_id matches the app that the code was issued for
430 let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.client_id)
431 .await?
432 .ok_or(AppError::BadRequest("Unknown client_id".to_string()))?;
433
434 if app.id != oauth_code.app_id {
435 return Err(AppError::BadRequest("client_id does not match".to_string()));
436 }
437
438 // Verify redirect_uri matches exactly
439 if req.redirect_uri != oauth_code.redirect_uri {
440 return Err(AppError::BadRequest("redirect_uri does not match".to_string()));
441 }
442
443 // Verify PKCE method matches what the authorize step recorded. We only
444 // accept S256 at authorize time, but pinning it here too means a future
445 // change that loosens the authorize check can't silently downgrade
446 // verification to `plain` (where code_verifier == code_challenge and
447 // the SHA-256 step below would never run). Defense in depth.
448 if oauth_code.code_challenge_method != "S256" {
449 return Err(AppError::BadRequest(
450 "Unsupported PKCE method on authorization code".to_string(),
451 ));
452 }
453
454 // Verify PKCE: SHA256(code_verifier) must equal stored code_challenge
455 // code_challenge is URL-safe base64 no-pad of SHA256(verifier)
456 let mut hasher = Sha256::new();
457 hasher.update(req.code_verifier.as_bytes());
458 let digest = hasher.finalize();
459 let computed_challenge = base64_url_nopad_encode(&digest);
460
461 if !crate::helpers::constant_time_compare(&computed_challenge, &oauth_code.code_challenge) {
462 return Err(AppError::BadRequest("PKCE verification failed".to_string()));
463 }
464
465 let token = synckit_auth::create_sync_token(
466 secret,
467 oauth_code.user_id,
468 oauth_code.app_id,
469 &req.key,
470 )?;
471
472 Ok(Json(TokenResponse {
473 access_token: token,
474 token_type: "Bearer".to_string(),
475 expires_in: constants::SYNCKIT_JWT_EXPIRY_SECS,
476 user_id: oauth_code.user_id,
477 app_id: oauth_code.app_id,
478 }))
479 }
480
481 /// URL-safe base64 encoding without padding (RFC 4648 Section 5).
482 fn base64_url_nopad_encode(data: &[u8]) -> String {
483 use base64::Engine;
484 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
485 }
486
487 // ── GET /oauth/userinfo ──
488 //
489 // Canonical "what is this user entitled to on MNW" endpoint for external
490 // implementers of "Log in with MNW". Always returns fresh state from the
491 // database — implementers cache client-side and pull-refresh on demand.
492 //
493 // The `perks` object is the extension point: new capabilities are added here
494 // (and to `CreatorTier::features`) as they ship. See `docs/oauth_integration.md`.
495
496 #[derive(Serialize)]
497 struct UserinfoResponse {
498 user_id: UserId,
499 username: String,
500 display_name: Option<String>,
501 avatar_url: Option<String>,
502 perks: UserPerks,
503 }
504
505 #[derive(Serialize)]
506 struct UserPerks {
507 /// Active Fan+ consumer subscription.
508 fan_plus: bool,
509 /// Has an active creator subscription at any tier.
510 is_creator: bool,
511 /// Structured creator tier info, present when `is_creator` is true.
512 creator_tier: Option<CreatorTierInfo>,
513 }
514
515 #[derive(Serialize)]
516 struct CreatorTierInfo {
517 tier: CreatorTier,
518 features: &'static [&'static str],
519 }
520
521 #[tracing::instrument(skip_all, name = "oauth::userinfo")]
522 async fn userinfo(
523 State(state): State<AppState>,
524 user: std::result::Result<synckit_auth::SyncUser, AppError>,
525 ) -> impl IntoResponse {
526 let user = match user {
527 Ok(u) => u,
528 Err(_) => {
529 return (
530 StatusCode::UNAUTHORIZED,
531 Json(serde_json::json!({"error": "invalid_token"})),
532 ).into_response();
533 }
534 };
535
536 let db_user = match db::users::get_user_by_id(&state.db, user.user_id).await {
537 Ok(Some(u)) => u,
538 _ => {
539 return (
540 StatusCode::UNAUTHORIZED,
541 Json(serde_json::json!({"error": "user_not_found"})),
542 ).into_response();
543 }
544 };
545
546 let fan_plus = db::fan_plus::is_fan_plus_active(&state.db, db_user.id)
547 .await
548 .unwrap_or(false);
549
550 let creator_tier = db_user
551 .creator_tier
552 .as_deref()
553 .and_then(|s| s.parse::<CreatorTier>().ok());
554
555 let perks = UserPerks {
556 fan_plus,
557 is_creator: creator_tier.is_some(),
558 creator_tier: creator_tier.map(|tier| CreatorTierInfo {
559 tier,
560 features: tier.features(),
561 }),
562 };
563
564 Json(UserinfoResponse {
565 user_id: db_user.id,
566 username: db_user.username.to_string(),
567 display_name: db_user.display_name,
568 avatar_url: db_user.avatar_url,
569 perks,
570 }).into_response()
571 }
572
573 // ── Router ──
574
575 pub fn oauth_routes() -> CsrfRouter<AppState> {
576 let authorize_rate_limit = crate::helpers::rate_limiter_ms(constants::OAUTH_RATE_LIMIT_MS, constants::OAUTH_RATE_LIMIT_BURST);
577 let token_rate_limit = crate::helpers::rate_limiter_ms(constants::OAUTH_TOKEN_RATE_LIMIT_MS, constants::OAUTH_TOKEN_RATE_LIMIT_BURST);
578
579 let authorize_routes = CsrfRouter::new()
580 .route_get("/oauth/authorize", get(authorize_get))
581 .route("/oauth/authorize", post_csrf_skip("pre-auth OAuth authorize endpoint", authorize_post))
582 .route_layer(GovernorLayer {
583 config: authorize_rate_limit,
584 });
585
586 let token_routes = CsrfRouter::new()
587 .route("/oauth/token", post_csrf_skip("pre-auth OAuth token exchange", token_exchange))
588 .route_layer(GovernorLayer {
589 config: token_rate_limit,
590 });
591
592 authorize_routes
593 .merge(token_routes)
594 .route_get("/oauth/userinfo", get(userinfo))
595 }
596