Skip to content

Commit 47ac465

Browse files
fix(e2e): repair badge-disclosure gated join; prove the GitHub-badge gate
The gated-join happy path was silently broken by SDK drift: @ministryofmany/client now pins each badge-VC key to the issuer did:web document's assertionMethod (fetched from /.well-known/did.json, kid-scoped so the id_token key cannot attest a badge) and binds each badge to the login via the pairwise subject DID `did:web:<host>:u:<id_token sub>`. The e2e mock issuer still served only /.well-known/jwks.json and minted VC subjects as `...:users:<sanitized-sub>`, so EVERY disclosed badge landed in `rejected` (fail-closed) and no badge-gated join could complete - the whole disclosure path was dead end to end, unnoticed because e2e is not in CI. Fix the mock to Minister's post-MIN-1 contract: - serve /.well-known/did.json with the badge key in assertionMethod; - mint the badge subject as `did:web:<host>:u:<raw id_token sub>`; - stamp the coarse issuanceMonth claim for disclosure fidelity. Add e2e/09: a user disclosing an oauth-account (provider=github) badge joins the github-devs sub-forum and gets the github-dev role - the happy-path counterpart to the spec-08 deny, exercising the full authorize-with-badge-scope -> disclose -> verify -> gate -> membership path (task #40).
1 parent 35a65ee commit 47ac465

2 files changed

Lines changed: 106 additions & 7 deletions

File tree

e2e/09-github-badge-join.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// SPEC 9: JOIN the GitHub-verified-devs sub-forum by DISCLOSING a GitHub badge
2+
// (the happy path of the badge-disclosure gate).
3+
//
4+
// The counterpart to spec 8a (deny): there, a user disclosing only an
5+
// email-domain badge is REJECTED from github-devs. Here the user discloses the
6+
// required oauth-account (provider=github) badge, so the whole disclosure path
7+
// runs green end to end:
8+
//
9+
// /api/auth/oidc/start?subforum=github-devs&role=github-dev
10+
// -> the role's policy { badge: oauth-account, where: { provider: github } }
11+
// rides along as `minister_policy` and adds the `badge:oauth-account`
12+
// scope, so the mock issuer discloses exactly that badge (minimal set);
13+
// -> the callback re-verifies the id_token + the disclosed VC and stashes a
14+
// single-use pending-join;
15+
// -> the join page derives this device's per-sub-forum Semaphore identity
16+
// client-side and POSTs it, and the server re-runs the gate on the
17+
// VERIFIED disclosure, admits the user, and records the github-dev role.
18+
//
19+
// This is the end-to-end proof that a user WITH a GitHub badge can join the
20+
// GitHub-gated forum (task #40), complementing the DB-level gate coverage in
21+
// tests/join.int.test.ts and the browser deny path in spec 8.
22+
23+
import { test, expect } from '@playwright/test';
24+
import { joinSubforum } from './harness/helpers';
25+
import { GITHUB_SLUG, GITHUB_ROLE } from './harness/env';
26+
27+
test('joins github-devs by disclosing a GitHub oauth-account badge', async ({ page }) => {
28+
await joinSubforum(page, {
29+
email: 'join-dev@acme.example',
30+
badges: ['oauth-account'],
31+
slug: GITHUB_SLUG,
32+
role: GITHUB_ROLE,
33+
pseudonym: 'OctoJoiner'
34+
});
35+
36+
// The confirmation shows the chosen pseudonym + the verified github-dev role
37+
// pill (the gate passed on the disclosed, re-verified GitHub badge).
38+
await expect(page.getByText('OctoJoiner', { exact: false })).toBeVisible();
39+
await expect(page.getByText('verified roles:')).toBeVisible();
40+
await expect(page.getByText(GITHUB_ROLE, { exact: true })).toBeVisible();
41+
42+
// Following through lands the now-member on the sub-forum page.
43+
await page.getByRole('link', { name: `Go to ${GITHUB_SLUG}` }).click();
44+
await expect(page).toHaveURL(new RegExp(`/s/${GITHUB_SLUG}`));
45+
});

e2e/mock-oidc/issuer.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@ async function jwks(port: number): Promise<{ keys: JWK[] }> {
4949
return { keys: [{ ...publicJwk, alg: 'EdDSA', use: 'sig', kid: kidForPort(port) }] };
5050
}
5151

52+
/**
53+
* The did:web DID document served at `/.well-known/did.json`. @ministryofmany/client
54+
* pins each badge-VC signing key to the issuer DID document's `assertionMethod`
55+
* (NOT the OIDC JWKS) - it resolves the key by the VC `kid` from here and REJECTS
56+
* any kid not listed, so the id_token key can never attest a badge. Without this
57+
* document every disclosed badge fails closed with a did.json-fetch error, and no
58+
* badge-gated join can complete. This mock signs the id_token AND the badge VCs
59+
* with one key, so its single kid is the sole `assertionMethod` entry.
60+
*/
61+
async function didDocument(port: number): Promise<Record<string, unknown>> {
62+
publicJwk ??= await exportJWK(publicKey);
63+
const did = vcIssuerForPort(port);
64+
const kid = kidForPort(port);
65+
return {
66+
'@context': ['https://www.w3.org/ns/did/v1', 'https://w3id.org/security/suites/jws-2020/v1'],
67+
id: did,
68+
verificationMethod: [
69+
{
70+
id: kid,
71+
type: 'JsonWebKey2020',
72+
controller: did,
73+
publicKeyJwk: { ...publicJwk, alg: 'EdDSA', use: 'sig', kid }
74+
}
75+
],
76+
assertionMethod: [kid]
77+
};
78+
}
79+
5280
export interface MockBadge {
5381
type: string;
5482
attributes: Record<string, string | number | boolean>;
@@ -66,16 +94,36 @@ function badgeTypeToCredType(type: string): string {
6694
return `Minister${pascal}Credential`;
6795
}
6896

69-
async function signVc(userId: string, badge: MockBadge, port: number): Promise<string> {
97+
/** The UTC calendar month ("YYYY-MM") of a unix-seconds instant - Minister's
98+
* coarse `credentialSubject.issuanceMonth` disclosure bucket. */
99+
function issuanceMonthOf(sec: number): string {
100+
return new Date(sec * 1000).toISOString().slice(0, 7);
101+
}
102+
103+
/**
104+
* Sign a badge VC bound to the login. Post-MIN-1 Minister re-mints each
105+
* disclosed badge under the per-RP PAIRWISE subject DID `did:web:<host>:u:<sub>`
106+
* - the SAME pairwise `sub` it stamps as the id_token subject - and the SDK's
107+
* holder-binding requires `credentialSubject.id === did:web:<host>:u:<id_token
108+
* sub>` (see @ministryofmany/client verify-badges). The subject MUST therefore be
109+
* built from the RAW id_token `sub`, not a sanitized handle, or every badge
110+
* lands in `rejected` (fail-closed) and no gated join can succeed. `iat` is the
111+
* disclosure instant; the only issuance residue is the coarse `issuanceMonth`.
112+
*/
113+
async function signVc(sub: string, badge: MockBadge, port: number): Promise<string> {
70114
const nowSec = Math.floor(Date.now() / 1000);
71115
const iatSec = badge.expired ? nowSec - 120 : nowSec - 10;
72116
const vcIssuer = vcIssuerForPort(port);
73-
const subjectId = `${vcIssuer}:users:${userId}`;
117+
const subjectId = `${vcIssuer}:u:${sub}`;
74118
return new SignJWT({
75119
vc: {
76120
'@context': ['https://www.w3.org/ns/credentials/v2'],
77121
type: ['VerifiableCredential', badgeTypeToCredType(badge.type)],
78-
credentialSubject: { id: subjectId, ...badge.attributes }
122+
credentialSubject: {
123+
id: subjectId,
124+
...badge.attributes,
125+
issuanceMonth: issuanceMonthOf(iatSec)
126+
}
79127
}
80128
})
81129
.setProtectedHeader({ alg: 'EdDSA', kid: kidForPort(port), typ: 'vc+jwt' })
@@ -97,15 +145,14 @@ export interface MintOpts {
97145
name?: string;
98146
badges?: MockBadge[];
99147
nonce?: string;
100-
userId?: string;
101148
}
102149

103-
/** Mint an id_token directly (non-browser checks). */
150+
/** Mint an id_token directly (non-browser checks). Each disclosed badge is
151+
* bound to the pairwise `sub` per the post-MIN-1 holder-binding contract. */
104152
export async function mintIdToken(opts: MintOpts): Promise<string> {
105-
const userId = opts.userId ?? opts.sub.replace(/[^a-zA-Z0-9]/g, '');
106153
const port = Number(new URL(opts.issuer).port);
107154
const minister_badges = await Promise.all(
108-
(opts.badges ?? []).map((b) => signVc(userId, b, port))
155+
(opts.badges ?? []).map((b) => signVc(opts.sub, b, port))
109156
);
110157
return new SignJWT({
111158
nonce: opts.nonce,
@@ -311,6 +358,13 @@ async function handle(req: IncomingMessage, res: ServerResponse, issuer: string)
311358
return json(res, await jwks(port));
312359
}
313360

361+
// The did:web DID document. @ministryofmany/client resolves the badge-VC key
362+
// from this document's assertionMethod (kid-pinned), so a gated join that
363+
// discloses a badge fails closed without it.
364+
if (path === '/.well-known/did.json') {
365+
return json(res, await didDocument(port));
366+
}
367+
314368
if (path === '/oidc/authorize') return authorize(req, res, url);
315369
if (path === '/oidc/approve') return approve(req, res);
316370
if (path === '/oidc/token') return token(req, res, issuer);

0 commit comments

Comments
 (0)