An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

docs: add test plan for MM-77 OAuth token endpoint

+129
+129
docs/test-plans/2026-03-22-MM-77.md
··· 1 + # OAuth Token Endpoint — Human Test Plan (MM-77) 2 + 3 + Generated from: `docs/implementation-plans/2026-03-22-MM-77/` 4 + 5 + **Automated coverage:** 30/30 acceptance criteria covered by `cargo test -p relay`. 6 + **Manual verification required:** AC6.2 (signing key persistence across restarts). 7 + 8 + --- 9 + 10 + ## Prerequisites 11 + 12 + - Relay binary built and running with a **persistent** SQLite database path (not `:memory:`) 13 + - `signing_key_master_key` configured in relay config (32-byte hex or base64 key) 14 + - `public_url` configured to the relay's externally reachable URL 15 + - At least one account created (DID provisioned) on the relay 16 + - An OAuth client registered (`client_id` = HTTPS metadata URL, with valid `redirect_uris`) 17 + - An authorization code issued via `POST /oauth/authorize` (or seeded manually via DB for testing) 18 + - `cargo test -p relay` passing (all 334 tests green) 19 + - A tool capable of crafting DPoP JWTs (e.g., a small script using the `jose` CLI, or the Bruno collection) 20 + 21 + --- 22 + 23 + ## Phase 1: Signing Key Persistence Across Restarts 24 + 25 + Verifies **AC6.2**: The relay reloads the same ES256 signing key on restart, so JWTs issued before and after a restart share the same `kid`. 26 + 27 + | Step | Action | Expected | 28 + |------|--------|----------| 29 + | 1 | Start the relay binary with `signing_key_master_key` configured and a persistent SQLite DB path. Observe startup logs. | Log line: `OAuth signing key generated and persisted` with a `key_id` UUID. | 30 + | 2 | Issue a valid `POST /oauth/token` request with `grant_type=authorization_code`, valid code, code_verifier, client_id, redirect_uri, and a valid DPoP proof (with server-issued nonce). | 200 response with `access_token` JWT. | 31 + | 3 | Decode the access token JWT header (base64url-decode the first segment). Record the `kid` value. | `kid` is a UUID string. `typ` is `at+jwt`. `alg` is `ES256`. | 32 + | 4 | Stop the relay process (Ctrl-C or SIGTERM). | Process exits cleanly. | 33 + | 5 | Restart the relay with the same config and database path. Observe startup logs. | Log line: `OAuth signing key loaded from database` with the **same** `key_id` from step 1 (not `generated and persisted`). | 34 + | 6 | Issue another authorization code exchange request (new code, new DPoP proof). Decode the access token JWT header. | `kid` in the new token matches the `kid` from step 3. Both tokens were signed by the same persistent key. | 35 + 36 + --- 37 + 38 + ## Phase 2: Full Authorization Code Exchange (End-to-End) 39 + 40 + Validates the complete happy-path flow from nonce negotiation through token issuance. 41 + 42 + | Step | Action | Expected | 43 + |------|--------|----------| 44 + | 1 | Generate a P-256 keypair (client DPoP key). Compute its JWK thumbprint (RFC 7638). | A 43-character base64url thumbprint string. | 45 + | 2 | Send `POST /oauth/token` with `grant_type=authorization_code`, valid code, code_verifier, client_id, redirect_uri, and a DPoP proof **without** a `nonce` claim. | 400. Body: `{"error": "use_dpop_nonce", "error_description": "..."}`. Response includes `DPoP-Nonce` header with a 22-character base64url nonce. | 46 + | 3 | Record the nonce from step 2. Build a new DPoP proof **with** that nonce in the `nonce` claim. Re-send the same `POST /oauth/token` request. | 200. Body includes `access_token`, `token_type: "DPoP"`, `expires_in: 300`, `refresh_token` (43 chars), `scope`. Response includes a fresh `DPoP-Nonce` header. | 47 + | 4 | Decode the access token JWT. Check `header.typ`, `header.alg`, `header.kid`, `payload.sub`, `payload.cnf.jkt`, `payload.exp`. | `typ == "at+jwt"`, `alg == "ES256"`, `kid` matches relay's signing key UUID, `sub` is the authenticated DID, `cnf.jkt` matches the thumbprint from step 1, `exp ≈ iat + 300`. | 48 + | 5 | Attempt to reuse the same authorization code with a fresh nonce and DPoP proof. | 400. Body: `{"error": "invalid_grant"}`. The code was consumed in step 3. | 49 + 50 + --- 51 + 52 + ## Phase 3: Full Refresh Token Rotation (End-to-End) 53 + 54 + Validates refresh token rotation, DPoP binding enforcement, and single-use semantics. 55 + 56 + | Step | Action | Expected | 57 + |------|--------|----------| 58 + | 1 | From Phase 2 step 3, record the `refresh_token` value and the `DPoP-Nonce` header value. | Both values available. | 59 + | 2 | Build a new DPoP proof using the **same** client key from Phase 2 step 1, with `htm=POST`, `htu=<relay>/oauth/token`, and the nonce from step 1. Send `POST /oauth/token` with `grant_type=refresh_token`, `refresh_token=<value from step 1>`, `client_id=<same client_id>`. | 200. Body includes a new `access_token`, `token_type: "DPoP"`, `expires_in: 300`, a **new** `refresh_token` (different from step 1), `scope`. `DPoP-Nonce` header present. | 60 + | 3 | Decode the new access token. Verify `cnf.jkt` matches the same client key thumbprint. | Thumbprint matches — the new token is bound to the same DPoP key. | 61 + | 4 | Attempt to use the **original** refresh token from step 1 again (with fresh nonce/DPoP). | 400. Body: `{"error": "invalid_grant"}`. The original token was consumed during rotation in step 2. | 62 + | 5 | Use the new refresh token from step 2 to rotate again (fresh nonce/DPoP). | 200 with another new token pair. Confirms the rotation chain works. | 63 + 64 + --- 65 + 66 + ## Phase 4: Error Handling Validation 67 + 68 + Verifies error responses are well-formed JSON, use correct OAuth error codes, and include both `error` and `error_description` fields. 69 + 70 + | Step | Action | Expected | 71 + |------|--------|----------| 72 + | 1 | Send `POST /oauth/token` with `grant_type=client_credentials` (unsupported). | 400. Body: `{"error": "unsupported_grant_type", "error_description": "..."}`. Content-Type: `application/json`. No HTML in body. | 73 + | 2 | Send `POST /oauth/token` with no body at all. | 400. Body: `{"error": "invalid_request", "error_description": "missing required parameter: grant_type"}`. | 74 + | 3 | Send `GET /oauth/token`. | 405 Method Not Allowed. | 75 + | 4 | Send `POST /oauth/token` with `grant_type=authorization_code` and all required fields but **without** a `DPoP` header. | 400. Body: `{"error": "invalid_dpop_proof", ...}`. | 76 + | 5 | Send a valid request but with a DPoP proof whose `htm` is `GET` instead of `POST`. | 400. Body: `{"error": "invalid_dpop_proof", ...}`. | 77 + | 6 | Send a valid request but with a DPoP proof whose `iat` is 2 minutes in the past. | 400. Body: `{"error": "invalid_dpop_proof", ...}`. | 78 + 79 + --- 80 + 81 + ## Phase 5: Security Edge Cases 82 + 83 + Validates security-critical behaviors that benefit from human observation. 84 + 85 + | Step | Action | Expected | 86 + |------|--------|----------| 87 + | 1 | Send a refresh request using a **different** P-256 key than the one the token was bound to. | 400. Body: `{"error": "invalid_grant"}`. DPoP key mismatch enforced. | 88 + | 2 | Send a refresh request with the correct key but a **different** `client_id`. | 400. Body: `{"error": "invalid_grant"}`. Client ID mismatch enforced. | 89 + | 3 | Wait >24 hours (or manually expire a refresh token in the DB: `UPDATE oauth_tokens SET expires_at = datetime('now', '-1 seconds') WHERE id = ?`). Attempt to use the expired token. | 400. Body: `{"error": "invalid_grant"}`. | 90 + | 4 | Fabricate a DPoP nonce string (never issued by the server). Include it in a DPoP proof. | 400. Body: `{"error": "use_dpop_nonce", ...}` with a fresh `DPoP-Nonce` header. The fabricated nonce is rejected and a real one is issued for retry. | 91 + | 5 | Send a valid authorization_code request but with `code_verifier` set to a wrong value. | 400. Body: `{"error": "invalid_grant", "error_description": "code_verifier does not match code_challenge"}`. | 92 + 93 + --- 94 + 95 + ## Traceability 96 + 97 + | Acceptance Criterion | Automated Test | Manual Phase/Step | 98 + |----------------------|----------------|-------------------| 99 + | AC1.1 — 200 with token fields | `authorization_code_happy_path_returns_200_with_tokens` | Phase 2, step 3 | 100 + | AC1.2 — ES256 AT+JWT with cnf.jkt | `authorization_code_happy_path_returns_200_with_tokens` | Phase 2, step 4 | 101 + | AC1.3 — Refresh token issued | `authorization_code_happy_path_returns_200_with_tokens` + `store_oauth_refresh_token_persists_row` | Phase 2, step 3 | 102 + | AC1.4 — Wrong code_verifier → invalid_grant | `wrong_code_verifier_returns_invalid_grant` | Phase 5, step 5 | 103 + | AC1.5 — Expired auth code rejected | `consume_authorization_code_returns_none_for_expired_code` | — | 104 + | AC1.6 — Code single-use | `consumed_code_returns_invalid_grant` + `consume_authorization_code_returns_row_and_deletes_it` | Phase 2, step 5 | 105 + | AC1.7 — client_id mismatch → invalid_grant | `client_id_mismatch_returns_invalid_grant` | — | 106 + | AC1.8 — redirect_uri mismatch → invalid_grant | `redirect_uri_mismatch_returns_invalid_grant` | — | 107 + | AC2.1 — Valid DPoP proof accepted | `authorization_code_happy_path_returns_200_with_tokens` | Phase 2, steps 2–3 | 108 + | AC2.2 — cnf.jkt matches DPoP key | `authorization_code_happy_path_returns_200_with_tokens` | Phase 2, step 4 | 109 + | AC2.3 — Missing DPoP header → 400 | `missing_dpop_header_returns_invalid_dpop_proof` | Phase 4, step 4 | 110 + | AC2.4 — Wrong htm → 400 | `dpop_wrong_htm_returns_invalid_dpop_proof` | Phase 4, step 5 | 111 + | AC2.5 — Wrong htu → 400 | `dpop_wrong_htu_returns_invalid_dpop_proof` | — | 112 + | AC2.6 — Stale iat → 400 | `dpop_stale_iat_returns_invalid_dpop_proof` | Phase 4, step 6 | 113 + | AC3.1 — Valid nonce accepted once | `issued_nonce_validates_once` | — | 114 + | AC3.2 — Missing nonce → use_dpop_nonce + header | `dpop_without_nonce_returns_use_dpop_nonce_with_header` | Phase 2, step 2 | 115 + | AC3.3 — Expired nonce rejected | `expired_nonce_is_rejected` | — | 116 + | AC3.4 — Unknown nonce rejected | `unknown_nonce_is_rejected` + `dpop_with_unknown_nonce_returns_use_dpop_nonce` | Phase 5, step 4 | 117 + | AC3.5 — Fresh nonce in success response | `authorization_code_happy_path_returns_200_with_tokens` + `refresh_token_happy_path_returns_200_with_new_tokens` | Phase 2, step 3; Phase 3, step 2 | 118 + | AC4.1 — Refresh returns new token pair | `refresh_token_happy_path_returns_200_with_new_tokens` | Phase 3, step 2 | 119 + | AC4.2 — Refresh single-use | `refresh_token_second_use_returns_invalid_grant` + `consume_oauth_refresh_token_returns_row_and_deletes_it` | Phase 3, step 4 | 120 + | AC4.3 — Expired refresh token rejected | `refresh_token_expired_returns_invalid_grant` + `consume_oauth_refresh_token_returns_none_for_expired_token` | Phase 5, step 3 | 121 + | AC4.4 — jkt mismatch on refresh → invalid_grant | `refresh_token_jkt_mismatch_returns_invalid_grant` | Phase 5, step 1 | 122 + | AC4.5 — client_id mismatch on refresh → invalid_grant | `refresh_token_client_id_mismatch_returns_invalid_grant` | Phase 5, step 2 | 123 + | AC5.1 — Error body has error + error_description | `error_response_has_error_and_error_description_fields` | Phase 4, step 1 | 124 + | AC5.2 — Unknown grant_type → unsupported_grant_type | `unknown_grant_type_returns_400_unsupported` | Phase 4, step 1 | 125 + | AC5.3 — Missing grant_type → invalid_request | `missing_grant_type_returns_400_invalid_request` | Phase 4, step 2 | 126 + | AC5.4 — Errors are JSON not HTML | `error_response_content_type_is_json` | Phase 4, step 1 | 127 + | AC6.1 — Signing key stored and retrieved | `store_and_retrieve_oauth_signing_key` | — | 128 + | AC6.2 — Same key loaded after restart | — (cannot automate: requires process restart with persistent DB) | **Phase 1, steps 1–6** | 129 + | AC6.3 — ES256 alg in JWT header | `authorization_code_happy_path_returns_200_with_tokens` | Phase 2, step 4 |