@jwilleke/oidc-auth-server
v0.4.0
Published
OpenID Connect authorization server for Node apps: a thin, hardened wrapper around node-oidc-provider with device authorization (RFC 8628), UserInfo, refresh rotation and PKCE, where the host app keeps sign-in.
Maintainers
Readme
oidc-auth-server
@jwilleke/oidc-auth-server — an OpenID Connect authorization server for Node apps, packaged so any app can embed it.
It is a thin, hardened wrapper around node-oidc-provider (MIT, OpenID Certified). The host app keeps sign-in: it says who the person is, how they signed in (amr, acr), and whether they approved the request. This package issues and checks the tokens.
Status: early, pre-1.0. Published on npm as @jwilleke/oidc-auth-server; release notes under Releases.
npm install @jwilleke/oidc-auth-serverRequires Node.js 20 or later (engines in package.json). Security issues: see SECURITY.md.
What it provides
- Device authorization grant (RFC 8628) for phones, health apps and bridges that have no browser of their own
- Authorization code flow with PKCE (S256 only)
- OIDC UserInfo, discovery and JWKS
- Refresh-token rotation where the client supports it
- Dynamic client registration and client ID metadata documents
- Optional SMART on FHIR v2 scopes
- An audit hook for every grant, refresh, revocation and refusal
- A storage adapter interface, with an in-memory adapter for development and the samples
Where the host plugs in
- Sign-in — an interface the host implements: given a request, return the person, their
amrandacr, and whether they approved. - Storage — an adapter for grants, sessions and clients.
- Audit — a callback for each event, so the host writes it to its own audit log.
Usage
What works today: authorization code flow with PKCE, the host sign-in seam, hashed token storage, UserInfo, refresh token rotation with reuse detection, audience-bound tokens for registered APIs, client ID metadata documents (off by default), the device authorization grant (off by default) and the audit hook. The hardening checklist tests are tracked under #13.
import { createServer } from 'node:http';
import { createAuthServer } from '@jwilleke/oidc-auth-server';
const auth = createAuthServer({
issuer: 'https://auth.example.com',
jwks: { keys: [privateSigningJwk] },
cookieKeys: [process.env.OIDC_COOKIE_KEY!],
adapter: hostAdapterFactory, // node-oidc-provider Adapter; token ids arrive hashed
clients: [{ client_id: 'app', token_endpoint_auth_method: 'none', redirect_uris: ['…'] }],
acrValues: ['aal1', 'aal2'],
interactionUrl: (uid) => `/interaction/${uid}`,
findAccount: async (accountId, signIn) => users.claimsFor(accountId, signIn), // undefined fails closed
audit: (event) => auditManager.record(event) // events named in oidc-auth-server.audit.events
});
createServer(async (req, res) => {
if (!req.url?.startsWith('/interaction/')) return auth.handler(req, res);
const pending = await auth.interactions.details(req, res);
if (pending.prompt === 'login') {
// The host's own sign-in runs here, then reports what it established:
await auth.interactions.finishLogin(req, res, { accountId: 'alice', amr: ['pwd', 'otp'], acr: 'aal2' });
} else {
await auth.interactions.finishConsent(req, res);
}
}).listen(9000);createAuthServer throws before anything listens if the options are unsafe: a non-HTTPS issuer, missing private keys, short cookie keys, or no storage adapter outside development: true.
Configuration
Settings follow ngdpbase's convention, under the package's own oidc-auth-server.* keys. config/app-default-config.json ships every default and documents each key. The host overrides them in its app-custom-config.json; maps merge per entry, anything else is replaced. An unknown key refuses the boot rather than being ignored.
Secrets come only from the environment — OIDC_AUTH_SERVER_JWKS and OIDC_AUTH_SERVER_COOKIE_KEYS (see .env.example). Writing either into a config file refuses the boot.
import { createAuthServer, loadConfig, optionsFromConfig } from '@jwilleke/oidc-auth-server';
const config = loadConfig({ customConfigPath: 'data/config/app-custom-config.json' });
const auth = createAuthServer(optionsFromConfig(config, { interactionUrl, findAccount, adapter }));Options passed directly to createAuthServer take the shipped defaults for anything left unset.
Refusing a stale sign-in
findAccount(accountId, signIn) receives how and when the person signed in: { acr, amr, authTime } (epoch seconds), taken from the code, refresh token or access token the request carries. Return undefined to refuse, and the request fails closed: a refresh answers invalid_grant, UserInfo 401. A host ends app access after a password change this way, by refusing any authTime older than the change. UserInfo still reports only acr and amr.
Mounting under a path
An issuer with a path, such as https://example.com/oidc, serves every endpoint below that path. Mount the handler behind a prefix-stripping router (app.use('/oidc', auth.handler) in Express) or hand it the full path from a plain http server; both work. Cookies are scoped to the issuer's path, so they are never sent with the host's own requests. Point interactionUrl at a host route inside the same path (/oidc/interaction/:uid) and keep that route out of the handler, as the example above does at the root.
PKCE exemption for confidential clients
PKCE (S256) is required from every client. A confidential client — one that authenticates at the token endpoint with a secret or key — may opt out with require_pkce: false in its client metadata, for server-side apps and test tools that do not send a code challenge (the OpenID Connect Playground is one: code flow, client_secret in the token request, no PKCE). A public client (token_endpoint_auth_method: "none") with the exemption refuses the boot. A challenge an exempt client does send is still verified.
{ "client_id": "playground", "client_secret": "…", "token_endpoint_auth_method": "client_secret_post",
"require_pkce": false, "redirect_uris": ["https://openidconnect.net/callback"] }Device flow
Set oidc-auth-server.device-flow.enabled to true. The device calls /device/auth; the person opens /device, enters the code, and is sent to the host's interaction route like any sign-in. auth.interactions.details() returns deviceFlow: true there, so the host can require step-up before approving a device. Pass deviceFlow.pages to createAuthServer to render the code pages in the host's own markup.
Samples
Under examples/:
node-http— a host on Node's built-inhttp, with a stand-in sign-in and consent page, two demo accounts and the device flow on. Built: examples/node-httpexpress— an Express hostreact— a browser client (authorization code + PKCE)angular— a browser clientdevice-cli— a device-flow client
Development
nvm use
npm install
npm run lint
npm run typecheck
npm test
npm run buildRun the example host and drive it end to end over real HTTP:
npm run dev # http://localhost:9000, throwaway development keys
npm run smoke # 10 checks against it: code flow, PKCE, UserInfo,
# refresh rotation and reuse, code reuse, resources,
# denied consent, device flow and slow_down
npm run smoke -- https://oidc.example.com # the same checks against a deployed example hostThe example keeps everything in memory, so it runs only with an http issuer (development mode). Under an https issuer createAuthServer refuses to start until a storage adapter is supplied, as it should; a public deployment needs one, plus OIDC_AUTH_SERVER_JWKS and OIDC_AUTH_SERVER_COOKIE_KEYS.
Deploying a host on bare metal, in Docker or on Kubernetes: docs/deploying.md.
Agent and contributor rules are in AGENTS.md.
Background
The design decisions behind this package were made for ngdpbase and are recorded in its docs/planning/authentication.md.
Acknowledgements
This package is built on, and shaped by, other people's work. No code was copied from any of the projects below; where their work is used, it is used as a dependency or as a published idea, credited here.
- node-oidc-provider by Filip Skokan (@panva), MIT. The OpenID Certified protocol implementation this package wraps: authorization code flow, PKCE, refresh rotation and reuse detection, RFC 8628, UserInfo, resource indicators, client ID metadata documents, and the special-use-address SSRF guard on outgoing requests. A runtime dependency; every protocol behaviour this package hardens or tests is ultimately its work.
- activescott/auth by Scott Willeke (@activescott), MIT. Its OAuth server proposal, activescott/auth#83, carries the hardening list this package adopted as its review checklist and proves item by item in src/__tests__/hardening.test.ts: code reuse revokes, S256-only PKCE, audience on every token, refresh reuse detection, single-use consent, hashed tokens at rest, SSRF-guarded client ID metadata documents. The broader review of its design is in ngdpbase's
docs/planning/authentication.md. - ngdpbase — its guiding framework and configuration conventions (a reserved key namespace, environment-only secrets, an audit event registry named
{target}-{action}, fail closed) are followed here, and its authentication plan is the source of this package's design decisions. - Specifications: RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), RFC 8628 (device authorization grant), RFC 8707 (resource indicators), RFC 8176 (
amrvalues), OpenID Connect Core 1.0, the OAuth Client ID Metadata Document draft, and NIST SP 800-63B for the assurance levels a host reports asacr. - OpenID Connect Playground (Auth0) — testing against it surfaced the per-client PKCE exemption for confidential clients.
