OIDC relying-party SDK for Minister — an OpenID Connect identity provider that lets users authenticate and disclose W3C verifiable-credential "badges" (email-domain, age-over-N, residency, connected accounts, and more).
Use this in your app to:
- Run the authorization-code flow with PKCE (S256) against Minister.
- Verify the returned
id_token(signature, issuer, audience, nonce). - Extract and signature-verify the disclosed badges against Minister's public keys, with holder-binding enforced.
Three ways to integrate: run the full flow yourself (createMinisterClient), verify tokens/badges on a backend (createMinisterVerifier), or plug into Auth.js (@ministryofmany/client/auth-js).
ESM-only. Runs on Node 20+, Deno, and edge runtimes (Vercel Edge, Cloudflare
Workers) — it uses the Web Crypto API and fetch, not node:crypto.
pnpm add @ministryofmany/clientjose and zod are runtime dependencies and are installed automatically.
Create one client and reuse it. issuer is Minister's origin.
import { createMinisterClient } from "@ministryofmany/client";
export const minister = createMinisterClient({
issuer: "https://ministry.id",
clientId: process.env.MINISTER_CLIENT_ID!,
clientSecret: process.env.MINISTER_CLIENT_SECRET, // omit for public/PKCE-only clients
redirectUri: "https://yourapp.example/auth/minister/callback",
});import type { OidcFlowState } from "@ministryofmany/client";
export async function startLogin() {
const { verifier, challenge } = await minister.generatePkce();
const state = minister.randomToken();
const nonce = minister.randomToken();
// Ask for the badges your app needs. `openid` is required; the SDK does
// not add it for you. `badgeScope("age-over-21")` === "badge:age-over-21".
const url = await minister.getAuthorizationUrl({
scopes: [
"openid",
"profile",
minister.badgeScope("age-over-21"),
minister.badgeScope("email-domain"),
],
state,
nonce,
codeChallenge: challenge,
});
// YOU own persistence. Store this keyed by `state`; the SDK stores nothing.
const flow: OidcFlowState = {
state,
nonce,
codeVerifier: verifier,
expiresAt: Date.now() + 10 * 60_000, // 10 minutes
};
await saveFlow(state, flow); // your storage: signed cookie, KV, DB row, ...
return url; // redirect the user here (302)
}You own the flow state, and you must consume it atomically by
state. On callback, look the record up by the returnedstate, delete it in the same operation (delete-on-read), and reject if it is missing or expired. This makes eachstate/nonceusable at most once, which is what defends the flow against CSRF and replay. The SDK deliberately stores nothing.
export async function handleCallback(req: Request) {
const url = new URL(req.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || !state) throw new Error("missing code/state");
// Atomic consume: fetch AND delete by state in one step.
const flow = await takeFlow(state);
if (!flow || flow.expiresAt < Date.now()) {
throw new Error("unknown or expired login flow");
}
const { claims, badges, rejected } = await minister.exchangeCode({
code,
codeVerifier: flow.codeVerifier,
expectedNonce: flow.nonce,
});
// `claims.sub` is a pairwise pseudonymous id — stable for this user at
// YOUR client, and different from what other RPs see.
// `badges` are already signature-verified and holder-bound.
const isAdult = badges.some((b) => b.type === "age-over-21");
// `rejected` holds any disclosed badges that failed verification (bad
// signature, expired, wrong issuer, ...). Login still succeeds; log/alert
// on these if a partner may be misconfigured.
await upsertUser({
ministerSub: claims.sub,
name: claims.name,
avatar: claims.picture,
isAdult,
});
// ... set your own session ...
}exchangeCode throws MinisterTokenError if the token exchange fails or the
id_token signature / issuer / audience / nonce / expiry checks fail — map
that to a 401. A disclosed badge that fails verification is not fatal:
it is dropped from badges and surfaced in rejected, and login proceeds.
Badges can also reach you outside the OIDC flow (e.g. a Minister share link). Verify any Minister VC JWT against Minister's public keys:
import { VcVerificationError } from "@ministryofmany/client";
try {
const badge = await minister.verifyMinisterBadge(vcJwt);
// badge.type -> the badge slug, e.g. "email-domain"
// badge.claims -> schema-validated claims, e.g. { domain: "example.com" }
// badge.subject -> the holder's stable Minister DID (== credentialSubject.id);
// NOT the id_token `sub` (that is a per-RP pairwise value)
// badge.raw -> the original JWT, for storage/forwarding
} catch (err) {
if (err instanceof VcVerificationError) {
// invalid signature, wrong issuer, bad envelope, or holder-binding mismatch
}
throw err;
}Optionally validate the claim shape against the known badge vocabulary:
import { getBadgeClaimSchema } from "@ministryofmany/client";
const schema = getBadgeClaimSchema("email-domain");
const parsed = schema?.safeParse(badge.claims);If your app uses an OIDC library (or another service runs the flow) and you
just need to verify a Minister id_token and its badges, use the verifier —
no flow state, no redirect handling:
import { createMinisterVerifier } from "@ministryofmany/client";
const minister = createMinisterVerifier({
issuer: "https://ministry.id",
clientId: "your-client-id", // enables the id_token `aud` check (recommended)
});
const claims = await minister.verifyIdToken(idToken); // throws MinisterTokenError on a bad token
// claims: { sub, name?, picture?, raw }
const { badges, rejected } = await minister.verifyBadges(idToken);
// badges: [{ type: "age-over-21", claims: { threshold: 21 }, subject, raw }, ...]
// rejected: [{ raw, error }] (badges that failed verification; never throws per-badge)verifyBadges accepts either a raw id_token string (it verifies the wrapper
first) or an already-verified payload object (it trusts the wrapper and only
verifies the badge VCs). The verifier caches Minister's JWKS after the first
fetch. Pass jwks to inject a key in tests.
@ministryofmany/client/auth-js gives you a provider config and a badge helper you
hand to Auth.js through its documented extension points. We do not modify,
fork, or pin Auth.js — @auth/core is a types-only optional peer.
import NextAuth from "next-auth";
import { ministerProvider, ministerBadgesFromProfile } from "@ministryofmany/client/auth-js";
import { badgeScopes } from "@ministryofmany/client/badges";
export const { handlers, auth } = NextAuth({
providers: [
ministerProvider({
clientId: process.env.MINISTER_CLIENT_ID!,
clientSecret: process.env.MINISTER_CLIENT_SECRET, // omit for public clients
issuer: process.env.MINISTER_ISSUER!,
scopes: ["openid", "profile", ...badgeScopes(["age-over-18"])],
}),
],
callbacks: {
async jwt({ token, profile }) {
if (profile) {
const { badges } = await ministerBadgesFromProfile(profile, {
issuer: process.env.MINISTER_ISSUER!,
});
token.ministerBadges = badges;
}
return token;
},
},
});ministerProvider returns a standard OIDC provider config; Auth.js verifies the
id_token, and ministerBadgesFromProfile verifies the nested badge VCs inside
your own callback.
@ministryofmany/client— the main entry: flow client, verifier, errors, types, and the badge vocabulary.@ministryofmany/client/badges— the badge vocabulary alone (slugs, scopes, Zod claim schemas,badgeScope/badgeScopes/badgeTypeOf). Dependency-light; no jose pulled in. Useful for building scope lists or parsing claims in a UI.@ministryofmany/client/auth-js— the Auth.js helpers only (ministerProvider,ministerBadgesFromProfile).
- id_token: EdDSA signature against Minister's JWKS, plus
iss== configured issuer,aud== yourclientId, andnonce== the value you persisted at start, withexp/iatpresent andexpnot in the past. - Each badge: EdDSA signature against Minister's public keys,
iss==did:web:<minister-host>, JWTtyp==vc+jwt, a present and unexpiredexp, a well-formedvcenvelope, andcredentialSubject.id== the VC's ownsub(holder binding). A badge whose signature, issuer, type, expiry, structure, or subject binding is wrong is rejected (dropped intorejected, never thrown fromverifyBadges).
Issuer-domain coupling (all badges rejected?). The expected badge issuer is derived as
did:web:<host-of-your-configured-issuer>. Minister signs badge VCs withdid:web:<MINISTER_ISSUER_DOMAIN>. If the Minister deployment'sMINISTER_ISSUER_DOMAINhost does not equal the OIDC issuer host, every badge fails verification and lands inrejectedwith an issuer mismatch (login andid_tokenverification are unaffected). If you see all badges rejected, check that Minister'sMINISTER_ISSUER_DOMAINhost matches its OIDC issuer host.
Some badges carry a gating nullifier — verifiedBadge.nullifier, an opaque
mnv1:... string typed as MinisterGatingNullifier. When present, it is a
per-relying-party Sybil-dedup tag Minister derives from the credential behind
the badge (the email, the GitHub account) and stamps inside the signed
credentialSubject at disclosure. Use it to enforce "one credential, one
account" on your side (dedup, persistent bans):
const badge = badges.find((b) => b.type === "oauth-account");
if (badge?.nullifier) {
// Stable per (credential, YOUR site). Ban or dedup keyed on this value.
await gateOn(badge.nullifier);
}Its guarantees, precisely:
- Per-site and unlinkable. Your site sees a value derived for your
client_id; other sites get a different, unlinkable tag for the same credential. It is not a cross-site correlator. - Stable and persistent. The same value appears if any account re-proves the same credential to your site — and it survives the user deleting and re-creating their Minister account, so a ban keyed on it persists across that churn.
- Bound under the signature. It rides in the same signed VC as the badge,
tied to this badge's subject,
jti, type, andexp, so it cannot be lifted onto another credential or replayed as another user. The existing binding rule still holds: the wrappers (verifyBadges/exchangeCode/ministerBadgesFromProfile) require the badge subject's trailing component to equal theid_tokensub, so a borrowed badge lands inrejected. - Absent ⇒ untagged. Badges with no wired nullifier (invite-code,
age/residency, and any pre-M5 disclosure) expose
nullifier: undefined; gate accordingly. A present-but-malformed value fails the badge closed.
Honesty: this proves one credential, not one person. Its strength is only
the strength of the credential behind it — see each badge type's
sybilResistance (none / weak / moderate). A weak anchor (catch-all
email domains, cheap GitHub accounts) is cheap to farm; weight it yourself.
Never treat the nullifier as a unique-human oracle.
This value is not interchangeable with @ministryofmany/nullifier's
Poseidon/BN254 nullifier, and there is no conversion between them:
@ministryofmany/nullifier |
MinisterGatingNullifier (this) |
|
|---|---|---|
| Math | Poseidon / BN254 | RFC 9497 VOPRF + HMAC-SHA256 |
| Anchor | the per-RP sub (account) |
the credential (email, GitHub id) |
| Circuit-usable | yes (SNARK-provable; membership/RLN) | no (gating-only, plaintext compare) |
| Catches | same account across contexts | same credential across accounts |
The branded type stops the two from being mixed at compile time. A future circuit-usable credential nullifier must be a new Poseidon construction, never a bridge from this gating value.
| Export | Purpose |
|---|---|
createMinisterClient(config) |
Build a flow client bound to one Minister + RP. |
client.getAuthorizationUrl(args) |
Discover the authorize endpoint and build the redirect URL. |
client.exchangeCode(args) |
Token exchange + verify id_token + verify badges. Returns { claims, badges, rejected }. |
client.verifyMinisterBadge(vcJwt, opts?) |
Verify a single VC badge; type is a slug string (e.g. "email-domain"). |
client.generatePkce() |
PKCE S256 { verifier, challenge }. |
client.randomToken(bytes?) |
URL-safe random state / nonce. |
client.badgeScope(slug) |
"badge:<slug>" helper. |
createMinisterVerifier(config) |
Configure-once verifier: verifyIdToken, verifyBadges, verifyBadge. |
verifyMinisterIdToken, verifyMinisterBadges, verifyMinisterBadge |
The standalone verification functions. |
badgeScope, badgeScopes, badgeTypeOf, knownBadgeTypes, getBadgeClaimSchema |
Badge vocabulary helpers. |
MinisterTokenError |
Thrown when an id_token itself fails verification. |
OidcFlowState, MinisterClaims, VerifiedBadge, BadgesResult, RejectedBadge, ... |
Public types. |
VcVerificationError, OidcError, MinisterTokenError |
Error classes. |
exchangeCode and verifyMinisterBadge accept injectable key sources so your
tests never hit Minister: pass idTokenKey / badgeKey (to exchangeCode) or
{ key } (to verifyMinisterBadge) — a KeyLike, a Uint8Array, or a jose
key-resolver function. The default is a remote JWKS fetched from Minister.
MIT OR Apache-2.0