Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

security proposal: node:crypto.verifyOIDCClaims — stdlib primitive to close the OIDC Provider Trust Boundary bug class

オープン
#1,593 コメント 1 件 リアクション 1 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

評価

難易度
5/5
見積もり時間
1週間以上
初心者へのやさしさ
20/100
issue の種類
機能追加
明瞭さ
おおむね明確
活発さ
活発
技術スタック
javascript, node.js

調査の方向性

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.

索引モデルが issue の本文から書いたものです。

説明

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 email claim is only verified
    when the optional claim xms_edov is true (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 base email claim docs
    (Microsoft Entra — ID token claims reference) say in
    bold: "Never use email or upn claim values to store or
    determine whether the user in an access token should have access
    to data."
  • Apple Sign in with Apple. email_verified and
    is_private_email are JSON string literals ("true" /
    "false"), not booleans. A library that compares with the
    strict Boolean === true treats every Apple-emitted token as
    unverified; a library that relaxes to == true admits 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 assumes hd is universally present fails-open on every
    consumer login; a library whose verifier accepts any hd value
    without pinning admits every Google Workspace tenant as a
    trusted internal user.
  • GitHub OAuth. The /user endpoint returns a single email
    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 is primary === true AND verified === true on
    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:

  1. The primitives are already here. crypto.verify, JWK
    import, URL parsing for iss, Buffer.timingSafeEqual for
    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.
  2. 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 on node:crypto
    maintainers plus one named reviewer per provider (see §4.4
    below).
  3. The guidance is already in the OAuth 2.0 Security BCP
    (RFC 9700). RFC 9700 mandates iss, 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.
  4. The policy-engine pattern already lives in node:crypto.
    X509Certificate.checkHost, checkEmail, and checkIssued
    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.custom escape hatch is a
    per-call function, not a stateful registry; node:crypto has 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, or oauth4webapi
    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:

  1. Parse the token into header + payload + signature (base64url
    decode, JSON parse the first two).
  2. Validate alg is in the allowed set and matches the key type.
  3. Resolve the key via kid from either the static JWKSet or
    the async lookup callback.
  4. Call crypto.verify(alg, data, key, signature) on the
    signed input.
  5. Validate standard claims: iss, aud, exp, nbf,
    nonce if required, iat if maxTokenAgeSeconds set.
  6. Apply the provider contract (see §4.4).
  7. 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 a trusted field that the trust contract
did not license. It does not mean every verification call
succeeds or throws. The common case where trusted.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 be null when 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." A throw means 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:

  • pin absent — the latest built-in contract applies. The
    caller receives the current contractMeta and may act on
    contractMeta.version to detect drift.
  • pin present and matches — contract applies, no observable
    behavior difference.
  • pin present and mismatch — the call throws
    ERR_OIDC_PIN_MISMATCH with a detail naming 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
    (undici ships separately, node:test was debated for
    years, node:fetch arrived 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 a node:crypto reviewer. The retainment
    cost is on the same order as X509Certificate'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:crypto pure —
    a separate node:oidc module — is evaluated in §Rationale
    and alternatives and is open for the TSC's preference. If
    "yes to the primitive, no in node:crypto" is the collective
    position, the authors will re-draft against node: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 supplies custom.

  • Potential duplication with userland libraries. jose,
    openid-client, and jsonwebtoken cover 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

  • jose by Filip Skokan — the de facto Node.js JWT /
    JWS / JWK library. Provides jwtVerify and
    createRemoteJWKSet with generic standard-claim validation
    (iss, aud, exp, nbf, nonce). Does not encode
    provider-specific trust contracts; xms_edov, is_private_email,
    hd-branching, and primary+verified are left to the caller.
  • openid-client by 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.)
  • oauth4webapi by Filip Skokan — the WHATWG-fetch-first
    successor to openid-client. Verifies ID tokens via jose and
    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 beneath oauth4webapi the same way it composes
    beneath openid-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 no verifyOIDCClaims
    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 of node:crypto but
    does not encode provider trust contracts.
  • Bun — ships no first-party OIDC client as of writing; relies
    on npm userland. A verifyOIDCClaims in node:crypto would
    reach Bun transparently (Bun implements node: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.profile typed as IDTokenClaims is
    provider-neutral by design; xms_edov and is_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 for email is 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/oauth2 and coreos/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 a warnings field. A caller that awaits and
    catches is safe-by-construction.
  • Timing. Signature verification uses crypto.verify
    which is already constant-time for the supported algorithms.
    Nonce comparison uses Buffer.timingSafeEqual.
  • JWKS caching. The primitive does not do HTTP. JWKS
    resolution is either a static JWKSet or 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 algorithms list excludes
    none and 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 to none. This closes the classic
    RS256 → HS256 downgrade where the attacker reuses the issuer's
    public key as an HMAC secret.
  • kid confusion. The kid header value is treated as a
    lookup key into the caller-supplied JWKS, not as evidence
    of which key to trust. A JWT whose kid points to a key the
    JWKS does not contain is rejected with ERR_OIDC_SIGNATURE;
    the primitive never follows an attacker-controlled URL embedded
    in jku / jwk / x5u headers. These headers are stripped
    from consideration before verification.
  • typ header confusion (RFC 9068 §4). When the JWT header
    carries a typ claim, 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_MALFORMED before 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 retiring xms_edov in favor of a new claim)
    is handled by a semver-minor Node release. Until it ships,
    the custom escape hatch covers the gap.

Unresolved questions

  1. Should verifyOIDCClaims support a sync overload (for parity
    with crypto.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.
  2. Should the trust contract for github take a pre-fetched
    /user/emails array (as sketched) or should the primitive
    accept a fetcher callback?
    Resolved: pre-fetched array
    only, required when provider === 'github' AND
    requirePrimary === true, fail-closed with
    ERR_OIDC_CONTRACT if absent. The primitive stays HTTP-free;
    discoverability is handled by the typed error at runtime and
    by TypeScript narrowing at compile time.
  3. Should the primitive expose the raw post-verification claims
    object alongside trusted, so that a caller can log the
    provider's own fields without re-parsing? Tentative yes —
    returning claims as a frozen Record<string, unknown>
    addresses the methodology's api_capability dimension at the
    stdlib layer.
  4. Node core's X509 class exposes checkEmail. Should
    verifyOIDCClaims surface a parallel verifyAudience /
    verifyIssuer static 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.json schema that
    any provider may publish on their own infrastructure, and an
    options.manifestUrl option 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-combinators on npm) could ship and, 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 discoveryUrl option that resolves
    issuer + jwks via /.well-known/openid-configuration using
    the existing fetch.

  • Companion node:crypto.pickVerifiedEmail primitive wrapping
    the GitHub /user/emails selection 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 1 BUILTIN_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
    for test/parallel/test-crypto-oidc-*.js and is attached to
    the submission issue.
  • identity-trust-hunt (scanner): https://github.com/61465/identity-trust-hunt
  • ptb-report-card (methodology + fix patterns): https://github.com/61465/ptb-report-card
主要言語
Go
スター
547
フォーク
133
平均マージ
11分
マージ済み PR(30日)
1

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

nodejs/security-wg のほかの issue

nodejs/security-wg の issue をすべて見る

似ている issue

Go の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。