feat(relay): B7 — close browser DPoP-subprotocol loop + harden staging mint
- Read DPoP proof from term.dpop.<b64u> WS subprotocol (browsers can't set WS headers); header wins, else subprotocol. Fail-closed decoder. Unblocks real browser connect. - F1: rate-limit /auth/mint per-IP via Redis token bucket (salted-hash key, 429 on burst, before password compare). - F2: wire real per-tenant active WS count into activeSessionCount (was hardcoded 0). - F5: scrub error logs to e.message/.code (no DSN leak, INV9). relay-run: tsc clean, 92 tests pass (+18). F3/F4 -> Phase 2 backlog.
This commit is contained in:
63
relay-run/tests/dpop-subprotocol.test.ts
Normal file
63
relay-run/tests/dpop-subprotocol.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* B7 · unit tests for the relay-side DPoP subprotocol decoder (`extractDpopProofFromSubprotocols`).
|
||||
* The browser (relay-web) offers the DPoP proof as an extra `term.dpop.<b64u(proofJws)>` entry on
|
||||
* the WS upgrade because native WebSocket cannot set request headers. These tests pin the wire
|
||||
* contract (valid decodes; absent → null; malformed → null, fail-closed) against the SAME isomorphic
|
||||
* base64url helper relay-web encodes with (relay-contracts), so the two sides agree byte-for-byte.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { encodeBase64UrlString } from 'relay-contracts'
|
||||
import {
|
||||
extractDpopProofFromSubprotocols,
|
||||
DPOP_SUBPROTOCOL_PREFIX,
|
||||
} from '../src/servers/dpop-subprotocol.js'
|
||||
import { APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX } from 'relay-contracts'
|
||||
|
||||
/** Build the wire entry exactly as relay-web `encodeDpopSubprotocol` does. */
|
||||
function dpopEntry(proofJws: string): string {
|
||||
return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws)
|
||||
}
|
||||
|
||||
// A representative three-part DPoP proof JWS (header.payload.sig) — content is opaque to the decoder.
|
||||
const PROOF = 'eyJhbGciOiJFZERTQSJ9.eyJodHUiOiJodHRwczovL2FsaWNlL3dzIn0.c2ln'
|
||||
|
||||
describe('extractDpopProofFromSubprotocols — valid', () => {
|
||||
it('decodes a term.dpop.<b64u> entry back to the exact proof JWS', () => {
|
||||
const subprotocols = [APP_SUBPROTOCOL, dpopEntry(PROOF)]
|
||||
expect(extractDpopProofFromSubprotocols(subprotocols)).toBe(PROOF)
|
||||
})
|
||||
|
||||
it('finds the DPoP entry regardless of position (after app + token entries)', () => {
|
||||
const subprotocols = [
|
||||
APP_SUBPROTOCOL,
|
||||
TOKEN_SUBPROTOCOL_PREFIX + encodeBase64UrlString('v4.public.raw-token'),
|
||||
dpopEntry(PROOF),
|
||||
]
|
||||
expect(extractDpopProofFromSubprotocols(subprotocols)).toBe(PROOF)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractDpopProofFromSubprotocols — absent → null', () => {
|
||||
it('returns null when no term.dpop. entry is present', () => {
|
||||
expect(extractDpopProofFromSubprotocols([APP_SUBPROTOCOL])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an empty list', () => {
|
||||
expect(extractDpopProofFromSubprotocols([])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the prefix is present but the payload is empty', () => {
|
||||
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractDpopProofFromSubprotocols — malformed → null (fail-closed)', () => {
|
||||
it('returns null for a non-base64url payload (illegal characters)', () => {
|
||||
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX + 'not*base64url!'])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for base64url bytes that are not valid UTF-8', () => {
|
||||
// 0xFF 0xFE is not a valid UTF-8 sequence; base64url of those two bytes is "__4".
|
||||
expect(extractDpopProofFromSubprotocols([DPOP_SUBPROTOCOL_PREFIX + '__4'])).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user