max / makenotwork
4 files changed,
+318 insertions,
-1 deletion
| @@ -0,0 +1,116 @@ | |||
| 1 | + | # OAuth Integration Guide for External Implementers | |
| 2 | + | ||
| 3 | + | How to integrate "Log in with MNW" into an external service (Multithreaded, future MNW-integrated services). | |
| 4 | + | ||
| 5 | + | This document is the contract. Implementers should not reverse-engineer entitlement logic from MNW source code — read the `perks` object and the rules below. | |
| 6 | + | ||
| 7 | + | --- | |
| 8 | + | ||
| 9 | + | ## Flow | |
| 10 | + | ||
| 11 | + | Standard OAuth 2.0 Authorization Code + PKCE (RFC 7636). See `src/routes/oauth.rs` for the server side. | |
| 12 | + | ||
| 13 | + | 1. Redirect the user to `GET /oauth/authorize?response_type=code&client_id=...&redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256`. | |
| 14 | + | 2. User authenticates with MNW, gets redirected to your `redirect_uri` with `?code=...&state=...`. | |
| 15 | + | 3. Exchange the code at `POST /oauth/token` (form-encoded): `grant_type=authorization_code&code=...&redirect_uri=...&code_verifier=...&client_id=...`. | |
| 16 | + | 4. Use the returned `access_token` as a Bearer token on subsequent requests. | |
| 17 | + | ||
| 18 | + | --- | |
| 19 | + | ||
| 20 | + | ## `GET /oauth/userinfo` | |
| 21 | + | ||
| 22 | + | Canonical "what is this user entitled to" endpoint. Always returns fresh state from the MNW database — no MNW-side caching. | |
| 23 | + | ||
| 24 | + | **Auth:** `Authorization: Bearer <access_token>` | |
| 25 | + | ||
| 26 | + | **Response (200):** | |
| 27 | + | ```json | |
| 28 | + | { | |
| 29 | + | "user_id": "uuid", | |
| 30 | + | "username": "alice", | |
| 31 | + | "display_name": "Alice Example", | |
| 32 | + | "avatar_url": "https://...", | |
| 33 | + | "perks": { | |
| 34 | + | "fan_plus": false, | |
| 35 | + | "is_creator": true, | |
| 36 | + | "creator_tier": { | |
| 37 | + | "tier": "big_files", | |
| 38 | + | "features": ["file_uploads", "large_files"] | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ``` | |
| 43 | + | ||
| 44 | + | **Errors:** | |
| 45 | + | - `401 invalid_token` — missing, malformed, or revoked access token. | |
| 46 | + | - `401 user_not_found` — token valid but user deactivated/deleted. | |
| 47 | + | ||
| 48 | + | --- | |
| 49 | + | ||
| 50 | + | ## The `perks` contract | |
| 51 | + | ||
| 52 | + | `perks` is the extension point. Implementers consume the fields they care about. New capabilities are added here as they ship — old fields are not renamed or removed without coordination. | |
| 53 | + | ||
| 54 | + | ### `fan_plus: bool` | |
| 55 | + | ||
| 56 | + | True iff the user has an active Fan+ consumer subscription (`fan_plus_subscriptions.status = 'active'`). | |
| 57 | + | ||
| 58 | + | ### `is_creator: bool` | |
| 59 | + | ||
| 60 | + | True iff the user has an active creator subscription at any tier. Equivalent to `creator_tier != null` and provided for ergonomic boolean checks. | |
| 61 | + | ||
| 62 | + | ### `creator_tier: { tier, features } | null` | |
| 63 | + | ||
| 64 | + | `null` when the user is not a creator. Otherwise: | |
| 65 | + | ||
| 66 | + | - `tier`: snake-cased tier name (`"basic" | "small_files" | "big_files" | "everything"`). Implementers **should not** gate features on this string — gate on `features` instead. The tier names exist for display and analytics. | |
| 67 | + | - `features`: array of capability strings backed by live platform behavior. Today: `"file_uploads"` (SmallFiles+), `"large_files"` (BigFiles+). New capabilities (e.g., `"live_streaming"`) are added when they actually launch — never as "coming soon" placeholders. | |
| 68 | + | ||
| 69 | + | ### Why structured, not flat booleans | |
| 70 | + | ||
| 71 | + | A flat `creator_tier: "big_files"` would force every implementer to memorize the tier lineup. Adding a new tier (or splitting an existing one) would break callers. The structured form means implementers gate on capabilities, and the platform owns the mapping. | |
| 72 | + | ||
| 73 | + | --- | |
| 74 | + | ||
| 75 | + | ## Refresh ergonomics | |
| 76 | + | ||
| 77 | + | Implementers cache `perks` per session, not per request. State changes (Fan+ subscribe, tier upgrade, cancellation) become visible only after refresh. | |
| 78 | + | ||
| 79 | + | **Refresh on:** | |
| 80 | + | ||
| 81 | + | 1. Login (initial `userinfo` call after token exchange). | |
| 82 | + | 2. Session cycle / token refresh. | |
| 83 | + | 3. **On demand** — when the user takes an action that should have changed perks. Example: after returning from a Fan+ checkout flow. Hit `userinfo` again and overwrite cached fields. | |
| 84 | + | ||
| 85 | + | There is no push notification of perk changes. If pull-on-demand isn't sufficient, an `/internal/perks-webhooks/register` API will be added — talk to MNW maintainers before relying on stale data. | |
| 86 | + | ||
| 87 | + | --- | |
| 88 | + | ||
| 89 | + | ## Recommended implementer pattern (Rust) | |
| 90 | + | ||
| 91 | + | ```rust | |
| 92 | + | struct CachedSession { | |
| 93 | + | user_id: Uuid, | |
| 94 | + | username: String, | |
| 95 | + | perks: Perks, | |
| 96 | + | fetched_at: DateTime<Utc>, | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // One place that calls /oauth/userinfo and overwrites cached perks. | |
| 100 | + | async fn refresh_session(session_id: &str) -> Result<()> { /* … */ } | |
| 101 | + | ||
| 102 | + | // Authorization check used everywhere. | |
| 103 | + | fn effective_plus(perks: &Perks) -> bool { | |
| 104 | + | perks.fan_plus || perks.is_creator | |
| 105 | + | } | |
| 106 | + | ``` | |
| 107 | + | ||
| 108 | + | Every gated route reads `effective_plus(&session.perks)`. Refresh-on-demand routes call `refresh_session` first. | |
| 109 | + | ||
| 110 | + | --- | |
| 111 | + | ||
| 112 | + | ## Stability rules | |
| 113 | + | ||
| 114 | + | - **Additive only.** New `perks` fields are non-breaking; missing fields default to "absent" (false / null / empty array). | |
| 115 | + | - **No renames.** Once a `features` string ships, it stays. Mistakes are deprecated, not deleted. | |
| 116 | + | - **No silent semantics changes.** Behavior changes to existing capability strings (e.g., raising the size threshold for `large_files`) are coordinated with implementers. |
| @@ -536,6 +536,20 @@ impl CreatorTier { | |||
| 536 | 536 | pub fn allows_file_uploads(&self) -> bool { | |
| 537 | 537 | !matches!(self, Self::Basic) | |
| 538 | 538 | } | |
| 539 | + | ||
| 540 | + | /// Capability strings exposed to external OAuth implementers via `/oauth/userinfo`. | |
| 541 | + | /// | |
| 542 | + | /// Implementers gate features on these strings rather than tier names so the | |
| 543 | + | /// tier lineup can change without breaking callers. Only ship strings backed by | |
| 544 | + | /// live behavior; new capabilities are added when they actually launch. | |
| 545 | + | pub fn features(&self) -> &'static [&'static str] { | |
| 546 | + | match self { | |
| 547 | + | Self::Basic => &[], | |
| 548 | + | Self::SmallFiles => &["file_uploads"], | |
| 549 | + | Self::BigFiles => &["file_uploads", "large_files"], | |
| 550 | + | Self::Everything => &["file_uploads", "large_files"], | |
| 551 | + | } | |
| 552 | + | } | |
| 539 | 553 | } | |
| 540 | 554 | ||
| 541 | 555 | // ── App Sync Tiers ── | |
| @@ -1233,6 +1247,14 @@ mod tests { | |||
| 1233 | 1247 | } | |
| 1234 | 1248 | ||
| 1235 | 1249 | #[test] | |
| 1250 | + | fn creator_tier_features_track_live_capabilities() { | |
| 1251 | + | assert!(CreatorTier::Basic.features().is_empty()); | |
| 1252 | + | assert_eq!(CreatorTier::SmallFiles.features(), &["file_uploads"]); | |
| 1253 | + | assert_eq!(CreatorTier::BigFiles.features(), &["file_uploads", "large_files"]); | |
| 1254 | + | assert_eq!(CreatorTier::Everything.features(), &["file_uploads", "large_files"]); | |
| 1255 | + | } | |
| 1256 | + | ||
| 1257 | + | #[test] | |
| 1236 | 1258 | fn app_sync_tier_round_trip() { | |
| 1237 | 1259 | assert_eq!(AppSyncTier::Standard.to_string(), "standard"); | |
| 1238 | 1260 | assert_eq!("light".parse::<AppSyncTier>().unwrap(), AppSyncTier::Light); |
| @@ -21,7 +21,7 @@ use crate::{ | |||
| 21 | 21 | auth::{verify_password, MaybeUser, SESSION_TRACKING_KEY}, | |
| 22 | 22 | constants::{self, LOCKOUT_MINUTES, MAX_LOGIN_ATTEMPTS}, | |
| 23 | 23 | csrf, | |
| 24 | - | db::{self, SyncAppId, UserId, UserSessionId, Username}, | |
| 24 | + | db::{self, CreatorTier, SyncAppId, UserId, UserSessionId, Username}, | |
| 25 | 25 | error::{AppError, Result}, | |
| 26 | 26 | synckit_auth, | |
| 27 | 27 | templates::OAuthAuthorizeTemplate, | |
| @@ -485,6 +485,13 @@ fn base64_url_nopad_encode(data: &[u8]) -> String { | |||
| 485 | 485 | } | |
| 486 | 486 | ||
| 487 | 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`. | |
| 488 | 495 | ||
| 489 | 496 | #[derive(Serialize)] | |
| 490 | 497 | struct UserinfoResponse { | |
| @@ -492,6 +499,23 @@ struct UserinfoResponse { | |||
| 492 | 499 | username: String, | |
| 493 | 500 | display_name: Option<String>, | |
| 494 | 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], | |
| 495 | 519 | } | |
| 496 | 520 | ||
| 497 | 521 | #[tracing::instrument(skip_all, name = "oauth::userinfo")] | |
| @@ -519,11 +543,30 @@ async fn userinfo( | |||
| 519 | 543 | } | |
| 520 | 544 | }; | |
| 521 | 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 | + | ||
| 522 | 564 | Json(UserinfoResponse { | |
| 523 | 565 | user_id: db_user.id, | |
| 524 | 566 | username: db_user.username.to_string(), | |
| 525 | 567 | display_name: db_user.display_name, | |
| 526 | 568 | avatar_url: db_user.avatar_url, | |
| 569 | + | perks, | |
| 527 | 570 | }).into_response() | |
| 528 | 571 | } | |
| 529 | 572 |
| @@ -294,3 +294,139 @@ async fn oauth_invalid_credentials() { | |||
| 294 | 294 | resp.text | |
| 295 | 295 | ); | |
| 296 | 296 | } | |
| 297 | + | ||
| 298 | + | // ── Userinfo (`/oauth/userinfo`) ── | |
| 299 | + | // | |
| 300 | + | // `userinfo` is the canonical entitlement endpoint for external "Log in with MNW" | |
| 301 | + | // implementers. Tests cover the `perks` contract: shape on a fresh user, on a | |
| 302 | + | // creator, and on a Fan+ subscriber. | |
| 303 | + | ||
| 304 | + | /// Run the full authorize → token flow and return the Bearer access token. | |
| 305 | + | async fn obtain_access_token(h: &mut TestHarness, username: &str, password: &str) -> String { | |
| 306 | + | let user_id = sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE username = $1") | |
| 307 | + | .bind(username) | |
| 308 | + | .fetch_one(&h.db) | |
| 309 | + | .await | |
| 310 | + | .expect("user lookup"); | |
| 311 | + | ||
| 312 | + | let (_app_id, client_id) = create_sync_app(&h.db, user_id).await; | |
| 313 | + | let (verifier, challenge) = generate_pkce(); | |
| 314 | + | ||
| 315 | + | h.client.post_form("/logout", "").await; | |
| 316 | + | let (code, _state) = authorize(h, &client_id, &challenge, username, password).await; | |
| 317 | + | ||
| 318 | + | let resp = h | |
| 319 | + | .client | |
| 320 | + | .post_form( | |
| 321 | + | "/oauth/token", | |
| 322 | + | &format!( | |
| 323 | + | "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}", | |
| 324 | + | code, | |
| 325 | + | urlencoding::encode("http://127.0.0.1:9999/callback"), | |
| 326 | + | verifier, | |
| 327 | + | client_id, | |
| 328 | + | ), | |
| 329 | + | ) | |
| 330 | + | .await; | |
| 331 | + | assert_eq!(resp.status.as_u16(), 200, "Token exchange failed: {}", resp.text); | |
| 332 | + | let token: TokenResponse = resp.json(); | |
| 333 | + | token.access_token | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | #[derive(Deserialize)] | |
| 337 | + | struct UserinfoResp { | |
| 338 | + | user_id: UserId, | |
| 339 | + | username: String, | |
| 340 | + | display_name: Option<String>, | |
| 341 | + | avatar_url: Option<String>, | |
| 342 | + | perks: PerksResp, | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[derive(Deserialize)] | |
| 346 | + | struct PerksResp { | |
| 347 | + | fan_plus: bool, | |
| 348 | + | is_creator: bool, | |
| 349 | + | creator_tier: Option<CreatorTierResp>, | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | #[derive(Deserialize)] | |
| 353 | + | struct CreatorTierResp { | |
| 354 | + | tier: String, | |
| 355 | + | features: Vec<String>, | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | #[tokio::test] | |
| 359 | + | async fn oauth_userinfo_default() { | |
| 360 | + | let mut h = TestHarness::new().await; | |
| 361 | + | let user_id = h.signup("uinfo_def", "uinfo_def@test.com", "Password1!").await; | |
| 362 | + | ||
| 363 | + | let token = obtain_access_token(&mut h, "uinfo_def", "Password1!").await; | |
| 364 | + | h.client.set_bearer_token(&token); | |
| 365 | + | let resp = h.client.get("/oauth/userinfo").await; | |
| 366 | + | assert_eq!(resp.status.as_u16(), 200, "userinfo failed: {}", resp.text); | |
| 367 | + | ||
| 368 | + | let info: UserinfoResp = resp.json(); | |
| 369 | + | assert_eq!(info.user_id, user_id); | |
| 370 | + | assert_eq!(info.username, "uinfo_def"); | |
| 371 | + | assert!(info.display_name.is_none() || info.display_name.as_deref() == Some("")); | |
| 372 | + | let _ = info.avatar_url; | |
| 373 | + | assert!(!info.perks.fan_plus); | |
| 374 | + | assert!(!info.perks.is_creator); | |
| 375 | + | assert!(info.perks.creator_tier.is_none()); | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | #[tokio::test] | |
| 379 | + | async fn oauth_userinfo_creator_tier() { | |
| 380 | + | let mut h = TestHarness::new().await; | |
| 381 | + | let user_id = h.signup("uinfo_creator", "uinfo_creator@test.com", "Password1!").await; | |
| 382 | + | sqlx::query("UPDATE users SET creator_tier = 'big_files' WHERE id = $1") | |
| 383 | + | .bind(user_id) | |
| 384 | + | .execute(&h.db) | |
| 385 | + | .await | |
| 386 | + | .expect("set tier"); | |
| 387 | + | ||
| 388 | + | let token = obtain_access_token(&mut h, "uinfo_creator", "Password1!").await; | |
| 389 | + | h.client.set_bearer_token(&token); | |
| 390 | + | let resp = h.client.get("/oauth/userinfo").await; | |
| 391 | + | assert_eq!(resp.status.as_u16(), 200); | |
| 392 | + | ||
| 393 | + | let info: UserinfoResp = resp.json(); | |
| 394 | + | assert!(info.perks.is_creator); | |
| 395 | + | assert!(!info.perks.fan_plus); | |
| 396 | + | let tier = info.perks.creator_tier.expect("creator_tier populated"); | |
| 397 | + | assert_eq!(tier.tier, "big_files"); | |
| 398 | + | assert!(tier.features.iter().any(|f| f == "file_uploads")); | |
| 399 | + | assert!(tier.features.iter().any(|f| f == "large_files")); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | #[tokio::test] | |
| 403 | + | async fn oauth_userinfo_fan_plus() { | |
| 404 | + | let mut h = TestHarness::new().await; | |
| 405 | + | let user_id = h.signup("uinfo_fp", "uinfo_fp@test.com", "Password1!").await; | |
| 406 | + | sqlx::query( | |
| 407 | + | "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) \ | |
| 408 | + | VALUES ($1, 'sub_uinfo_fp', 'cus_uinfo_fp', 'active')", | |
| 409 | + | ) | |
| 410 | + | .bind(user_id) | |
| 411 | + | .execute(&h.db) | |
| 412 | + | .await | |
| 413 | + | .expect("seed fan_plus"); | |
| 414 | + | ||
| 415 | + | let token = obtain_access_token(&mut h, "uinfo_fp", "Password1!").await; | |
| 416 | + | h.client.set_bearer_token(&token); | |
| 417 | + | let resp = h.client.get("/oauth/userinfo").await; | |
| 418 | + | assert_eq!(resp.status.as_u16(), 200); | |
| 419 | + | ||
| 420 | + | let info: UserinfoResp = resp.json(); | |
| 421 | + | assert!(info.perks.fan_plus); | |
| 422 | + | assert!(!info.perks.is_creator); | |
| 423 | + | assert!(info.perks.creator_tier.is_none()); | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | #[tokio::test] | |
| 427 | + | async fn oauth_userinfo_unauthorized() { | |
| 428 | + | let mut h = TestHarness::new().await; | |
| 429 | + | // No bearer token set — extractor rejects. | |
| 430 | + | let resp = h.client.get("/oauth/userinfo").await; | |
| 431 | + | assert_eq!(resp.status.as_u16(), 401); | |
| 432 | + | } |