security proposal: node:crypto.verifyOIDCClaims — stdlib primitive to close the OIDC Provider Trust Boundary bug class
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Idoneità per principianti
- 20/100
- Tipo di issue
- Funzionalità
- Chiarezza
- Abbastanza chiara
- Stato di attività
- Attiva
- Stack tecnologico
- javascript, node.js
- Ambito
- authentication, backend, security
Direzione di ricerca
Start by reading the RFC in this issue, especially its proposed API, trust contracts, and non-goals. The proposal describes a new node:crypto primitive but does not identify implementation files or tests; first check the Node.js contribution process and discuss scope with maintainers. Done would require an accepted design, implementation, and tests covering the stated provider contracts and standard OIDC claims.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
RFC: node:crypto.verifyOIDCClaims — a provider-aware OIDC ID token verifier for Node core
Status: Design proposal · Author: @61465 · Target: nodejs/node
· Draft: 2026-10-22 · Ship (if accepted): TBD
Summary
Add verifyOIDCClaims to node:crypto: a fail-closed,
provider-aware OIDC ID-token verifier that encodes the four trust
contracts (Microsoft xms_edov, Apple string-literal
email_verified, Google hd-branch, GitHub /user/emails
same-object primary && verified) whose misimplementation produced
six vendor advisories in eight days — closing a bug class, not
a bug.
The function takes an OIDC ID token plus a provider-aware
trust contract and returns a verified claim set, or rejects.
The trust contract encodes the per-provider guards that the
OAuth 2.0 Security BCP (RFC 9700) and each provider's own
documentation already require but that today's Node ecosystem
leaves to every consumer to re-implement.
The addition is strictly higher-level than the existing
crypto.verify() and crypto.createVerify() primitives. It
composes JWK import, signature verification, and standard-claim
validation (iss / aud / exp / nbf / nonce) that node:crypto and
node:util already provide the pieces for, and adds the
provider-contract layer that is otherwise impossible to reuse
across call sites in a single application.
Motivation
The gap today
Node core ships cryptographic primitives — crypto.verify, JWK
import, X509 handling — but no higher-level OIDC claim verifier.
Every consumer-side OIDC integration therefore composes its own
verifier out of either a userland library (jose,
openid-client, jsonwebtoken + manual claim checks) or
hand-rolled code around crypto.verify.
Each provider ships a semantic contract for its claims. The
contracts are not uniform:
- Microsoft Entra (Azure). The
emailclaim is only verified
when the optional claimxms_edovistrue(Boolean) and has
been configured on the application registration. The claim is
not emitted for Facebook or SAML / WS-Fed accounts. The
reference stays at
Microsoft Entra — Optional claims reference under
xms_edov. The baseemailclaim docs
(Microsoft Entra — ID token claims reference) say in
bold: "Never useemailorupnclaim values to store or
determine whether the user in an access token should have access
to data." - Apple Sign in with Apple.
email_verifiedand
is_private_emailare JSON string literals ("true"/
"false"), not booleans. A library that compares with the
strict Boolean=== truetreats every Apple-emitted token as
unverified; a library that relaxes to== trueadmits further
coercion bugs.is_private_email === "true"identifies a
relay-alias email that is addressable for delivery but is
not a stable account identifier. - Google OIDC. The
hd(hosted domain) claim is only
present for Google Workspace accounts. Consumer@gmail.com
tokens omit it entirely (per
Google — OpenID Connect). A library whose
verifier assumeshdis universally present fails-open on every
consumer login; a library whose verifier accepts anyhdvalue
without pinning admits every Google Workspace tenant as a
trusted internal user. - GitHub OAuth. The
/userendpoint returns a singleemail
string with no verification signal attached. The verified-email
truth lives at/user/emails, as an array of
{email, primary, verified, visibility}objects. The verified
trust decision isprimary === true AND verified === trueon
the same array element; the order of the array is not
documented as stable (per
GitHub REST — Emails).
Across the 2026-10-14 – 2026-10-21 week, six consumer products
shipped failures against these contracts. Four are tracked by
GitHub Security Advisories, all in triage state at the time of
this RFC:
GHSA-ffp4-94w6-vfgv (Supabase, CVSS 9.8, in triage),
GHSA-2gvh-5vh3-m2rp (Hanko, CVSS 9.8, in triage),
GHSA-hm26-jm32-3wp4 (Documenso, in triage),
GHSA-x3fc-mfpw-93f6 (Auth.js, in triage). Two
were reported through vendor-email channels (Keycloak, Casdoor).
The CVSS 9.8 figures on the Supabase and Hanko advisories are
confirmed in the GHSA records; the Documenso and Auth.js severity
fields are the self-reported values from the submission and have
not yet been set by the vendors.
The pattern is not "one careless consumer" — it is a bug class
that recurs because the primitives developers reach for
(jose.jwtVerify, jsonwebtoken.verify, hand-rolled
crypto.verify) stop below the trust contract layer. The
contract is left to the application. The application reads a
different provider's docs each time, misreads one claim type, and
ships the class.
Why this belongs in Node core
Three reasons:
- The primitives are already here.
crypto.verify, JWK
import,URLparsing foriss,Buffer.timingSafeEqualfor
nonce — all stdlib. The glue layer is what is missing. A
stdlib verifier removes the "which userland library do I
trust" decision from the critical path. - The contracts are stable enough to maintain. The four
contracts in this proposal are grounded in each provider's own
current documentation; adding a provider requires one
predicate function. The maintenance burden sits onnode:crypto
maintainers plus one named reviewer per provider (see §4.4
below). - The guidance is already in the OAuth 2.0 Security BCP
(RFC 9700). RFC 9700 mandatesiss,aud, nonce,
and signature verification as the floor; it is silent on
provider-specific trust contracts because those are outside the
OAuth protocol. Node core can carry what the IETF BCP
mandates plus the provider layer the BCP implicitly
assumes, in a single primitive. - The policy-engine pattern already lives in
node:crypto.
X509Certificate.checkHost,checkEmail, andcheckIssued
encode WebPKI policy — name constraints, SAN parsing, CRL/OCSP
semantics — that tracks CA/B Forum ballots the same way this
proposal would track provider documentation. The "scope creep"
argument is already structurally unavailable: Node core
maintains vendor-tracking policy today in exactly the module
this proposal targets.
Non-goals
This primitive is not:
- An HTTP client. JWKS resolution is caller-supplied (static set
or async lookup); the primitive never fetches, caches, or
retries. The caller owns the cache and the retry policy. - An OAuth flow handler. Authorization-code exchange, PKCE, state
binding, redirect handling, token refresh, and token revocation
are out of scope. This is a verifier, not a client. - A UserInfo endpoint resolver. If the application needs more than
the ID token carries, it calls UserInfo itself; the primitive
verifies the ID token and stops. - A plugin registry. The
trustContract.customescape hatch is a
per-call function, not a stateful registry;node:cryptohas no
other stateful registries today and this proposal does not
introduce one. - A session or token-storage layer. The primitive is pure: it
takes an ID token in and returns a verdict out. Session binding
is the caller's responsibility. - A replacement for
jose,openid-client, oroauth4webapi
for non-OIDC JWTs or for OIDC client work. Those libraries
cover a wider surface and a different layer; this primitive
composes with them, not against them.
Guide-level explanation
The primitive is a single function, asynchronous, no persistent
state.
import { verifyOIDCClaims } from 'node:crypto';
const { claims, provider, trusted } = await verifyOIDCClaims(idToken, {
issuer: 'https://login.microsoftonline.com/<tenant>/v2.0',
audience: '<client-id>',
jwks: await fetchJWKS(discoveryUrl),
nonce: expectedNonce,
provider: 'azure',
trustContract: {
requireEmailVerified: true,
},
clockToleranceSeconds: 60,
});
The returned trusted object has only the claims the trust
contract would allow the application to act on:
{
subject: 'tid:oid',
email: '[email protected]' | null,
// additional provider-specific fields
}
A failure throws a typed error:
try {
await verifyOIDCClaims(idToken, options);
} catch (err) {
if (err.code === 'ERR_OIDC_CONTRACT') {
// provider contract violated (e.g. xms_edov not asserted)
} else if (err.code === 'ERR_OIDC_SIGNATURE') {
// JWT signature failed
} else if (err.code === 'ERR_OIDC_ISS') {
// issuer mismatch
}
}
A callback form is accepted for parity with the callback-last async
primitives in node:crypto (generateKeyPair, scrypt, hkdf,
pbkdf2, randomBytes). crypto.verify itself is synchronous and
is not the shape precedent here — JWKS resolution and provider-
contract evaluation are inherently async for the common remote-key
case, so the primitive is callback-last-async-only (no sync
overload):
verifyOIDCClaims(idToken, options, (err, result) => { ... });
Four providers ship built-in trust contracts in the initial
release: azure, apple, google, github. The generic
provider enforces only the OAuth BCP floor. A custom contract may
be supplied for any provider.
The returned result exposes contractMeta with the exact version
of the trust contract that produced the verdict, together with
the references (GHSA / CVE / vendor-doc) that drove each guard:
const { contractMeta, trusted } = await verifyOIDCClaims(idToken, {
provider: 'azure',
// ... issuer, audience, jwks, etc
});
console.log(contractMeta.version); // '1.0.0'
console.log(contractMeta.references[0].id); // 'GHSA-ffp4-94w6-vfgv'
console.log(contractMeta.references[0].droveGuard); // 'xms_edov === true'
Callers who want to lock a specific contract version across Node
upgrades supply pin:
await verifyOIDCClaims(idToken, {
provider: 'azure',
pin: { azure: '1.0.0' }, // throw if active contract != '1.0.0'
// ...
});
See §4.5 for the full three-layer design (metadata · pinning ·
future external manifest federation).
Reference-level explanation
Signature
function verifyOIDCClaims(
idToken: string,
options: VerifyOIDCClaimsOptions,
callback?: (err: Error | null, result?: VerifyOIDCClaimsResult) => void,
): Promise<VerifyOIDCClaimsResult> | void;
VerifyOIDCClaimsOptions
interface VerifyOIDCClaimsOptions {
// RFC 9700 floor — required for all providers.
issuer: string | string[]; // expected `iss`
audience: string | string[]; // expected `aud`
jwks: JWKSet | ((kid: string) => Promise<JWK>); // JWKS or lookup
clockToleranceSeconds?: number; // default 0
// Nonce binding when the caller initiated the request.
nonce?: string;
// Max token age guard (iat).
maxTokenAgeSeconds?: number;
// Provider-aware layer.
provider: 'azure' | 'apple' | 'google' | 'github' | 'generic';
trustContract?: TrustContract;
// Trust-contract version pin (§4.5 Layer 2). When present, the
// call throws ERR_OIDC_PIN_MISMATCH if the active built-in
// contract for the pinned provider is not exactly this semver.
// Absent = latest built-in applies (default).
pin?: Partial<Record<
'azure' | 'apple' | 'google' | 'github' | 'generic',
string // exact semver, e.g. '1.0.0'
>>;
// Optional algorithms whitelist (default: provider's known set).
algorithms?: string[];
// Observability. When set, the primitive publishes one event per
// internal step (parse / alg / kid / signature / iss / aud / exp /
// nbf / nonce / contract) to the supplied diagnostics_channel.
// Event shape: { step, ok, code?, elapsedNs, provider }. Zero
// overhead when the channel has no subscribers (same semantics as
// `undici`'s diagnostics_channel integration).
//
// This option does NOT change control flow: the function still
// throws on failure or returns a Result on success. The channel
// is strictly a non-blocking observer. Fail-closed invariant
// (Section Reference-level) is unaffected.
auditChannel?: import('node:diagnostics_channel').Channel;
}
VerifyOIDCClaimsResult
type VerifyOIDCClaimsResult =
| { provider: 'azure'; claims: FrozenClaims; trusted: AzureTrusted; contractMeta: TrustContractMeta }
| { provider: 'apple'; claims: FrozenClaims; trusted: AppleTrusted; contractMeta: TrustContractMeta }
| { provider: 'google'; claims: FrozenClaims; trusted: GoogleTrusted; contractMeta: TrustContractMeta }
| { provider: 'github'; claims: FrozenClaims; trusted: GitHubTrusted; contractMeta: TrustContractMeta }
| { provider: 'generic'; claims: FrozenClaims; trusted: GenericTrusted; contractMeta: TrustContractMeta };
type FrozenClaims = Readonly<Record<string, unknown>>;
// Defined in §4.5 Layer 1. Repeated here for reference.
interface TrustContractMeta {
provider: string;
version: string;
firstShippedInNode: string;
lastReviewedAt: string;
guards: readonly string[];
references: readonly ContractReference[];
}
interface ContractReference {
type: 'ghsa' | 'cve' | 'vendor-doc' | 'rfc' | 'conference-talk';
id: string;
url: string;
severity?: number;
droveGuard?: string;
note?: string;
}
interface AzureTrusted {
/** Provider's immutable identity. Format: `${tid}:${oid}`. */
subject: string;
/** Verified email per `V(azure, email)` or `null` when withheld. */
email: string | null;
oid: string;
tid: string;
}
interface AppleTrusted {
/** Apple's `sub` claim — stable across relink. */
subject: string;
email: string | null;
/** Only meaningful when `email !== null`. */
emailAddressable: boolean;
emailPrivate: boolean;
}
interface GoogleTrusted {
/** Google's `sub` claim. */
subject: string;
email: string | null;
identityType: 'workspace' | 'consumer';
/** Present only when identityType === 'workspace'. */
workspaceDomain: string | null;
}
interface GitHubTrusted {
/** Format: `github:${numeric id}`. */
subject: string;
email: string | null;
/** All objects from /user/emails where verified === true, for audit. */
verifiedEmails: ReadonlyArray<{ email: string; primary: boolean }>;
}
interface GenericTrusted {
subject: string;
email: string | null;
}
claims is the full post-verification claims object exactly as
emitted by the provider, with no normalization applied to the keys.
Frozen. Callers that need audit logging over provider-specific
fields (e.g., Microsoft aio, Apple real_user_status) read from
there. trusted is the subset of claims the trust contract allowed
the caller to act on; email is null whenever the contract
withheld it. The subject is always the provider's immutable
identifier (${tid}:${oid} / sub / sub / github:${id}),
never the email claim.
TrustContract
interface TrustContract {
// Email trust.
requireEmailVerified?: boolean; // generic
requireXmsEdov?: boolean; // azure
requireHd?: string | string[] | 'any'; // google
requirePrimary?: boolean; // github (triggers /user/emails fetch?)
rejectPrivateEmail?: boolean; // apple is_private_email
// Caller supplies for github — the pre-fetched /user/emails
// response. Required when `provider === 'github'` AND
// `requirePrimary === true`; absent in that combination throws
// `ERR_OIDC_CONTRACT` fail-closed. (Node core does not do HTTP.)
githubEmails?: ReadonlyArray<{
email: string; primary: boolean; verified: boolean;
}>;
// Escape hatch for providers not covered by the built-ins.
custom?: (claims: Record<string, unknown>) => TrustVerdict;
}
type TrustVerdict =
| { ok: true; emitClaims: string[] } // claims safe to surface
| { ok: false; reason: string };
Error types
class OIDCError extends Error {
code: 'ERR_OIDC_SIGNATURE'
| 'ERR_OIDC_ISS'
| 'ERR_OIDC_AUD'
| 'ERR_OIDC_EXP'
| 'ERR_OIDC_NBF'
| 'ERR_OIDC_NONCE'
| 'ERR_OIDC_CONTRACT'
| 'ERR_OIDC_PIN_MISMATCH' // §4.5 Layer 2: pin set, active version differs
| 'ERR_OIDC_MALFORMED';
provider: string;
detail?: string;
}
Implementation sketch
The implementation lives at
lib/internal/crypto/oidc.js, below the public re-export in
lib/crypto.js. The hot path:
- Parse the token into header + payload + signature (base64url
decode, JSON parse the first two). - Validate
algis in the allowed set and matches the key type. - Resolve the key via
kidfrom either the staticJWKSetor
the async lookup callback. - Call
crypto.verify(alg, data, key, signature)on the
signed input. - Validate standard claims:
iss,aud,exp,nbf,
nonceif required,iatifmaxTokenAgeSecondsset. - Apply the provider contract (see §4.4).
- Return the trusted claim set.
Each step throws a typed OIDCError on failure. The function
never returns a partial trust state.
Fail-closed, precisely defined. "Fail-closed" here means: the
function never returns atrustedfield that the trust contract
did not license. It does not mean every verification call
succeeds or throws. The common case wheretrusted.email === null
is not a failure — it is the correct trusted answer when the
contract withheld email while the authentication itself was valid.
The subject (oid:tid/sub/github:${id}) is always
populated when a result is returned; email, workspace, or
private-address fields may benullwhen the contract so decides.
A caller should treat a returned result as "every field present
was validated under the trust contract; every field absent was
explicitly withheld by it." Athrowmeans the invariant could
not be established at all.
Provider contract reference
The built-ins pin the four contracts described in §Motivation.
Pseudo-code for each is included in a companion
doc/api/crypto/oidc-contracts.md; the full predicates
(including the regression cases around string-vs-Boolean coercion)
are drawn from the public ptb-report-card methodology v0.1.
Each contract carries exactly one named reviewer in the Node core
CODEOWNERS file. A provider update (e.g., Microsoft adds a new
xms_* optional claim affecting email trust) is a two-reviewer
sign-off: the named provider reviewer plus a node:crypto
maintainer.
Trust-contract metadata, lineage, and version pinning
The primitive treats trust contracts as first-class versioned
artifacts, not opaque hard-coded predicates. Every built-in
contract carries three kinds of metadata; two of them (version +
provenance) are mandatory in every release, the third (external
manifest URL) is reserved for a follow-up revision described in
§Future possibilities.
The design follows a three-layer composition. Each layer is
independently valuable; v1 ships Layers 1 + 2, Layer 3 is a
chartered v2 roadmap item.
Layer 1 — Contract metadata (ships v1, mandatory)
Every call result exposes contractMeta:
interface TrustContractMeta {
provider: 'azure' | 'apple' | 'google' | 'github' | 'generic';
version: string; // semver; bumps when a guard changes
firstShippedInNode: string; // Node release where this contract first landed
lastReviewedAt: string; // ISO date, from CODEOWNERS review ping
guards: readonly string[]; // human-readable guard expressions
references: readonly ContractReference[];
}
interface ContractReference {
type: 'ghsa' | 'cve' | 'vendor-doc' | 'rfc' | 'conference-talk';
id: string; // 'GHSA-ffp4-94w6-vfgv' | 'CVE-...' | 'RFC-9700' | ...
url: string;
severity?: number; // CVSS, when applicable
droveGuard?: string; // which guard in `guards` this reference justifies
note?: string; // short editorial note (<= 140 chars)
}
An example from the Azure built-in at Node 24.0.0:
{
"provider": "azure",
"version": "1.0.0",
"firstShippedInNode": "26.0.0",
"lastReviewedAt": "2026-10-22",
"guards": ["xms_edov === true"],
"references": [
{ "type": "vendor-doc", "id": "ms-entra-xms-edov",
"url": "https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference" },
{ "type": "ghsa", "id": "GHSA-ffp4-94w6-vfgv", "severity": 9.8,
"droveGuard": "xms_edov === true", "url": "..." },
{ "type": "ghsa", "id": "GHSA-2gvh-5vh3-m2rp", "severity": 9.8,
"droveGuard": "xms_edov === true", "url": "..." },
{ "type": "conference-talk", "id": "descope-noauth-2024",
"url": "...", "note": "Original public writeup of nOAuth class" }
]
}
An SRE pipeline can grep the references[].id to answer "do any
of our Node runtimes carry a contract tied to a specific GHSA we
care about" without reading C++ source. A security auditor can
diff TrustContractMeta.version across Node releases and know
exactly which provider contracts tightened and why.
Layer 2 — Version pinning + changelog discipline (ships v1)
Callers can pin specific contract versions to lock verification
behavior across Node upgrades:
await verifyOIDCClaims(idToken, {
provider: 'azure',
pin: { azure: '1.0.0' }, // reject if the active contract isn't exactly 1.0.0
// ...
});
Behavior:
pinabsent — the latest built-in contract applies. The
caller receives the currentcontractMetaand may act on
contractMeta.versionto detect drift.pinpresent and matches — contract applies, no observable
behavior difference.pinpresent and mismatch — the call throws
ERR_OIDC_PIN_MISMATCHwith adetailnaming both the pinned
and the active version. This is deliberate; silent-upgrading a
pinned caller would defeat the pin.
A new entry type lands in the Node.js release changelog whenever
a built-in contract bumps:
### Changed
- crypto: azure trust-contract bumped to 1.1.0 — adds the
`xms_tcdt` guard. Driven by GHSA-xxxx-yyyy. Callers pinning
[email protected] remain on the old contract; unpinned callers
receive the new guard automatically.
The changelog line is the audit trail. Any CVE in a provider's
own documentation that drives a Node-side contract tightening is
cross-linked in both directions.
Layer 3 — External manifest federation (chartered v2)
Scheduled for a follow-up RFC. See §Future possibilities for the
sketch. The short version: .well-known/oidc-trust-contract.json
is a canonical format any provider can publish on their own
infrastructure. The primitive gains an options.manifestUrl
option that loads a contract from a vendor URL on an explicit
caller-controlled allowlist. Node core becomes the primary
consumer and arbiter of a federated contract registry rather than
the sole author.
v1 does not implement this layer. It is called out here so
the v1 metadata shape (Layer 1) can be read as "the local format
of what will become a federated manifest" rather than "a stdlib
quirk that will later be regretted."
Drawbacks
-
Scope creep for
node:crypto. The module today is
cryptographic primitives plus thin protocol glue
(X509Certificate.verify,checkHost, JWK import). A hostile
reviewer would frame this proposal as a step beyond that
thin-glue line: a policy engine carrying four vendor-
specific trust contracts, each tied to its vendor's release
cadence (Microsoft, Apple, Google, GitHub), exactly the kind
of surface Node has historically pushed to userland
(undiciships separately,node:testwas debated for
years,node:fetcharrived decades after the ecosystem).The authors' counter-argument has three parts and is intended
to be weighed as a package, not point-by-point:(a) The surface area is small and bounded. Four
contracts, each one predicate function, each covered by one
named CODEOWNER plus anode:cryptoreviewer. The retainment
cost is on the same order asX509Certificate's current
CRL / OCSP support, which Node core already carries.(b) The absence creates documented, repeated vulnerabilities.
Six vendors shipped the same bug class in eight days. The
pattern is not "userland hasn't solved it yet"; it is
"userland cannot solve it durably without the stdlib primitive
because each userland solution ships its own OAuth-BCP floor
and leaves the provider layer to the application above it."(c) An alternative shape that keeps
node:cryptopure —
a separatenode:oidcmodule — is evaluated in §Rationale
and alternatives and is open for the TSC's preference. If
"yes to the primitive, no innode:crypto" is the collective
position, the authors will re-draft againstnode:oidc. -
Maintenance burden on provider contracts. Microsoft, Apple,
Google, GitHub change their claim semantics over time. Node core
must keep up. The CODEOWNERS pin and the trust-contract
override hatch bound the cost: a provider that shifts its
contract without warning gets patched behind semver-minor; a
consumer who needs an unreleased contract suppliescustom. -
Potential duplication with userland libraries.
jose,
openid-client, andjsonwebtokencover the lower-level pieces
this primitive calls into. The primitive is strictly additive —
nothing in it precludes continued use of those libraries for
non-OIDC JWT work.
Rationale and alternatives
Why not keep this in userland? Userland libraries today each
re-implement the exact same OAuth 2.0 Security BCP (RFC 9700)
floor — iss, aud, exp, nbf, signature, nonce — because
node:crypto stops one layer below. jose has it,
openid-client has it, jsonwebtoken + a handwritten wrapper
has it, every provider SDK has it. Each implementation has to
re-prove it got the OAuth BCP right, and each implementation
leaves the provider-contract layer above it to the application
developer. Putting the floor in node:crypto lets userland
libraries stop re-implementing the floor and focus on the UX
above it; putting the provider contracts in node:crypto makes
the right trust decision the easy decision at every call site.
A library can wrap this primitive, but the primitive is the
thing missing.
Why not prove this in userland first (as Node has done for
fetch, undici, node:test)? Fair question — Node's
historical pattern is to let a userland implementation accrue
adoption first, then adopt. This proposal is deliberately shaped
to permit exactly that gate: §Companion artifacts commits to a
PR against panva/jose that implements the same API shape, so
Node core maintainers can observe adoption and breakage data
before carrying the surface area. The RFC does not ask Node
core to leapfrog userland; it asks Node core to pre-commit to
where the primitive will land once userland has proven the
shape. The ship-in-userland-then-propose order is also
acceptable to the authors; this RFC-first order was chosen so
that the design review and the userland-adoption review can
proceed in parallel rather than serial. The authors will accept
a decision to defer the stdlib addition pending jose adoption.
Why not a separate node:oidc module? Discussed. A separate
module adds a surface, pulls in url parsing and JWK import that
duplicate node:crypto, and leaves "which to import" as a user
decision. Placing the primitive next to crypto.verify and
X509Certificate.verify keeps the mental model local.
Why only these four providers in the first release? The four
have in-triage GHSAs covering the exact pattern this
primitive defends. Discord, LinkedIn, Twitter / X, and Facebook
each have less-sharply-defined contracts (LinkedIn OIDC emits no
email_verified; Twitter / X email is scope-gated) and are
scheduled for a follow-up revision after the first ships.
Why a trustContract object and not a plugin registry? A
plugin registry would be stateful; node:crypto has no other
stateful registries today. A per-call trustContract keeps the
primitive pure and lets a single process serve multiple providers
with different contracts.
Prior art
joseby Filip Skokan — the de facto Node.js JWT /
JWS / JWK library. ProvidesjwtVerifyand
createRemoteJWKSetwith generic standard-claim validation
(iss, aud, exp, nbf, nonce). Does not encode
provider-specific trust contracts;xms_edov,is_private_email,
hd-branching, andprimary+verifiedare left to the caller.openid-clientby Filip Skokan — a full OIDC client with
flow handling. Verifies ID tokens on the token endpoint; does
not encode the provider trust-contract layer this proposal
targets. (The author has published a successor,
oauth4webapi; see below.)oauth4webapiby Filip Skokan — the WHATWG-fetch-first
successor toopenid-client. Verifies ID tokens viajoseand
therefore inherits the same provider-contract gap: standard
claims validated, provider-specific guards (xms_edov,
is_private_email,hd-branch, GitHub/user/emails
same-object) left to the caller. The Node primitive proposed
here composes beneathoauth4webapithe same way it composes
beneathopenid-client.arctic(Lucia ecosystem) — a provider-aware OAuth / OIDC
library in TypeScript. Encodes some per-provider claim handling
inline per module (one Azure module, one Google module, etc.)
but ships no shared trust-contract type and noverifyOIDCClaims
equivalent. Each new provider is a copy-paste of the previous.- Deno
@std/oauth— Deno's standard-library OAuth client.
Verifies tokens via the WebCrypto equivalent ofnode:cryptobut
does not encode provider trust contracts. - Bun — ships no first-party OIDC client as of writing; relies
on npm userland. AverifyOIDCClaimsinnode:cryptowould
reach Bun transparently (Bun implementsnode:crypto
compatibility). jsonwebtoken— JWT signing / verification; standard-claim
validation only; no provider awareness.oidc-client-ts— browser-first OIDC consumer (SPA
context). Listed here for its API-shape precedent only; a
Node core primitive would not inherit from a browser-focused
library.User.profiletyped asIDTokenClaimsis
provider-neutral by design;xms_edovandis_private_email
are not surfaced through the typed public API.@azure/msal-node,passport-google-oauth20,
@teamhanko/*,@auth/core— provider-SDK libraries
that handle ID-token claims per-SDK. (@azure/msal-node
treats ID tokens as opaque in the authorization-code flow by
default; the trust decision foremailis left to the
application. The in-triage GHSAs cited in §Motivation each
come from the application layer above these SDKs, not from a
bug in the SDK itself.)identity-trust-hunt(public, MIT, github.com/61465/identity-trust-hunt)
— the scanner that discovered the four GHSAs grounding this
proposal. Scans consumer applications against provider trust
contracts.ptb-report-card(public, MIT, github.com/61465/ptb-report-card)
— companion methodology that scores consumer libraries on the
same contracts. This proposal would, if accepted, provide the
primitive the methodology currently measures libraries for
providing well or badly.- RFC 9700 — OAuth 2.0 Security Best Current Practice —
mandates the floor (signature, iss via RFC 9207, aud, nonce,
audience-restriction). This proposal implements that floor and
layers the provider trust contracts on top. - Go's
golang.org/x/oauth2andcoreos/go-oidc—
closest Go stdlib-adjacent equivalents. Both are generic;
neither encodes provider trust contracts.
Security considerations
- Fail-closed. Every error path returns a typed
OIDCError; there is no return value that mixes a success
shape with awarningsfield. A caller thatawaits and
catches is safe-by-construction. - Timing. Signature verification uses
crypto.verify
which is already constant-time for the supported algorithms.
Nonce comparison usesBuffer.timingSafeEqual. - JWKS caching. The primitive does not do HTTP. JWKS
resolution is either a staticJWKSetor a caller-supplied
async lookup; the caller owns the cache and the retry policy.
This keeps the primitive deterministic and testable, and keeps
the attack surface of the trust layer independent of the
network layer. - Algorithm whitelist. The default
algorithmslist excludes
noneand the HS* family (which would require the signing
secret to be shared with every consumer and are not used by
the four target providers). Callers may narrow further;
callers may not widen tonone. This closes the classic
RS256 → HS256 downgrade where the attacker reuses the issuer's
public key as an HMAC secret. kidconfusion. Thekidheader value is treated as a
lookup key into the caller-supplied JWKS, not as evidence
of which key to trust. A JWT whosekidpoints to a key the
JWKS does not contain is rejected withERR_OIDC_SIGNATURE;
the primitive never follows an attacker-controlled URL embedded
injku/jwk/x5uheaders. These headers are stripped
from consideration before verification.typheader confusion (RFC 9068 §4). When the JWT header
carries atypclaim, it must be"JWT"(case-insensitive) or
absent. An attacker who presents an access-token JWT
(typ: "at+jwt") in place of an ID token is rejected with
ERR_OIDC_MALFORMEDbefore signature verification runs.
This closes the access-token-as-ID-token substitution path
that RFC 9068 §4 flags as a shared concern across OAuth 2.0 /
OIDC deployments.- Provider contract updates. A silent provider shift
(e.g., Microsoft retiringxms_edovin favor of a new claim)
is handled by a semver-minor Node release. Until it ships,
thecustomescape hatch covers the gap.
Unresolved questions
- Should
verifyOIDCClaimssupport a sync overload (for parity
withcrypto.verify's sync form)? Tentative no — JWKS
resolution is inherently async when the key is remote; a sync
form would mislead on the common path. Should the trust contract forResolved: pre-fetched arraygithubtake a pre-fetched
/user/emailsarray (as sketched) or should the primitive
accept a fetcher callback?
only, required whenprovider === 'github'AND
requirePrimary === true, fail-closed with
ERR_OIDC_CONTRACTif absent. The primitive stays HTTP-free;
discoverability is handled by the typed error at runtime and
by TypeScript narrowing at compile time.- Should the primitive expose the raw post-verification claims
object alongsidetrusted, so that a caller can log the
provider's own fields without re-parsing? Tentative yes —
returningclaimsas a frozenRecord<string, unknown>
addresses the methodology'sapi_capabilitydimension at the
stdlib layer. - Node core's X509 class exposes
checkEmail. Should
verifyOIDCClaimssurface a parallelverifyAudience/
verifyIssuerstatic for callers who want to validate those
fields in isolation? Open.
Future possibilities
-
§4.5 Layer 3 — External manifest federation (chartered v2).
A canonical/.well-known/oidc-trust-contract.jsonschema that
any provider may publish on their own infrastructure, and an
options.manifestUrloption that loads a contract from a
caller-controlled allowlist of such URLs. Node core becomes the
primary consumer and arbiter of a federated contract registry
rather than the sole author. Draft manifest shape:{ "schema": "https://schemas.nodejs.org/oidc-trust-contract/1", "provider": "azure", "version": "1.1.0", "guards": ["xms_edov === true"], "references": [...] }The v1 metadata shape (Layer 1) is deliberately designed to be
the local serialisation of this future manifest: the fields are
identical, only the source of the record differs. -
Second revision adds Discord, LinkedIn, Twitter / X, Facebook
provider contracts. -
Userland composition pattern. The
trustContract.custom
escape hatch already lets a caller compose multiple predicates
by hand. A userland helper package (e.g.
oidc-trust-combinatorson npm) could shipand,or,not
combinators for multi-tenant and step-up-auth cases. Shipping
this as userland first validates the shape before any stdlib
surface area is considered. The primitive deliberately does
not ship composition primitives in v1. -
Discovery helper: optional
discoveryUrloption that resolves
issuer+jwksvia/.well-known/openid-configurationusing
the existingfetch. -
Companion
node:crypto.pickVerifiedEmailprimitive wrapping
the GitHub/user/emailsselection logic as a reusable utility
independent of the full ID-token flow. -
Provider-side (not consumer-side) OIDC helpers for Node-based
identity providers.
Companion artifacts
Alongside this RFC draft the authors ship:
- Reference TypeScript implementation sketch — a compilable
--strict-typed sketch covering every API surface, every
provider predicate, and the Layer 1BUILTIN_CONTRACTS
registry. It is not production code but it proves the shape is
implementable with zero TypeScript errors (approximately 590
lines, attached to the submission issue). - Test matrix — 46 cases across 9 sections (standard-claim
floor; signature + key-handling attacks; Azure, Apple, Google,
and GitHub provider contracts; fail-closed invariant;
discriminated-union TypeScript shape; and trust-contract
metadata + version pinning). The matrix is in a shape suitable
fortest/parallel/test-crypto-oidc-*.jsand is attached to
the submission issue. identity-trust-hunt(scanner): https://github.com/61465/identity-trust-huntptb-report-card(methodology + fix patterns): https://github.com/61465/ptb-report-card
- Lingua principale
- Go
- Stelle
- 549
- Fork
- 133
- Merge medio
- 11m
- PR unite (30g)
- 1
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di nodejs/security-wg
-
Difficoltà 3/5 1-2 giorni Idoneità per principianti 57/100
nodejs/security-wg#1595 ·
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 1/100
nodejs/security-wg#1592 · 1 reazione ·
-
Security Tooling ProposalForse di nuovo libera Una pull request per questa issue è stata chiusa senza essere unita. Apertasecurity-wg-agenda
Difficoltà 5/5 Più di una settimana Idoneità per principianti 25/100
nodejs/security-wg#1584 · 3 commenti · 1 reazione ·
-
Difficoltà 5/5 Più di una settimana Idoneità per principianti 10/100
nodejs/security-wg#1583 ·
-
Backlog: Adopt OpenJS CNA for CVE OperationsForse di nuovo libera @UlisesGascon l’ha presa 115 giorni fa e non c’è nessuna pull request aperta. Apertasecurity-wg-agenda wg-agenda
nodejs/security-wg#1576 · 2 commenti · 1 assegnatario ·
Tutte le issue di nodejs/security-wg
Issue simili
-
Project submission: BivyAperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 78/100
slavakurilyak/awesome-ai-agents#880 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
apache/openserverless#282 · 2 commenti ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
I maintainer di solito rispondono entro 1 giorno
-
agent-butler-finding bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
jordansmall/spindrift#6161 ·
I maintainer di solito rispondono entro 1 giorno