|
| 1 | +import type { AuthEvent } from '@wolfcola/devtools-types'; |
| 2 | +import { decodeJwtPayload } from '../../annotators/jwt-utils.js'; |
| 3 | +import type { IssueCandidate } from './types.js'; |
| 4 | + |
| 5 | +export function collectDpopIssues(events: readonly AuthEvent[]): IssueCandidate[] { |
| 6 | + const candidates: IssueCandidate[] = []; |
| 7 | + |
| 8 | + for (const event of events) { |
| 9 | + const sem = event.oidcSemantics; |
| 10 | + if (!sem?.dpop) continue; |
| 11 | + |
| 12 | + if (event.data._tag !== 'network') continue; |
| 13 | + const { data } = event; |
| 14 | + |
| 15 | + // Check DPoP proof structure |
| 16 | + if (sem.dpop.proofJwt) { |
| 17 | + const payload = decodeJwtPayload(sem.dpop.proofJwt); |
| 18 | + if (payload) { |
| 19 | + const requiredClaims = ['htm', 'htu', 'iat', 'jti']; |
| 20 | + const missing = requiredClaims.filter((c) => !(c in payload)); |
| 21 | + if (missing.length > 0) { |
| 22 | + candidates.push({ |
| 23 | + dedupKey: `dpop:invalid-structure:${event.id}`, |
| 24 | + eventId: event.id, |
| 25 | + issue: { |
| 26 | + id: 'dpop:invalid-structure', |
| 27 | + severity: 'error', |
| 28 | + category: 'dpop', |
| 29 | + title: 'DPoP proof missing required claims', |
| 30 | + description: `The DPoP proof JWT is missing: ${missing.join(', ')}.`, |
| 31 | + steps: [ |
| 32 | + 'Include all required claims: htm, htu, iat, jti.', |
| 33 | + 'Add ath when using DPoP with resource requests.', |
| 34 | + ], |
| 35 | + relevantData: { 'missing-claims': missing.join(', ') }, |
| 36 | + }, |
| 37 | + }); |
| 38 | + } |
| 39 | + |
| 40 | + // htm mismatch |
| 41 | + if (typeof payload['htm'] === 'string' && payload['htm'] !== data.method) { |
| 42 | + candidates.push({ |
| 43 | + dedupKey: `dpop:method-mismatch:${event.id}`, |
| 44 | + eventId: event.id, |
| 45 | + issue: { |
| 46 | + id: 'dpop:method-mismatch', |
| 47 | + severity: 'error', |
| 48 | + category: 'dpop', |
| 49 | + title: 'DPoP method mismatch', |
| 50 | + description: `DPoP proof htm="${payload['htm']}" does not match actual method "${data.method}".`, |
| 51 | + steps: ['The htm claim must match the HTTP method of the request.'], |
| 52 | + relevantData: { htm: payload['htm'] as string, method: data.method }, |
| 53 | + }, |
| 54 | + }); |
| 55 | + } |
| 56 | + |
| 57 | + // htu mismatch |
| 58 | + if (typeof payload['htu'] === 'string') { |
| 59 | + const htu = payload['htu'] as string; |
| 60 | + const urlNoQuery = data.url.split('?')[0]; |
| 61 | + if (htu !== urlNoQuery && htu !== data.url) { |
| 62 | + candidates.push({ |
| 63 | + dedupKey: `dpop:uri-mismatch:${event.id}`, |
| 64 | + eventId: event.id, |
| 65 | + issue: { |
| 66 | + id: 'dpop:uri-mismatch', |
| 67 | + severity: 'error', |
| 68 | + category: 'dpop', |
| 69 | + title: 'DPoP URI mismatch', |
| 70 | + description: 'The DPoP proof htu does not match the request URL.', |
| 71 | + steps: [ |
| 72 | + 'The htu claim must match the URL of the request (without query/fragment).', |
| 73 | + ], |
| 74 | + relevantData: { htu, url: urlNoQuery }, |
| 75 | + }, |
| 76 | + }); |
| 77 | + } |
| 78 | + } |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + // DPoP nonce required error |
| 83 | + if (sem.dpop.nonce && data.status === 400) { |
| 84 | + const body = data.responseBody as Record<string, unknown> | null; |
| 85 | + if (body && body['error'] === 'use_dpop_nonce') { |
| 86 | + candidates.push({ |
| 87 | + dedupKey: `dpop:nonce-required:${event.id}`, |
| 88 | + eventId: event.id, |
| 89 | + issue: { |
| 90 | + id: 'dpop:nonce-required', |
| 91 | + severity: 'info', |
| 92 | + category: 'dpop', |
| 93 | + title: 'DPoP nonce required', |
| 94 | + description: |
| 95 | + 'The server requires a DPoP nonce. The client should retry with the provided nonce.', |
| 96 | + steps: [ |
| 97 | + 'Include the DPoP-Nonce header value in the next DPoP proof.', |
| 98 | + 'This is expected behavior for server nonce enforcement.', |
| 99 | + ], |
| 100 | + relevantData: { nonce: sem.dpop.nonce }, |
| 101 | + }, |
| 102 | + }); |
| 103 | + } |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + // Check for token requests to DPoP servers missing DPoP header |
| 108 | + const dpopServers = new Set<string>(); |
| 109 | + for (const event of events) { |
| 110 | + if (event.oidcSemantics?.dpop?.tokenType?.toLowerCase() === 'dpop') { |
| 111 | + if (event.data._tag === 'network') { |
| 112 | + try { |
| 113 | + dpopServers.add(new URL(event.data.url).origin); |
| 114 | + } catch { |
| 115 | + // ignore invalid URLs |
| 116 | + } |
| 117 | + } |
| 118 | + } |
| 119 | + } |
| 120 | + for (const event of events) { |
| 121 | + if (event.data._tag !== 'network') continue; |
| 122 | + if (event.oidcSemantics?.oidcPhase !== 'token') continue; |
| 123 | + if (event.data.requestHeaders['dpop']) continue; |
| 124 | + try { |
| 125 | + const origin = new URL(event.data.url).origin; |
| 126 | + if (dpopServers.has(origin)) { |
| 127 | + candidates.push({ |
| 128 | + dedupKey: `dpop:missing-proof:${event.id}`, |
| 129 | + eventId: event.id, |
| 130 | + issue: { |
| 131 | + id: 'dpop:missing-proof', |
| 132 | + severity: 'warning', |
| 133 | + category: 'dpop', |
| 134 | + title: 'Missing DPoP proof', |
| 135 | + description: |
| 136 | + 'This token endpoint previously issued DPoP tokens but this request lacks a DPoP header.', |
| 137 | + steps: ['Include a DPoP proof JWT in the DPoP header.'], |
| 138 | + }, |
| 139 | + }); |
| 140 | + } |
| 141 | + } catch { |
| 142 | + // ignore |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + return candidates; |
| 147 | +} |
0 commit comments