A minimal, config-driven OIDC provider for local development.
When you build an app that integrates with an OIDC provider (Azure AD / Entra, Auth0, Keycloak, Okta), local iteration is painful: you either bypass auth in dev (drift between dev and prod) or stand up a full IdP (slow). dev-oidc sits where the real IdP would — your app runs its real auth code path (redirect, token exchange, JWT verify, refresh) against a mock that you configure via a JSON file.
- Full auth-code + PKCE flow with redirect + login page + token exchange.
- Refresh tokens with single-use rotation — each
/tokenresponse carries a freshrefresh_token. - Profile tiles on the login page — pick a user with one click, no password.
- Hot reload of the config file — edit the JSON on disk or from another tool, no restart.
- Admin UI at
/adminfor profile CRUD. - Persistent signing keys (optional) so JWTs survive server restarts.
- RS256 and ES256 signing algorithms, configurable per deployment.
- Optional
clientSecret— public clients require no secret; confidential clients can useclient_secret_postorclient_secret_basic. - End-to-end scope propagation —
scopeis reflected in the token response and as a claim in the access token. - Root landing page at
GET /listing discovery, JWKS, and (when admin is enabled) the admin link. - Permissive CORS for browser apps running on
localhost:*. - OIDC-conformant enough for
oidc-client-ts, MSAL, and standard JWT libraries to work against it.
This is a five-minute walkthrough to point an existing app's OIDC integration at dev-oidc instead of a real IdP during development.
Pick one of the run modes below (Hub, Docker, CLI, programmatic). All modes read the same project JSON config.
In Hub mode (the default — dev-oidc start), each registered project is served under a URL slug derived from its directory name. This walkthrough assumes a slug of my-app. In legacy mode (dev-oidc start --config <path>) and Docker, drop the /<slug> segment from every URL below.
Wherever your app reads its OIDC settings (usually env vars), swap the provider URLs for dev-oidc's:
| Your app's config key | Production value | Dev value (Hub mode) | Dev value (legacy / Docker) |
|---|---|---|---|
OIDC_ISSUER / authority |
https://login.microsoftonline.com/<tenant>/v2.0 |
http://localhost:8095/my-app |
http://localhost:8095 |
OIDC_CLIENT_ID |
your app registration ID | matches clients[].clientId in dev-oidc config |
matches clients[].clientId in dev-oidc config |
OIDC_AUDIENCE |
your API's audience | matches clients[].audience in dev-oidc config |
matches clients[].audience in dev-oidc config |
| Redirect URI | your prod callback URL | matches clients[].redirectUris[] in dev-oidc config |
matches clients[].redirectUris[] in dev-oidc config |
URLs below show Hub mode; in legacy/Docker mode drop the /my-app segment.
- Your app redirects to
http://localhost:8095/my-app/authorize?client_id=...&redirect_uri=...&response_type=code&scope=openid&code_challenge=...&code_challenge_method=S256. - dev-oidc renders a tile per
profilein the config. - User clicks a tile → dev-oidc redirects to your app's
redirect_uriwith acode. - Your app exchanges the code for tokens at
http://localhost:8095/my-app/token. - Your app verifies the JWT using the JWKS at
http://localhost:8095/my-app/.well-known/jwks.json.
The same code path runs in production — only the URLs change.
Run a single dev-oidc process that serves multiple project tenants concurrently. Each project keeps its own dev-oidc.config.json in its repo; a registry at ~/.config/dev-oidc/hub.json tracks which projects are mounted.
Setup:
npm install -g dev-oidc # or run via npx
dev-oidc register /path/to/your/project # accepts a project dir or a path to dev-oidc.config.json
# (optional) dev-oidc register /path/to/your/project --slug my-app
dev-oidc start # listens on 127.0.0.1:8095When given a directory, register looks for dev-oidc.config.json inside it. The slug defaults to a hyphenated form of the directory name; pass --slug to override.
Each tenant gets its own URL namespace:
- Discovery:
http://localhost:8095/<slug>/.well-known/openid-configuration - Authorize:
http://localhost:8095/<slug>/authorize - Token:
http://localhost:8095/<slug>/token - Admin:
http://localhost:8095/admin/<slug>
The Hub's dashboard at http://localhost:8095/admin lists all registered tenants.
Commands:
| Command | What it does |
|---|---|
dev-oidc start |
Start the Hub. |
dev-oidc register <path> |
Mount a project's dev-oidc.config.json. |
dev-oidc unregister <slug> |
Remove a tenant from the registry. |
dev-oidc list |
List registered tenants. |
The Hub watches hub.json — register/unregister take effect within ~200 ms with no restart.
docker run --rm -p 8095:8095 \
-v "$(pwd)/dev-oidc.config.json:/config/config.json:ro" \
-v dev-oidc-data:/data \
camcima2/dev-oidc:latest/config/config.json— your config file (see Config reference)./data— optional persistent volume for the signing key (see Signing-key persistence).
The image listens on port 8095 inside the container; map it to whatever you want on the host.
# docker-compose.yml
services:
dev-oidc:
image: camcima2/dev-oidc:latest
volumes:
- ./dev-oidc.config.json:/config/config.json:ro
- dev-oidc-data:/data
ports:
- '8095:8095'
healthcheck:
test:
- CMD-SHELL
- 'wget -q -O- http://127.0.0.1:8095/.well-known/openid-configuration > /dev/null || exit 1'
interval: 5s
timeout: 2s
retries: 10
your-api:
build: .
depends_on:
dev-oidc:
condition: service_healthy
environment:
OIDC_ISSUER: http://dev-oidc:8095
OIDC_CLIENT_ID: my-app
OIDC_AUDIENCE: my-api
volumes:
dev-oidc-data:Important notes:
- Use
http://dev-oidc:8095(the compose service name) for server-to-server calls between containers on the shared Docker network — for example, your API validating JWTs by fetching JWKS. - Use
http://localhost:8095for browser-side redirects and token calls — the user's browser doesn't resolve Docker service names. - The image's default
CMDalready passes--host 0.0.0.0so the published port is reachable from the host. The project config no longer acceptshost/portfields — both are CLI/Hub concerns now. - If your relying parties resolve dev-oidc through a name other than
localhost(e.g.dev-oidcon the compose network), pass--public-url http://dev-oidc:8095so the issuer in discovery and JWTs matches the URL the RPs will use to fetch JWKS.
Use this mode when you want a single isolated OIDC server for one project, without the Hub registry.
npm install --save-dev dev-oidc
npx dev-oidc start --config ./dev-oidc.config.jsonOptional flags:
| Flag | Default | Purpose |
|---|---|---|
--port <number> |
8095 |
TCP port to listen on. |
--host <address> |
127.0.0.1 |
Address to bind. |
--public-url <url> |
derived | Issuer base URL reported in discovery and tokens. |
Or programmatically, e.g. in a Vitest globalSetup:
import { createDevOidcServer, loadConfig } from 'dev-oidc';
const config = await loadConfig('./dev-oidc.config.json');
const server = await createDevOidcServer({
config,
// Optional. Defaults to `publicUrl` or `http://${listenHost}:${listenPort}`.
// Pass explicitly when relying parties resolve dev-oidc through a
// different name than the listen address.
// issuer: 'http://localhost:8095',
listenHost: '127.0.0.1',
listenPort: 8095,
});
await server.app.listen({ port: 8095, host: '127.0.0.1' });Every field in dev-oidc.config.json:
Hub mode vs Legacy mode: In Hub mode, the listener address, port, and issuer base URL are owned by the Hub process (configured in
~/.config/dev-oidc/hub.json). In Legacy single-tenant mode, pass--port,--host, and--public-urltodev-oidc start --config <path>. The project config file no longer acceptsissuer,port, orhostfields — configs that include them fail validation with a tailored error.
The canonical schema lives in src/config/schema.ts — it validates every config file on load and on hot reload, so typos fail fast with a zod error pointing at the bad field.
By default (signingKey.source: "generate") a fresh RSA keypair is created at every boot. That's fine for one-shot tests but disruptive during interactive development: every container restart rotates the key, which invalidates any JWTs your app had in browser storage and breaks your API's cached JWKS until it refetches.
To persist the key across restarts, set source to "file:<path>":
{
"signingKey": { "kid": "dev-key-1", "source": "file:/data/signing-key.json" },
}On first boot, dev-oidc generates a keypair and writes it to the path as JSON (with 0600 permissions). On subsequent boots it loads the same key. Mount a Docker volume at /data (or your chosen path) to persist it across container rebuilds:
services:
dev-oidc:
volumes:
- dev-oidc-data:/data
# ...
volumes:
dev-oidc-data:Rotate the key by either changing the kid (dev-oidc will refuse to load a file with a mismatched kid, forcing you to delete and regenerate) or just deleting the file.
subjectClaim controls which JWT claim carries the user's ID. Three choices in practice:
"sub"(default) — standard OIDC. Most libraries (oidc-client-ts, Auth0, Keycloak) read this."oid"— Entra / Azure AD convention. If your backend expectsoid, set this.- Anything else — for custom integrations. The profile's
idstill lands insubtoo;subjectClaimjust adds an alias.
Everything in profile.claims is merged into the issued JWT verbatim, with these reserved claim names protected from override: sub, name, email, iat, exp, iss, aud, nonce.
When a client entry includes clientSecret, dev-oidc requires the secret at the /token endpoint. Public clients (no clientSecret) continue to work without any secret, as before.
Two auth methods are accepted:
client_secret_post— includeclient_secretas a form field in thePOST /tokenbody.client_secret_basic— HTTP Basic auth:Authorization: Basic <base64(clientId:clientSecret)>.
When the secret is missing or wrong, dev-oidc returns 401 with WWW-Authenticate: Basic realm="dev-oidc".
Example config entry:
{
"clients": [
{
"clientId": "confidential-app",
"clientSecret": "s3cr3t-value",
"redirectUris": ["http://localhost:5173/auth/callback"],
"audience": "my-api"
}
]
}The signingKey.alg field accepts "RS256" (default) or "ES256":
{
"signingKey": { "kid": "k1", "alg": "ES256", "source": "generate" }
}File-backed key files written with RS256 load unchanged when alg is "RS256". ES256 key files written by this version are not loadable by alpha.2 — only forward-compatible within the same algorithm.
The scope parameter is propagated end-to-end:
/authorizerejects requests whosescopedoes not includeopenidwith400 invalid_scope.- The
/tokenresponsescopefield reflects the scope the client actually requested, not a hardcoded string. - Access tokens carry a
scopeclaim with the same value.
dev-oidc rotates refresh tokens on every use. The consumed token becomes invalid as soon as /token returns the new one. Apps that previously cached a single refresh token must capture and store the new refresh_token from each /token response.
In Hub mode, every OIDC route is namespaced under the tenant slug; replace :slug with the slug you registered. In legacy/Docker mode drop the :slug/ segment.
| Path (Hub) | Path (Legacy/Docker) | Purpose |
|---|---|---|
GET / |
GET / |
Hub landing page (lists tenants). |
GET /:slug/.well-known/openid-configuration |
GET /.well-known/openid-configuration |
Discovery doc. |
GET /:slug/.well-known/jwks.json |
GET /.well-known/jwks.json |
Public keys. |
GET /:slug/authorize |
GET /authorize |
Renders the login page (tiles). |
POST /:slug/authorize/complete |
POST /authorize/complete |
Issues an auth code. |
POST /:slug/token |
POST /token |
Code exchange + refresh. |
GET / POST /:slug/logout |
GET / POST /logout |
Ends the session. |
GET /admin |
GET /admin |
Hub dashboard (Hub) / single admin page (Legacy/Docker). |
GET /admin/:slug |
— | Per-tenant admin UI (profile CRUD). |
All OIDC flows require PKCE with S256. No implicit flow. Client secrets are optional — see Confidential clients below.
CORS is permissive by default (Access-Control-Allow-Origin reflects the request's Origin) — browser-based OIDC clients can fetch the discovery doc, JWKS, and token endpoint without additional config.
Visit http://localhost:8095/admin to:
- View all configured profiles.
- Add, edit, or delete profiles. Changes write atomically to the JSON config file on disk.
- View the full raw config.
The admin UI subscribes to a Server-Sent Events stream at /admin/events. When the JSON config file is edited externally (by another tool, another human, or a coding agent), a "Config changed on disk" banner appears so you can reload.
No authentication on /admin — the default 127.0.0.1 bind is the only protection. If you run dev-oidc somewhere network-reachable, put it behind a firewall, reverse-proxy auth, or a VPN. dev-oidc is a development tool, not a production service.
From the login page itself, a small "Manage profiles →" link jumps to /admin for quick iteration.
- Development only. Not suitable for production use under any circumstances.
- Single tenant per Docker container. The Docker image runs in legacy single-tenant mode. Use Hub mode (CLI) for multi-tenant local development.
- In-memory session state. Authorization codes (60 s TTL) and refresh tokens (8 h default) are held in memory. A server restart invalidates all active codes and refresh tokens. Persistent session storage is intentionally out of scope. Signing keys can be persisted across restarts via
signingKey.source: "file:<path>"(see Signing-key persistence). - Partial config hot-reload. Edits to
clients,profiles,branding,subjectClaim, andtokenTtlSecondsapply on the next request after the file watcher fires. Edits tosigningKey(kid/alg/source) andrefreshTokenTtlSecondsrequire a process restart — or, in Hub mode,dev-oidc unregister <slug> && dev-oidc register <path>— because they're baked into the per-tenant key material and refresh-token store at activation time. Live-rotating a signing key would invalidate every JWT minted before the rotation; that's not a hot-reload behavior we want. - Signing key rotates on every restart unless
source: "file:<path>"is set. - No authentication on
/admin. - Logout without redirect. When
/logoutis called without apost_logout_redirect_uri, the server returns a 200 HTML "Signed out" page with a link back to/. If a registeredpost_logout_redirect_uriis provided, the normal 302 redirect applies.
@navikt/mock-oauth2-server— JVM-based, highest protocol fidelity, generic login form UI. Use if you want JVM + stricter spec conformance.oauth2-mock-server— Node, lighter footprint, programmatic hooks. Use if you don't need a login UI and want to drive auth programmatically in tests.node-oidc-provider— production-grade OIDC provider. Use if you want to build a real IdP, not a dev tool.- dev-oidc (this) — Node, minimal, config-driven, login UI that lets you pick a profile with one click, admin UI, persistent keys. Use if that's what you want.
Browser shows CORS error when calling /.well-known/openid-configuration:
dev-oidc enables permissive CORS by default. If you still see errors, the browser is probably loading a cached version of the page before dev-oidc added CORS headers. Hard-refresh (Cmd/Ctrl+Shift+R).
API returns 401 "JWT verification failed" right after dev-oidc restarts: Your API's JWKS cache still has the old public key, but dev-oidc rotated it. Either restart your API, or enable signing-key persistence.
Inside Docker: wget: can't connect to remote host: Connection refused on healthcheck:
Node binds IPv4-only when host: "0.0.0.0" but localhost can resolve to IPv6 inside Alpine. Use 127.0.0.1 in your healthcheck URL.
/authorize returns 400 "redirect_uri does not match a registered value":
The redirect_uri query param must exactly match one of clients[].redirectUris[] — no trailing slash tolerance, no path normalization.
Config edits in the mounted file don't reload:
Bind-mount file watching is unreliable on Docker Desktop for macOS/WSL2. docker compose restart dev-oidc after editing the config file is the simplest workaround; the admin UI (which writes via a Docker-internal path) doesn't hit this issue.
This repo ships a Claude Code skill that walks an agent through wiring dev-oidc into your app — detect the existing OIDC integration, generate a matching dev-oidc.config.json, pick a run mode, update env vars or compose files. Source: skills/dev-oidc-setup/.
Install with the skills CLI from Vercel Labs:
# Claude Code
npx skills add camcima/dev-oidc --skill dev-oidc-setup -a claude-code
# Other supported agents (Cursor, Codex, Cline, etc. — see `npx skills agents`)
npx skills add camcima/dev-oidc --skill dev-oidc-setup -a <agent>Once installed, ask the agent to "set up dev-oidc in this project" (or any equivalent phrasing) and it picks up from there. The skill handles both first-time wire-ups (Hub mode, Docker compose, or legacy CLI) and edits to an existing dev-oidc.config.json.
To uninstall: npx skills remove dev-oidc-setup -a claude-code.
Contributions welcome. See CONTRIBUTING.md for setup, style, and commit conventions. Security issues: please read SECURITY.md first.
dev-oidc ships to two registries: npm (the dev-oidc package) and GHCR (ghcr.io/camcima/dev-oidc). Each registry has its own publish path, so you can release to either or both.
npm run releaserelease-it runs typecheck, lint, formatting checks, and tests; bumps package.json and package-lock.json; builds dist; verifies the npm package with npm pack --dry-run; then commits, tags v${version}, pushes, and publishes to npm.
For the current alpha line use npm run release:alpha. Use npm run release:dry to preview without writing changes.
npm run release:dockerThis triggers the release-docker.yml workflow on GitHub Actions for the version currently in package.json. The workflow checks out the v${version} tag (which must already exist on origin), builds a multi-arch image (linux/amd64, linux/arm64), and pushes to ghcr.io/camcima/dev-oidc:${version} and :latest.
To backfill or re-publish a specific tag, dispatch the workflow directly:
gh workflow run release-docker.yml -f tag=v0.1.0 # also tags :latest
gh workflow run release-docker.yml -f tag=v0.1.0 -f latest=falsenpm run release:allRuns npm run release first (npm publish + tag push), then npm run release:docker (GitHub Actions multi-arch build + GHCR push) using the freshly-bumped version.
See CHANGELOG.md.
MIT.
{ "signingKey": { "kid": "dev-key-1", // Required. Key ID surfaced in JWKS + JWT header. "alg": "RS256", // Default "RS256". Also supports "ES256" — see Signing algorithm below. "source": "generate", // Default "generate" (ephemeral) or "file:<path>" (persistent). }, "clients": [ // Required. One or more registered clients. { "clientId": "my-app", // What your app sends as `client_id`. "clientSecret": "s3cr3t", // Optional. Omit for public clients (no secret required). "redirectUris": [ // Exact-match allowlist. "http://localhost:5173/auth/callback", ], "postLogoutRedirectUris": [ // Optional. Default []. "http://localhost:5173/", ], "audience": "my-api", // Required. Populates the JWT `aud` claim. }, ], "subjectClaim": "sub", // Default "sub". Use "oid" for Azure AD / Entra compat. "tokenTtlSeconds": 900, // Default 900. Access-token lifetime. "refreshTokenTtlSeconds": 28800, // Default 28800. Refresh-token lifetime. "branding": { "title": "Dev OIDC Login", // Default "Dev OIDC Login". "accentColor": "#1f6feb", // Default #1f6feb. "logoUrl": null, // Default null. }, "profiles": [ // The users offered on the login page. { "id": "alice", // Goes into the `sub` (or `oid`) claim. "displayName": "Alice Developer", "email": "alice@example.com", "avatar": null, // Optional URL, default null. "claims": { // Optional. Merged into every JWT for this profile. "department": "Engineering", "platformRole": "admin", }, }, ], }