/**
* Tier 7 — SECURITY (the centerpiece): adversarial properties of the Phase 3 OAuth/PKCE core.
*
* Each block maps to an attacker capability from the Phase 3 threat model and asserts the exact
* control that stops it. A subtle deviation here is an account-compromise path, so these tests
* are the proof the protocol core is correct BEFORE any socket/network/keychain I/O exists
* (deferred to Phase 5).
*
* Mandatory coverage (Phase 3 prompt + gate §10 security tier):
* - code_challenge is the correct S256 of the verifier (RFC 7636 §4.1) + verifier entropy/length
* - 'plain' method is rejected (no downgrade)
* - state mismatch → reject (constant-time, no oracle)
* - an authorization-server error is surfaced without leaking
* - a non-loopback / wildcard / foreign redirect_uri is rejected (RFC 8252)
* - the authorization URL never contains a client secret and uses response_type=code + S256
* - the token request carries the code_verifier (PKCE binding) and no client secret
* - a malformed / oversized token response fails closed
* - replay (reused state) is rejected
* - NO secret (code, code_verifier, state, access/refresh token, JWT) appears in any output,
* reason, log, or thrown error
*
* Reference: RFC 7636 (PKCE), RFC 8252 (native apps), RFC 9207 (iss); design doc threat model.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import {
OAUTH_PKCE_REASONS,
computeCodeChallenge,
createPkcePair,
createOAuthState,
constantTimeEqual,
validateRedirectUri,
buildAuthorizationUrl,
validateAuthorizationResponse,
buildTokenRequest,
validateTokenResponse,
} from '../lib/companion-oauth-pkce.mjs';
const AUTH_EP = 'https://knowtation.store/authorize';
const TOKEN_EP = 'https://knowtation.store/token';
const CLIENT_ID = 'companion-public-client';
const REDIRECT = 'http://127.0.0.1:49321/callback';
const SCOPES = ['vault:read', 'vault:write'];
// ── Attacker A — code interception on the loopback redirect (PKCE S256 binds code↔verifier) ──
describe('Security — PKCE S256 correctly binds the code to the verifier (RFC 7636 §4.1/§4.2)', () => {
it('code_challenge is exactly base64url(SHA-256(verifier)) for fresh pairs', () => {
for (let i = 0; i < 1000; i++) {
const { codeVerifier, codeChallenge } = createPkcePair();
const expected = crypto.createHash('sha256').update(codeVerifier, 'ascii').digest('base64url');
assert.equal(codeChallenge, expected);
}
});
it('the verifier has sufficient entropy/length (≥ 43 chars, unreserved charset)', () => {
const seen = new Set();
for (let i = 0; i < 5000; i++) {
const { codeVerifier } = createPkcePair();
assert.ok(codeVerifier.length >= 43, 'RFC 7636 §4.1 minimum length');
assert.match(codeVerifier, /^[A-Za-z0-9\-._~]+$/);
seen.add(codeVerifier);
}
assert.equal(seen.size, 5000, 'every verifier is unique (CSPRNG, no collisions)');
});
it('a wrong verifier does NOT reproduce the challenge (a stolen code is useless without it)', () => {
const { codeVerifier, codeChallenge } = createPkcePair();
const other = createPkcePair().codeVerifier;
assert.notEqual(computeCodeChallenge(other), codeChallenge);
assert.equal(computeCodeChallenge(codeVerifier), codeChallenge);
});
});
// ── Attacker D — PKCE downgrade to 'plain' ────────────────────────────────────
describe("Security — 'plain' PKCE is rejected (no downgrade) (RFC 7636 §7.2)", () => {
it('buildAuthorizationUrl refuses any method other than S256', () => {
for (const method of ['plain', 'PLAIN', 'none', 's256', '']) {
assert.throws(() => buildAuthorizationUrl({
authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: REDIRECT,
scopes: SCOPES, state: 's', codeChallenge: 'c', codeChallengeMethod: method,
}), `method ${JSON.stringify(method)} must be rejected`);
}
});
it('a built URL always advertises S256, never plain', () => {
const url = new URL(buildAuthorizationUrl({
authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: REDIRECT,
scopes: SCOPES, state: 's', codeChallenge: 'c',
}));
assert.equal(url.searchParams.get('code_challenge_method'), 'S256');
});
});
// ── Attacker B — CSRF / session-fixation on the callback (state, constant-time) ──
describe('Security — state defends CSRF/fixation; compare is constant-time (RFC 6749 §10.12)', () => {
it('a forged/mismatched state is rejected', () => {
const r = validateAuthorizationResponse({ params: { code: 'c', state: 'attacker' }, expectedState: createOAuthState() });
assert.equal(r.reason, OAUTH_PKCE_REASONS.STATE_MISMATCH);
});
it('an absent state is rejected (fail-closed)', () => {
const r = validateAuthorizationResponse({ params: { code: 'c' }, expectedState: 'legit' });
assert.equal(r.reason, OAUTH_PKCE_REASONS.STATE_MISSING);
});
it('the state compare is constant-time (no early-exit position oracle)', () => {
const state = 'S'.repeat(43);
const early = '!' + state.slice(1);
const late = state.slice(0, -1) + '!';
const ITER = 150_000;
for (let i = 0; i < 10_000; i++) { constantTimeEqual(early, state); constantTimeEqual(late, state); }
const t0 = performance.now();
for (let i = 0; i < ITER; i++) constantTimeEqual(early, state);
const earlyT = performance.now() - t0;
const t1 = performance.now();
for (let i = 0; i < ITER; i++) constantTimeEqual(late, state);
const lateT = performance.now() - t1;
const ratio = earlyT / lateT;
assert.ok(ratio > 0.25 && ratio < 4, `timing ratio ${ratio.toFixed(3)} suggests non-constant-time compare`);
});
});
// ── Attacker C — authorization-server / redirect mix-up (RFC 9207 iss) ────────
describe('Security — issuer mix-up defense (RFC 9207)', () => {
it('a present-but-foreign iss is rejected even with a valid state', () => {
const state = createOAuthState();
const r = validateAuthorizationResponse({
params: { code: 'c', state, iss: 'https://attacker.example' },
expectedState: state, expectedIssuer: 'https://knowtation.store',
});
assert.equal(r.reason, OAUTH_PKCE_REASONS.ISSUER_MISMATCH);
});
});
// ── Attacker E — open-redirect / redirect_uri manipulation (RFC 8252) ─────────
describe('Security — strict loopback redirect allowlist, no wildcard (RFC 8252 §7.3/§8.3)', () => {
it('rejects foreign, LAN, https, wildcard, and schemeless redirect targets', () => {
const bad = [
'https://evil.example/cb',
'http://evil.example:80/cb',
'http://192.168.1.50:8080/cb',
'http://127.0.0.1.evil.example:49321/cb',
'http://0.0.0.0:49321/cb',
'http://*.127.0.0.1:49321/cb',
'https://127.0.0.1:49321/cb',
'ftp://127.0.0.1:49321/cb',
'javascript:alert(1)',
];
for (const uri of bad) {
assert.equal(validateRedirectUri(uri).ok, false, `${uri} must be rejected`);
}
});
it('build* functions refuse to emit a request to a non-loopback redirect', () => {
assert.throws(() => buildAuthorizationUrl({
authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: 'https://evil.example/cb',
scopes: SCOPES, state: 's', codeChallenge: 'c',
}));
assert.throws(() => buildTokenRequest({
tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, code: 'c',
codeVerifier: createPkcePair().codeVerifier, redirectUri: 'http://evil.example:1/cb',
}));
});
});
// ── Attacker G — client-secret extraction from the distributed binary ─────────
describe('Security — public client: NO client secret anywhere (RFC 8252 §8.5)', () => {
it('the authorization URL never carries a client secret and uses response_type=code + S256', () => {
const url = buildAuthorizationUrl({
authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: REDIRECT,
scopes: SCOPES, state: createOAuthState(), codeChallenge: createPkcePair().codeChallenge,
extraParams: { client_secret: 'should-be-dropped' },
});
assert.ok(!/client_secret/i.test(url), 'no client_secret param in the authorization URL');
const u = new URL(url);
assert.equal(u.searchParams.get('response_type'), 'code');
assert.equal(u.searchParams.get('code_challenge_method'), 'S256');
});
it('the token request carries the code_verifier (PKCE proof) and no client secret', () => {
const { codeVerifier } = createPkcePair();
const req = buildTokenRequest({ tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, code: 'authcode', codeVerifier, redirectUri: REDIRECT });
assert.equal(req.bodyParams.code_verifier, codeVerifier);
assert.ok(!/client_secret/i.test(req.body));
assert.equal(req.bodyParams.client_secret, undefined);
});
});
// ── Attacker — authorization-server error response leakage ────────────────────
describe('Security — auth-server errors surface without leaking free text', () => {
it('a known error code is surfaced; error_description is never echoed', () => {
const r = validateAuthorizationResponse({
params: { error: 'invalid_scope', error_description: 'leak
', state: 'x' },
expectedState: 's',
});
assert.equal(r.reason, OAUTH_PKCE_REASONS.AUTHORIZATION_SERVER_ERROR);
assert.equal(r.errorCode, 'invalid_scope');
assert.ok(!JSON.stringify(r).includes('leak'));
assert.ok(!JSON.stringify(r).includes('onerror'));
});
});
// ── Attacker H — authorization-response replay (one-time state, single-use code) ──
describe('Security — replay of a reused state is rejected (single-use contract)', () => {
it('once the caller has consumed (cleared) the expected state, a replayed callback fails closed', () => {
const state = createOAuthState();
const first = validateAuthorizationResponse({ params: { code: 'c1', state }, expectedState: state });
assert.equal(first.ok, true);
// Caller discards the one-time state after success → a replayed callback has no expectedState.
const replay = validateAuthorizationResponse({ params: { code: 'c1', state }, expectedState: '' });
assert.equal(replay.ok, false);
assert.equal(replay.reason, OAUTH_PKCE_REASONS.STATE_MISSING);
});
it('a different (attacker) state never validates against the pending one', () => {
const pending = createOAuthState();
const attacker = createOAuthState();
const r = validateAuthorizationResponse({ params: { code: 'c', state: attacker }, expectedState: pending });
assert.equal(r.reason, OAUTH_PKCE_REASONS.STATE_MISMATCH);
});
});
// ── Malformed / oversized token response fails closed ─────────────────────────
describe('Security — malformed/oversized token response fails closed', () => {
it('rejects non-objects, arrays, and missing fields', () => {
for (const bad of [null, undefined, 'str', 42, [], {}, { access_token: 'x' }, { token_type: 'Bearer' }]) {
assert.equal(validateTokenResponse(bad).ok, false);
}
});
it('rejects an oversized access token', () => {
const huge = 'a'.repeat(9000);
assert.equal(validateTokenResponse({ access_token: huge, token_type: 'Bearer', expires_in: 60 }).ok, false);
});
it('rejects an oversized refresh token', () => {
assert.equal(validateTokenResponse({ access_token: 'jwt', token_type: 'Bearer', expires_in: 60, refresh_token: 'r'.repeat(9000) }).ok, false);
});
});
// ── Attacker F — token theft at rest is out of scope for THIS module (custody owns it) ──
// (Covered by companion-token-custody-security.test.mjs: keychain-only, never plaintext/log.)
// ── No secret in any output / reason / thrown error ───────────────────────────
describe('Security — no secret in any output, reason, or thrown error', () => {
const SECRET_CODE = 'AUTHCODE-' + 'C'.repeat(40);
const SECRET_STATE = 'STATE-' + 'S'.repeat(40);
it('a state-mismatch verdict never echoes either state value', () => {
const r = validateAuthorizationResponse({ params: { code: SECRET_CODE, state: SECRET_STATE }, expectedState: 'expected-different' });
const s = JSON.stringify(r);
assert.ok(!s.includes(SECRET_STATE));
assert.ok(!s.includes(SECRET_CODE));
assert.ok(!s.includes('expected-different'));
});
it('the success channel returns the code (legitimate) but reasons never carry it', () => {
const ok = validateAuthorizationResponse({ params: { code: SECRET_CODE, state: SECRET_STATE }, expectedState: SECRET_STATE });
assert.equal(ok.code, SECRET_CODE); // legitimate return channel
const deny = validateAuthorizationResponse({ params: { state: SECRET_STATE }, expectedState: SECRET_STATE });
assert.ok(!JSON.stringify(deny).includes(SECRET_CODE));
});
it('thrown configuration errors never contain the verifier or code', () => {
const verifier = createPkcePair().codeVerifier;
try {
buildTokenRequest({ tokenEndpoint: 'http://insecure/token', clientId: CLIENT_ID, code: SECRET_CODE, codeVerifier: verifier, redirectUri: REDIRECT });
assert.fail('should have thrown');
} catch (e) {
assert.ok(!String(e.message).includes(verifier));
assert.ok(!String(e.message).includes(SECRET_CODE));
}
});
it('computeCodeChallenge throws without leaking the verifier', () => {
const bad = 'short-but-secret-' + 'Z'.repeat(5);
try {
computeCodeChallenge(bad);
assert.fail('should have thrown');
} catch (e) {
assert.ok(!String(e.message).includes(bad));
}
});
it('every validator reason is a fixed constant (never attacker-controlled text)', () => {
const reasons = new Set(Object.values(OAUTH_PKCE_REASONS));
const probes = [
validateAuthorizationResponse({ params: { error: '"; DROP TABLE x; --', state: 'x' }, expectedState: 's' }),
validateAuthorizationResponse({ params: 'not-an-object', expectedState: 's' }),
validateTokenResponse({ error: '