tangled
alpha
login
or
join now
malpercio.dev
/
ezpds
0
fork
atom
An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.
0
fork
atom
overview
issues
pulls
pipelines
docs: add test plan for MM-77 OAuth token endpoint
malpercio.dev
1 day ago
910eb976
01b66b8e
+129
1 changed file
expand all
collapse all
unified
split
docs
test-plans
2026-03-22-MM-77.md
+129
docs/test-plans/2026-03-22-MM-77.md
reviewed
···
1
1
+
# OAuth Token Endpoint — Human Test Plan (MM-77)
2
2
+
3
3
+
Generated from: `docs/implementation-plans/2026-03-22-MM-77/`
4
4
+
5
5
+
**Automated coverage:** 30/30 acceptance criteria covered by `cargo test -p relay`.
6
6
+
**Manual verification required:** AC6.2 (signing key persistence across restarts).
7
7
+
8
8
+
---
9
9
+
10
10
+
## Prerequisites
11
11
+
12
12
+
- Relay binary built and running with a **persistent** SQLite database path (not `:memory:`)
13
13
+
- `signing_key_master_key` configured in relay config (32-byte hex or base64 key)
14
14
+
- `public_url` configured to the relay's externally reachable URL
15
15
+
- At least one account created (DID provisioned) on the relay
16
16
+
- An OAuth client registered (`client_id` = HTTPS metadata URL, with valid `redirect_uris`)
17
17
+
- An authorization code issued via `POST /oauth/authorize` (or seeded manually via DB for testing)
18
18
+
- `cargo test -p relay` passing (all 334 tests green)
19
19
+
- A tool capable of crafting DPoP JWTs (e.g., a small script using the `jose` CLI, or the Bruno collection)
20
20
+
21
21
+
---
22
22
+
23
23
+
## Phase 1: Signing Key Persistence Across Restarts
24
24
+
25
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
26
+
27
27
+
| Step | Action | Expected |
28
28
+
|------|--------|----------|
29
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
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
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
32
+
| 4 | Stop the relay process (Ctrl-C or SIGTERM). | Process exits cleanly. |
33
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
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
35
+
36
36
+
---
37
37
+
38
38
+
## Phase 2: Full Authorization Code Exchange (End-to-End)
39
39
+
40
40
+
Validates the complete happy-path flow from nonce negotiation through token issuance.
41
41
+
42
42
+
| Step | Action | Expected |
43
43
+
|------|--------|----------|
44
44
+
| 1 | Generate a P-256 keypair (client DPoP key). Compute its JWK thumbprint (RFC 7638). | A 43-character base64url thumbprint string. |
45
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
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
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
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
49
+
50
50
+
---
51
51
+
52
52
+
## Phase 3: Full Refresh Token Rotation (End-to-End)
53
53
+
54
54
+
Validates refresh token rotation, DPoP binding enforcement, and single-use semantics.
55
55
+
56
56
+
| Step | Action | Expected |
57
57
+
|------|--------|----------|
58
58
+
| 1 | From Phase 2 step 3, record the `refresh_token` value and the `DPoP-Nonce` header value. | Both values available. |
59
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
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
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
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
63
+
64
64
+
---
65
65
+
66
66
+
## Phase 4: Error Handling Validation
67
67
+
68
68
+
Verifies error responses are well-formed JSON, use correct OAuth error codes, and include both `error` and `error_description` fields.
69
69
+
70
70
+
| Step | Action | Expected |
71
71
+
|------|--------|----------|
72
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
73
+
| 2 | Send `POST /oauth/token` with no body at all. | 400. Body: `{"error": "invalid_request", "error_description": "missing required parameter: grant_type"}`. |
74
74
+
| 3 | Send `GET /oauth/token`. | 405 Method Not Allowed. |
75
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
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
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
78
+
79
79
+
---
80
80
+
81
81
+
## Phase 5: Security Edge Cases
82
82
+
83
83
+
Validates security-critical behaviors that benefit from human observation.
84
84
+
85
85
+
| Step | Action | Expected |
86
86
+
|------|--------|----------|
87
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
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
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
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
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
92
+
93
93
+
---
94
94
+
95
95
+
## Traceability
96
96
+
97
97
+
| Acceptance Criterion | Automated Test | Manual Phase/Step |
98
98
+
|----------------------|----------------|-------------------|
99
99
+
| AC1.1 — 200 with token fields | `authorization_code_happy_path_returns_200_with_tokens` | Phase 2, step 3 |
100
100
+
| AC1.2 — ES256 AT+JWT with cnf.jkt | `authorization_code_happy_path_returns_200_with_tokens` | Phase 2, step 4 |
101
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
102
+
| AC1.4 — Wrong code_verifier → invalid_grant | `wrong_code_verifier_returns_invalid_grant` | Phase 5, step 5 |
103
103
+
| AC1.5 — Expired auth code rejected | `consume_authorization_code_returns_none_for_expired_code` | — |
104
104
+
| AC1.6 — Code single-use | `consumed_code_returns_invalid_grant` + `consume_authorization_code_returns_row_and_deletes_it` | Phase 2, step 5 |
105
105
+
| AC1.7 — client_id mismatch → invalid_grant | `client_id_mismatch_returns_invalid_grant` | — |
106
106
+
| AC1.8 — redirect_uri mismatch → invalid_grant | `redirect_uri_mismatch_returns_invalid_grant` | — |
107
107
+
| AC2.1 — Valid DPoP proof accepted | `authorization_code_happy_path_returns_200_with_tokens` | Phase 2, steps 2–3 |
108
108
+
| AC2.2 — cnf.jkt matches DPoP key | `authorization_code_happy_path_returns_200_with_tokens` | Phase 2, step 4 |
109
109
+
| AC2.3 — Missing DPoP header → 400 | `missing_dpop_header_returns_invalid_dpop_proof` | Phase 4, step 4 |
110
110
+
| AC2.4 — Wrong htm → 400 | `dpop_wrong_htm_returns_invalid_dpop_proof` | Phase 4, step 5 |
111
111
+
| AC2.5 — Wrong htu → 400 | `dpop_wrong_htu_returns_invalid_dpop_proof` | — |
112
112
+
| AC2.6 — Stale iat → 400 | `dpop_stale_iat_returns_invalid_dpop_proof` | Phase 4, step 6 |
113
113
+
| AC3.1 — Valid nonce accepted once | `issued_nonce_validates_once` | — |
114
114
+
| AC3.2 — Missing nonce → use_dpop_nonce + header | `dpop_without_nonce_returns_use_dpop_nonce_with_header` | Phase 2, step 2 |
115
115
+
| AC3.3 — Expired nonce rejected | `expired_nonce_is_rejected` | — |
116
116
+
| AC3.4 — Unknown nonce rejected | `unknown_nonce_is_rejected` + `dpop_with_unknown_nonce_returns_use_dpop_nonce` | Phase 5, step 4 |
117
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
118
+
| AC4.1 — Refresh returns new token pair | `refresh_token_happy_path_returns_200_with_new_tokens` | Phase 3, step 2 |
119
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
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
121
+
| AC4.4 — jkt mismatch on refresh → invalid_grant | `refresh_token_jkt_mismatch_returns_invalid_grant` | Phase 5, step 1 |
122
122
+
| AC4.5 — client_id mismatch on refresh → invalid_grant | `refresh_token_client_id_mismatch_returns_invalid_grant` | Phase 5, step 2 |
123
123
+
| AC5.1 — Error body has error + error_description | `error_response_has_error_and_error_description_fields` | Phase 4, step 1 |
124
124
+
| AC5.2 — Unknown grant_type → unsupported_grant_type | `unknown_grant_type_returns_400_unsupported` | Phase 4, step 1 |
125
125
+
| AC5.3 — Missing grant_type → invalid_request | `missing_grant_type_returns_400_invalid_request` | Phase 4, step 2 |
126
126
+
| AC5.4 — Errors are JSON not HTML | `error_response_content_type_is_json` | Phase 4, step 1 |
127
127
+
| AC6.1 — Signing key stored and retrieved | `store_and_retrieve_oauth_signing_key` | — |
128
128
+
| AC6.2 — Same key loaded after restart | — (cannot automate: requires process restart with persistent DB) | **Phase 1, steps 1–6** |
129
129
+
| AC6.3 — ES256 alg in JWT header | `authorization_code_happy_path_returns_200_with_tokens` | Phase 2, step 4 |