Skip to content

feat(oidc): @exortek/oidc — OpenID Connect Core 1.0 - #56

Merged
ExorTek merged 15 commits into
masterfrom
feat/oidc-initial-release
Sep 10, 2026
Merged

ExorTek merged 15 commits into
masterfrom
feat/oidc-initial-release

Conversation

@ExorTek

@ExorTek ExorTek commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Package #19 of the 20-package stack — OpenID Connect Core 1.0 on top of @exortek/oauth2. New package, initial release at 1.0.0 (changeset included).

Design

oauth2 already carries most of the OIDC machinery, so this composes its public API rather than reimplementing it:

  • Relying party (@exortek/oidc/client) — a discovery-first createClient built on defineProvider({ discover: true }) + createOAuth. The openid scope is enforced, and the id_token is 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.
  • OpenID Provider (@exortek/oidc/provider) — add-ons to mount beside an @exortek/oauth2/server createServer (which already issues the id_token): discoveryHandler (a full /.well-known/openid-configuration), userinfoHandler (Core §5.3 with scope→claims release and a claims.userinfo policy), jwksHandler, endSessionHandler (validates post_logout_redirect_uri against registered URIs), checkSessionHandler + sessionState() (Session Management), and an idTokenSigner reusing oauth2's createIdTokenSigner so both sign with one key.
  • Framework adapters — ./client/express, ./client/fastify, ./provider/express, ./provider/fastify; express/fastify are optional peers.

No changes to @exortek/oauth2 were 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-entry build (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.

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.
Root README (19/20 counter, Shipping row #19 linked + npm badge, express/
fastify peer-dep row), ARCHITECTURE.md #19 flipped to shipped, SECURITY.md
supported-versions row, setup-labels.sh pkg:oidc label, issue/PR templates
package selectors.
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.
Copilot AI lite review requested due to automatic review settings September 10, 2026 21:37
@ExorTek
ExorTek merged commit 9ee723a into master Sep 10, 2026
4 checks passed
@ExorTek
ExorTek deleted the feat/oidc-initial-release branch September 10, 2026 21:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.0 with 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 onSuccess promise from an Express 4 handler instead of awaiting it. The rejection escapes the try/catch, and Express 4 does not consume returned promises, leaving the request without error handling. Await the callback inside the try block.
          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 documents createClient without a clientSecret. 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 explicit authMethods configuration.
    token_endpoint_auth_methods_supported: authMethods ?? ['client_secret_basic', 'client_secret_post'],

packages/oidc/src/internal/discovery-doc.js:33

  • grant_types_supported is hard-coded to include refresh_token, but this add-on does not know the grants configured on the adjacent createServer; 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 public jwks_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 advertises jwks_uri and this provider is required to sign ID tokens. A provider created without jwks therefore 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, so HS256 is 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/none and 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 checks iss; it does not verify the signature or bind aud to a client. Passing hint.sub directly to onLogout therefore 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 issuer is an HTTPS issuer, but this guard accepts any non-empty string, including a non-loopback http:// issuer. That permits discovery, advertised endpoints, and ID-token issuer claims to be configured over cleartext; @exortek/oauth2/server already 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-urlencoded POST body. The adapter route table registers only GET, and normalizeRequest drops 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));
Comment on lines +81 to +83
if (isObject(config.userinfo) || isNonEmptyString(endpoints.userinfo)) {
resolved.userinfo = toAbsolute(endpoints.userinfo ?? '/userinfo', issuer);
}
Comment thread packages/oidc/README.md

mountOidcLogin(app, {
client,
onSuccess: ({ res, claims }) => {
Comment on lines +89 to +92
"dependencies": {
"@exortek/jwks": "workspace:^",
"@exortek/jwt": "workspace:^",
"@exortek/oauth2": "workspace:^"
client,
loginPath: '/login',
callbackPath: '/callback',
onSuccess: ({ res, claims }) => {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants