feat(oidc): @exortek/oidc — OpenID Connect Core 1.0 - #56
Conversation
New package #19 in the 20-package stack — the OpenID Connect Core 1.0 identity layer on top of @exortek/oauth2. Adds the build wiring only: package.json (0.0.0, unpublished), tsconfig, rollup entries for the root barrel plus ./client and ./provider subpaths, README in house style with a pre-release notice, and an empty CHANGELOG.
The public API shape, settled and validated; the flow bodies are scaffolded. createClient enforces the openid scope and validates its config; createProvider validates issuer/jwks/store. Both return objects whose flow methods (createAuthUrl, handleCallback, discoveryHandler, userinfoHandler) throw OidcError NOT_IMPLEMENTED until the endpoint pipeline lands. OidcError follows the shared BaseError idiom.
Covers the public exports, that ./client and ./provider resolve to the same factories, the config guards (INVALID_ARGUMENT), openid-scope defaulting, and that every scaffolded flow method throws NOT_IMPLEMENTED. 10 tests.
createClient is a discovery-first facade over @exortek/oauth2: it builds a
single defineProvider({ discover: true }) + createOAuth internally, so the
issuer's endpoints, PKCE/state/nonce, id_token verification (iss/aud/nonce/
exp, azp, at_hash) and UserInfo layering all come from oauth2 rather than
being reimplemented. authorize() threads the OIDC auth-request params
(prompt/login_hint/max_age/acr_values); handleCallback() returns
{ idToken, claims, userinfo, user, tokens }. refresh/revoke pass through.
Tested end to end against a hermetic stub OP (local HTTP server + a real
ES256-signed id_token) covering the authorize URL, a full login round-trip,
and a nonce-mismatch rejection.
createProvider returns the OIDC identity endpoints to mount beside an @exortek/oauth2/server createServer: discoveryHandler (a full OIDC /.well-known/openid-configuration, superset of oauth2's RFC 8414 metadata), userinfoHandler (OIDC Core §5.3 — Bearer resolve, scope→claims release per §5.4, claims.userinfo policy, 401 + WWW-Authenticate on bad tokens), jwksHandler, and an id_token signer reusing oauth2's createIdTokenSigner so server and provider sign with one key. Handlers speak a small framework-agnostic req/response shape (internal/ http-io.js) that the express/fastify adapters will translate. ErrorCode trimmed to what actually throws (INVALID_ARGUMENT); NOT_IMPLEMENTED dropped now that both halves are real. Deps pruned: jwt/jwks/oauth2 runtime, jwk moved to devDependencies (test-only). 20 tests.
OpenID Connect RP-Initiated Logout 1.0 on both halves. Client gains
endSessionUrl({ idTokenHint, postLogoutRedirectUri, state }) — the
end_session_endpoint is taken from config or resolved from OP metadata via a
small cached fetch (internal/issuer-discovery.js), since oauth2's own
discovery is not surfaced to the RP. Provider gains endSessionHandler, which
validates post_logout_redirect_uri against the client's registered URIs
before redirecting (echoing state), calls a best-effort onLogout hook to
clear the OP session, and otherwise returns a logged_out confirmation;
configuring logout auto-advertises end_session_endpoint in discovery.
Adds ErrorCode.DISCOVERY_FAILED (thrown by the metadata fetch). 27 tests.
OpenID Connect Session Management 1.0 on the provider. internal/session.js computes session_state (§4.2: base64url(sha256(client_id + origin + op_browser_state + salt)) + '.' + salt) and renders the OP check_session_iframe document whose browser-side script re-runs that calculation on each postMessage and replies unchanged/changed/error. createProvider gains sessionState() (for attaching session_state to an auth response) and checkSessionHandler(); configuring session auto-advertises check_session_iframe in discovery. 32 tests.
Four new subpath entries — ./client/express, ./client/fastify, ./provider/express, ./provider/fastify — plus the exports/rollup wiring and express/fastify as OPTIONAL peers (fastify plugins fp-wrapped via @exortek/shared's bundled fastifyPlugin). Provider adapters mount exactly the endpoints discovery advertises, at the paths it names, via a shared route table (provider/routes.js). Client adapters give a browser-redirect login: start stashes the flow session in a short-lived cookie and redirects; callback reads it back, runs handleCallback and hands the result to onSuccess (API/SPA clients call the client methods directly). UserInfo is now advertised only when a resolver is configured. 35 tests, incl. a fastify login round-trip against a stub OP.
House-style README (badges, Why, Modules, Usage, Why-not, Specifications) covering the RP client (authorize/handleCallback/endSessionUrl + express adapter), the OpenID Provider add-ons (discovery/userinfo/jwks/logout/ session + mount), and the reuse relationship with @exortek/oauth2. Replaces the pre-release scaffold notice.
Register oidc in the sidebar (_meta.js), flip the Shipping-table row to linked + npm badge on the landing page, and add web/content/oidc/ overview (RP + OpenID Provider usage, specs).
Major bump — 0.0.0 → 1.0.0 for the initial release.
Add an OpenID Connect section (Core / Discovery / RP-Initiated Logout / Session Management rows) to docs/compliance.md and web/content/compliance.mdx with a summary bullet, and flip the ARCHITECTURE.md spec-mapping row for oidc from planned to its shipped spec set.
Dedicated docs pages beside the overview — client (createClient), provider (createProvider), logout (RP-Initiated Logout), session (Session Management), middleware (Express/Fastify adapters) and errors (OidcError) — registered in _meta.js, with cross-links from the overview.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect client security and provider correctness.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds @exortek/oidc, an OpenID Connect relying-party and provider package built on @exortek/oauth2, with framework adapters, tests, documentation, and release metadata.
Changes:
- Adds OIDC client/provider APIs, discovery, UserInfo, logout, and session management.
- Adds Express/Fastify adapters and comprehensive tests.
- Updates documentation, compliance mappings, repository metadata, and changesets.
File summaries
| File | Reviewed change |
|---|---|
yarn.lock |
Registers workspace resolution. |
web/content/oidc/session.mdx |
Documents session management. |
web/content/oidc/provider.mdx |
Documents provider APIs. |
web/content/oidc/middleware.mdx |
Documents framework adapters. |
web/content/oidc/logout.mdx |
Documents RP-initiated logout. |
web/content/oidc/index.mdx |
Adds the OIDC overview. |
web/content/oidc/errors.mdx |
Documents OIDC errors. |
web/content/oidc/client.mdx |
Documents relying-party APIs. |
web/content/oidc/_meta.js |
Adds OIDC navigation. |
web/content/index.mdx |
Updates the package listing. |
web/content/compliance.mdx |
Adds OIDC compliance mapping. |
web/content/_meta.js |
Updates site navigation. |
SECURITY.md |
Adds OIDC support information. |
scripts/setup-labels.sh |
Adds the OIDC issue label. |
README.md |
Updates package catalog metadata. |
packages/oidc/tsconfig.json |
Configures declaration generation. |
packages/oidc/tests/smoke.test.js |
Tests public exports. |
packages/oidc/tests/session.test.js |
Tests session management. |
packages/oidc/tests/provider.test.js |
Tests provider functionality. |
packages/oidc/tests/logout.test.js |
Tests logout flows. |
packages/oidc/tests/helpers/oidc.js |
Provides OIDC test fixtures. |
packages/oidc/tests/client.test.js |
Tests client flows. |
packages/oidc/tests/adapters.test.js |
Tests framework adapters. |
packages/oidc/src/provider/routes.js |
Builds provider route tables. |
packages/oidc/src/provider/index.js |
Implements provider add-ons. |
packages/oidc/src/provider/fastify.js |
Adds the Fastify provider adapter. |
packages/oidc/src/provider/express.js |
Adds the Express provider adapter. |
packages/oidc/src/internal/userinfo.js |
Handles UserInfo claims. |
packages/oidc/src/internal/session.js |
Implements session-state helpers. |
packages/oidc/src/internal/logout.js |
Implements logout helpers. |
packages/oidc/src/internal/issuer-discovery.js |
Resolves issuer logout metadata. |
packages/oidc/src/internal/http-io.js |
Defines framework-neutral HTTP types. |
packages/oidc/src/internal/errors.js |
Defines OIDC errors. |
packages/oidc/src/internal/discovery-doc.js |
Builds discovery metadata. |
packages/oidc/src/index.js |
Exposes package exports. |
packages/oidc/src/client/index.js |
Implements the relying-party client. |
packages/oidc/src/client/fastify.js |
Adds the Fastify client adapter. |
packages/oidc/src/client/express.js |
Adds the Express client adapter. |
packages/oidc/rollup.config.js |
Configures the package build. |
packages/oidc/README.md |
Documents the package APIs. |
packages/oidc/package.json |
Defines package metadata and dependencies. |
packages/oidc/CHANGELOG.md |
Adds the initial changelog. |
docs/compliance.md |
Adds detailed OIDC compliance mapping. |
ARCHITECTURE.md |
Marks OIDC as package 19. |
.github/pull_request_template.md |
Adds OIDC checklist coverage. |
.github/ISSUE_TEMPLATE/feature_request.yml |
Adds OIDC feature selection. |
.github/ISSUE_TEMPLATE/bug_report.yml |
Adds OIDC bug selection. |
.changeset/oidc-initial-release.md |
Defines the initial release bump. |
Review details
Suppressed comments (11)
README.md:13
- This count and the linked npm badge are changed to say 19 packages are published, but the new package is still at
0.0.0with an unconsumed changeset and the PR description says its first npm publish happens after merge. The repository will advertise a package that does not exist yet; apply the shipping-status sweep after the version/publish step or keep the package planned until then.
**19 of 20 packages published** · [Documentation](https://auth.memet.dev) · [Guides](https://auth.memet.dev/guides) · [Comparison](https://auth.memet.dev/comparison)
packages/oidc/src/client/express.js:70
- This returns a possibly rejected
onSuccesspromise from an Express 4 handler instead of awaiting it. The rejection escapes thetry/catch, and Express 4 does not consume returned promises, leaving the request without error handling. Await the callback inside thetryblock.
return config.onSuccess({ req, res, ...result });
packages/oidc/src/client/index.js:80
- The client also accepts a non-loopback
http://issuer because it only checks for a non-empty string. That lets the discovery and token-verification flow target an unencrypted OP in production, contrary to the OIDC issuer contract and the repository's existing HTTPS issuer guard. Reject non-HTTPS issuers except loopback development hosts.
const { issuer, clientId, clientSecret, redirectUri } = config;
if (!isNonEmptyString(issuer)) {
invalidArgument('createClient(config): `issuer` must be a non-empty string.');
}
packages/oidc/src/internal/discovery-doc.js:39
- The default metadata omits
none, even though the adjacent OAuth2 server's default token endpoint supports public clients and this package documentscreateClientwithout aclientSecret. Discovery-driven clients can therefore reject a valid public-client registration based on inaccurate metadata. Align the default with the mounted server's supported methods or require an explicitauthMethodsconfiguration.
token_endpoint_auth_methods_supported: authMethods ?? ['client_secret_basic', 'client_secret_post'],
packages/oidc/src/internal/discovery-doc.js:33
grant_types_supportedis hard-coded to includerefresh_token, but this add-on does not know the grants configured on the adjacentcreateServer; callers can disable refresh-token grants. Discovery then promises a flow the server cannot serve. Accept the server's grant configuration or omit this optional metadata instead of advertising it unconditionally.
response_types_supported: ['code'],
response_modes_supported: ['query', 'fragment'],
grant_types_supported: ['authorization_code', 'refresh_token'],
subject_types_supported: ['public'],
packages/oidc/src/provider/index.js:139
- The JWKS response serializes the configured objects verbatim. Since the accepted
JWK[]shape can contain private members (d,p,q, etc.), an accidentally supplied private JWK would publish the signing secret at the publicjwks_uri. Sanitize keys to their public members or reject private JWKs before exposing this handler.
return () => jsonResponse(200, { keys: publicJwks }, { 'cache-control': 'public, max-age=3600' });
packages/oidc/src/provider/index.js:108
normalizeJwks(undefined)produces an empty set, but discovery always advertisesjwks_uriand this provider is required to sign ID tokens. A provider created withoutjwkstherefore publishes a valid-looking discovery document whose JWKS cannot verify any returned ID token. Reject missing/empty public keys or make the JWKS endpoint and metadata conditional.
const publicJwks = normalizeJwks(config.jwks);
packages/oidc/src/provider/index.js:60
- Only non-emptiness is checked for
signing.alg, soHS256is accepted even though this package documents asymmetric ID-token signing and publishes only a public JWKS. HMAC ID tokens cannot be represented by that JWKS and violate the documented verification contract. Reject symmetric/noneand unsupported algorithms during provider creation.
if (!isObject(signing) || signing.key === undefined || signing.key === null || !isNonEmptyString(signing.alg)) {
invalidArgument('createProvider(config): `signing` must be { key, alg }.');
packages/oidc/src/provider/index.js:193
readIdTokenHint()only decodes the JWT and checksiss; it does not verify the signature or bindaudto a client. Passinghint.subdirectly toonLogouttherefore lets a forged token with this issuer name an arbitrary subject and trigger destructive session teardown. Verify the hint against the OP key/client before using its subject, or do not use the decoded subject for session lookup.
const hint = readIdTokenHint(idTokenHint, issuer);
if (onLogout) {
try {
await onLogout({ sub: hint ? hint.sub : undefined, idTokenHint });
packages/oidc/src/provider/index.js:58
- The provider documentation says
issueris an HTTPS issuer, but this guard accepts any non-empty string, including a non-loopbackhttp://issuer. That permits discovery, advertised endpoints, and ID-token issuer claims to be configured over cleartext;@exortek/oauth2/serveralready rejects this class of issuer. Apply the same HTTPS/loopback validation here.
const { issuer, signing, endpoints } = config;
if (!isNonEmptyString(issuer)) {
invalidArgument('createProvider(config): `issuer` must be a non-empty string.');
}
packages/oidc/src/provider/routes.js:21
- Core UserInfo supports both GET and POST, with the access token allowed in an
application/x-www-form-urlencodedPOST body. The adapter route table registers only GET, andnormalizeRequestdrops request bodies, so a conforming POST UserInfo request cannot work through either adapter. Add the POST route and propagate/parse the form body before resolving the bearer token.
if (isNonEmptyString(meta.userinfo_endpoint)) {
routes.userinfo = { method: 'GET', path: pathOf(meta.userinfo_endpoint), handler: provider.userinfoHandler() };
- Files reviewed: 47/48 changed files
- Comments generated: 12
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| try { | ||
| const options = isFunction(config.authorizeOptions) ? config.authorizeOptions(req) : {}; | ||
| const { url, session } = await client.authorize(options); | ||
| res.setHeader('Set-Cookie', serialiseCookie(cookieName, session, cookieOptions)); |
| async handler(request, reply) { | ||
| const authorizeOptions = isFunction(options.authorizeOptions) ? options.authorizeOptions(request) : {}; | ||
| const { url, session } = await client.authorize(authorizeOptions); | ||
| reply.header('set-cookie', serialiseCookie(cookieName, session, cookieOptions)); |
| } | ||
| let payload; | ||
| try { | ||
| payload = decode(idTokenHint).payload; |
| * @property {{ key: unknown, alg: string, kid?: string, expiresIn?: string|number }} signing id_token signer. | ||
| * @property {object[] | { keys: object[] }} [jwks] public JWK Set to publish at `jwks_uri`. | ||
| * @property {Record<string, string>} endpoints endpoint URLs/paths to advertise (authorization + token required). | ||
| * @property {{ supported?: string[], id_token?: string[], userinfo?: string[] }} [claims] claim policy. |
| const meta = provider.metadata(); | ||
| /** @type {Record<string, { method: string, path: string, handler: Function }>} */ | ||
| const routes = { | ||
| discovery: { method: 'GET', path: '/.well-known/openid-configuration', handler: provider.discoveryHandler() }, |
|
|
||
| async function computeSessionState(clientId, origin, salt) { | ||
| var material = clientId + ' ' + origin + ' ' + opBrowserState() + ' ' + salt; | ||
| var digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(material)); |
| if (isObject(config.userinfo) || isNonEmptyString(endpoints.userinfo)) { | ||
| resolved.userinfo = toAbsolute(endpoints.userinfo ?? '/userinfo', issuer); | ||
| } |
|
|
||
| mountOidcLogin(app, { | ||
| client, | ||
| onSuccess: ({ res, claims }) => { |
| "dependencies": { | ||
| "@exortek/jwks": "workspace:^", | ||
| "@exortek/jwt": "workspace:^", | ||
| "@exortek/oauth2": "workspace:^" |
| client, | ||
| loginPath: '/login', | ||
| callbackPath: '/callback', | ||
| onSuccess: ({ res, claims }) => { |
Package #19 of the 20-package stack — OpenID Connect Core 1.0 on top of
@exortek/oauth2. New package, initial release at1.0.0(changeset included).Design
oauth2 already carries most of the OIDC machinery, so this composes its public API rather than reimplementing it:
@exortek/oidc/client) — a discovery-firstcreateClientbuilt ondefineProvider({ discover: true })+createOAuth. Theopenidscope is enforced, and theid_tokenis verified end to end (iss/aud/nonce/exp,azp,at_hash) by oauth2's own flow — not re-done here. Surface:authorize()(with OIDC auth-request params),handleCallback() → { idToken, claims, userinfo },endSessionUrl()(RP-Initiated Logout),refresh,revoke.@exortek/oidc/provider) — add-ons to mount beside an@exortek/oauth2/servercreateServer(which already issues theid_token):discoveryHandler(a full/.well-known/openid-configuration),userinfoHandler(Core §5.3 with scope→claims release and aclaims.userinfopolicy),jwksHandler,endSessionHandler(validatespost_logout_redirect_uriagainst registered URIs),checkSessionHandler+sessionState()(Session Management), and anidTokenSignerreusing oauth2'screateIdTokenSignerso both sign with one key../client/express,./client/fastify,./provider/express,./provider/fastify;express/fastifyare optional peers.No changes to
@exortek/oauth2were required. Runtime deps:@exortek/oauth2,@exortek/jwt,@exortek/jwks.Specifications
OpenID Connect Core 1.0 · Discovery 1.0 · RP-Initiated Logout 1.0 · Session Management 1.0.
Testing
35 tests, all green — config guards, an end-to-end login round-trip against a hermetic stub OP (local HTTP server + a real ES256-signed id_token), discovery/UserInfo/JWKS/logout/session handlers, and express + fastify adapters (incl. a fastify login round-trip).
typecheck,lint,format, and the multi-entrybuild(self-contained.d.ts) all pass.Docs / repo
Package README,
web/content/oidc/(overview + client/provider/logout/session/middleware/errors pages), compliance mapping, ARCHITECTURE status, and repo metadata (labels, issue/PR templates, SECURITY) are all updated.Follow-up (not in this PR): the first npm publish is manual (local, interactive 2FA) per the release workflow;
@exortek/auth(#20 umbrella) is the last remaining package.