Swimlanes.io is a free webapp for making sequence diagrams. You simply edit the text on the left and the diagram is updated in real time. You can download your sequence diagrams as images or distribute with a link.
title: Lenny OAuth 2.0 PKCE + OTP Authentication Flow order: OPDS Client, Lenny Server, Open Library, Lenny DB note: **Actors** * **OPDS Client** — A registered reader app (e.g. reader.archive.org, etc.). Public client, no secret. * **Lenny Server** — Resource Server + built-in OAuth Authorization Server. Serves its own login UI. * **Open Library** — External OTP provider for identity verification. * **Lenny DB** — Stores Clients, AuthCodes, Items, Loans. =: **Phase 0 — Client Registration (One-Time Setup)** note OPDS Client, Lenny DB: Before any auth flow can happen, the client must be **registered** with Lenny's OAuth server. OPDS Client -> Lenny Server: Register as a client (provide app name + redirect_uri) Lenny Server -> Lenny DB: Store in `Client` table: `client_id` = generated identifier `redirect_uris` = registered callback URL(s) `is_confidential` = False (public OPDS client) `client_secret_hash` = NULL (no secret for public clients) Lenny DB --> Lenny Server: Client saved Lenny Server --> OPDS Client: Share `client_id` (public, not secret) note OPDS Client: Client stores `client_id` in its app config. No `client_secret` — **PKCE replaces the need for it**. note Lenny Server: For well-known OPDS readers (reader.archive.org, etc.) these can be **pre-registered** via `scripts/register_client.py` or a documented well-known clients list. -: note: Hadrien (OPDS 2.0 designer) confirmed: * Client registration **is required** for PKCE * Server **must serve its own OAuth UI** (login page) * Well-known clients list minimizes registration friction * OPDS Auth draft will add proper `rel` values for PKCE discoverability _: **Phase 1 — Discovery** OPDS Client -> Lenny Server: GET /v1/api/opds Lenny Server --> OPDS Client: OPDS Catalog (includes Authentication link with `rel` for auth) OPDS Client -> Lenny Server: GET /v1/oauth/implicit Lenny Server --> OPDS Client: OPDS Authentication Document (JSON) `authenticate` → /v1/oauth/authorize `access-token` → /v1/oauth/token note OPDS Client: Client now knows **where** to send users for login and **where** to exchange codes for tokens. The `client_id` was obtained during Registration (Phase 0). -: **Phase 2 — Authorization Request + PKCE** note OPDS Client: Client generates **PKCE pair** locally: `code_verifier` = random(43–128 chars) — **kept secret, never sent to server** `code_challenge` = BASE64URL(SHA256(code_verifier)) — **sent to server** OPDS Client -> Lenny Server: GET /v1/oauth/authorize ?response_type=code &client_id=reader-archive-org &redirect_uri=https://reader.archive.org/callback &state=random-csrf-token &code_challenge=BASE64URL(SHA256(verifier)) &code_challenge_method=S256 note Lenny Server: `code_verifier` is **NOT** sent here. Only the `code_challenge` (the hash) is sent. The verifier stays with the client until Phase 5. Lenny Server -> Lenny DB: Client.get_by_id("reader-archive-org") Lenny DB --> Lenny Server: Client record (redirect_uris, is_confidential) Lenny Server -> Lenny Server: client.is_valid_redirect_uri("https://reader.archive.org/callback") if: Missing required parameters (e.g. client_id, redirect_uri) Lenny Server -> OPDS Client: **400** JSON Error (invalid_request) else if: Invalid client_id or redirect_uri not registered Lenny Server -> OPDS Client: **400** Bad Request end if: No session cookie (user not logged in) Lenny Server --> OPDS Client: **200** otp_issue.html (Lenny **serves its own login UI** — email input form) else: Has valid session cookie (already logged in) note Lenny Server: Skip to Phase 4 end -: **Phase 3 — OTP Authentication (Open Library)** note: User interacts with **Lenny's server-rendered login page**. The OPDS Client (reader app) just displays this page in a webview/browser. Lenny delegates identity verification to Open Library's OTP service. OPDS Client -> Lenny Server: POST /v1/oauth/authorize form body: `email=user@example.com` (all OAuth params preserved in form/URL) Lenny Server -> Open Library: POST /account/otp/issue query string: `email=user@example.com&ip=client_ip` note Open Library: **Note**: Open Library's OTP API accepts params via query string — a known PII exposure limitation. IP forwarding (`ip=client_ip`) is a privacy/compliance concern. Open Library --> Lenny Server: OTP sent to user's email Lenny Server --> OPDS Client: **200** otp_redeem.html (OTP input form) ...: {fas-envelope} User checks email, receives 6-digit OTP OPDS Client -> Lenny Server: POST /v1/oauth/authorize form body: `email=user@example.com&otp=123456` Lenny Server -> Open Library: POST /account/otp/redeem query string: `email=user@example.com&ip=client_ip&otp=123456` Open Library --> Lenny Server: {fas-check} **success** (identity verified) note Lenny Server: User is now verified by Open Library. Lenny proceeds to generate the authorization code. -: **Phase 4 — Auth Code Generation** Lenny Server -> Lenny Server: encrypt_email(email) → AES-GCM with `v1:` version prefix (cached PBKDF2 key) Lenny Server -> Lenny DB: AuthCode.create( code = random 64-char token, client_id = "reader-archive-org", redirect_uri = "https://reader.archive.org/callback", email_encrypted = "v1:encrypted...", code_challenge = stored from Phase 2, state = CSRF token, expires_at = now + **5 minutes** ) Lenny DB --> Lenny Server: Auth code stored Lenny Server -> Lenny Server: Create session cookie (httponly, secure, SameSite=Lax, 7-day TTL) if: redirect_uri starts with `opds://` Lenny Server --> OPDS Client: **200** oauth_success.html (displays authorization code + state for the app to capture) else: redirect_uri is `https://` Lenny Server --> OPDS Client: **302** → redirect_uri?code=abc123&state=csrf-token end note OPDS Client: Client now has the **authorization code** + original **state**. The `code_verifier` is still stored locally from Phase 2. Time to exchange them for a real token. =: **Phase 5 — Token Exchange (PKCE Verified)** note OPDS Client: Now the client sends both: * The `code` it just received (from Phase 4) * The `code_verifier` it kept secret (from Phase 2) This is the moment PKCE proves the same client that started the flow is finishing it. OPDS Client => Lenny Server: POST /v1/oauth/token (rate limited: 5/min) grant_type = authorization_code &code = abc123 &client_id = reader-archive-org &redirect_uri = https://reader.archive.org/callback &code_verifier = **THE ORIGINAL SECRET FROM PHASE 2** group: {fas-shield-alt} Security Checks (server-side) Lenny Server -> Lenny DB: AuthCode.mark_as_used(code) — **atomic update** Lenny DB --> Lenny Server: rows_updated (1 = success, 0 = already used) if: Code already used (**replay attack detected**) Lenny Server -x OPDS Client: **400** "Authorization code already used" end Lenny Server -> Lenny DB: AuthCode.get_by_code(code) Lenny DB --> Lenny Server: AuthCode record Lenny Server -> Lenny Server: {fas-clock} Check expiry (must be within 5 min) Lenny Server -> Lenny Server: Check client_id matches stored value Lenny Server -> Lenny Server: Check redirect_uri matches stored value note Lenny Server: **PKCE Verification** (the core security check): Does SHA256(`code_verifier`) == stored `code_challenge`? This proves the same client that started the flow is the one finishing it. An attacker who intercepted the `code` cannot pass this check because they don't have the original `code_verifier`. Lenny Server -> Lenny Server: verify_pkce(code_verifier, code_challenge, S256) if: PKCE mismatch (code intercepted by attacker) Lenny Server -x OPDS Client: **400** "PKCE verification failed" end end Lenny Server -> Lenny Server: decrypt_email(auth_code.email_encrypted) Lenny Server -> Lenny Server: generate_jwt(client_id, email, scope) Signed with `LENNY_SEED` (HS256, **1hr TTL**) Lenny Server =>> OPDS Client: **200** { access_token: "eyJhbGciOiJIUzI1NiJ9...", refresh_token: "Rt_...", token_type: "Bearer", expires_in: 3600 } note OPDS Client: Client stores the JWT. This token is used for all subsequent API calls. No more auth flow needed until token expires (1 hour). =: **Phase 6 — Authenticated OPDS Access** OPDS Client => Lenny Server: GET /v1/api/opds Authorization: Bearer eyJ... (or Cookie: session=...) Lenny Server -> Lenny Server: {fas-key} auth.verify_session_cookie(request) Lenny Server -> Lenny Server: Validates Session Cookie OR JWT Signature (LENNY_SEED) + expiry Lenny Server =>> OPDS Client: **200** OPDS Catalog (**full access**) OPDS Client => Lenny Server: POST /v1/api/items/{id}/borrow Authorization: Bearer eyJ... Lenny Server =>> OPDS Client: **200** Loan created {fas-book} OPDS Client => Lenny Server: GET /v1/api/shelf Authorization: Bearer eyJ... Lenny Server =>> OPDS Client: **200** User's borrowed books =: **Phase 7 — Token Refresh (Silent)** note OPDS Client: Access token expired (1 hour TTL). Use the **refresh token** to get a new pair without re-authenticating. OPDS Client -> Lenny Server: POST /v1/oauth/token form body: `grant_type=refresh_token` `&client_id=reader-archive-org` `&refresh_token=<stored_refresh_token>` Lenny Server -> Lenny Server: {fas-shield-alt} Validate refresh token:\n1. Not revoked\n2. Not expired (30-day TTL)\n3. client_id matches alt: Valid refresh token Lenny Server -> Lenny Server: **Revoke** old refresh token (rotation) Lenny Server -> Lenny Server: Generate new JWT access token + new refresh token note Lenny Server: **Token Rotation**: Old refresh token is immediately revoked. A new refresh token is issued alongside the new access token. This prevents replay attacks on stolen refresh tokens. Lenny Server =>> OPDS Client: **200** { access_token: "eyJ...(new)", refresh_token: "new_token...", token_type: "Bearer", expires_in: 3600 } else: Invalid / revoked / expired Lenny Server =>> OPDS Client: **400** { "error": "invalid_grant", "error_description": "..." } end note OPDS Client: Client replaces stored tokens with the new pair. Next refresh uses the NEW refresh token. -: note: **Who sends what — Summary** | What | From → To | | --- | --- | | `client_id` | Shared during registration, sent by Client in every auth request | | `code_challenge` | Generated by Client, sent to Server in Phase 2 | | `code_verifier` | Generated by Client, **kept secret**, only sent in Phase 5 | | `authorization code` | Generated by Server, sent to Client in Phase 4 | | `JWT access token` | Generated by Server, sent to Client in Phase 5 | | `refresh token` | Generated by Server in Phase 5, used by Client in Phase 7 | | `OTP` | Generated by Open Library, sent to user's email | note: **Security Mechanisms** | Mechanism | Purpose | | --- | --- | | PKCE (S256) | Prevents code interception — only the original client can exchange | | Client Registration | Server validates known `client_id` + `redirect_uri` pairs | | AES-GCM `v1:` encryption | Emails encrypted at rest, versioned for key rotation | | Atomic `mark_as_used` | Each auth code is single-use, prevents replay | | 5 min code TTL | Limits window for code theft | | Rate limit (5/min) | Protects /token from brute-force | | Session cookie | httponly, secure, SameSite=Lax | | Open Library OTP | Delegates identity verification to trusted provider | | Refresh token rotation | Each refresh revokes old token, preventing replay | | 30-day refresh TTL | Balances convenience with security exposure window |