oidc-mock 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +382 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +74 -0
- package/dist/config.d.ts +59 -0
- package/dist/config.js +157 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/keys.d.ts +13 -0
- package/dist/keys.js +36 -0
- package/dist/pages.d.ts +10 -0
- package/dist/pages.js +134 -0
- package/dist/provider.d.ts +14 -0
- package/dist/provider.js +412 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.js +77 -0
- package/dist/tokens.d.ts +36 -0
- package/dist/tokens.js +44 -0
- package/dist/vite.d.ts +25 -0
- package/dist/vite.js +114 -0
- package/examples/oidc-mock.yaml +58 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tade Strehk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# oidc-mock
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/oidc-mock)
|
|
4
|
+
[](https://github.com/strehk/oidc-mock/actions/workflows/test.yml)
|
|
5
|
+
|
|
6
|
+
**A fake OpenID Connect provider for local development – users in a YAML file, one click to sign
|
|
7
|
+
in, and a Vite plugin that keeps login working on your phone.**
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<picture>
|
|
11
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/strehk/oidc-mock/main/docs/login-dark.png">
|
|
12
|
+
<img src="https://raw.githubusercontent.com/strehk/oidc-mock/main/docs/login-light.png" alt="The oidc-mock login page: one button per configured user, and a box for custom claims" width="420">
|
|
13
|
+
</picture>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
Developing against a real identity provider means test accounts, passwords and a network
|
|
17
|
+
connection. Most mocks fix that but bring their own friction: they run in Docker, you paste claims
|
|
18
|
+
into a text field on every login, and they break the moment you open your app on another device.
|
|
19
|
+
|
|
20
|
+
oidc-mock is a small Node package instead:
|
|
21
|
+
|
|
22
|
+
- **Users are a YAML file.** Every user has a stable `sub` and whatever claims your app expects –
|
|
23
|
+
roles, groups, tenant IDs, any JSON shape. Check the file in, and the whole team signs in as the
|
|
24
|
+
same “Admin” or “Member without email”.
|
|
25
|
+
- **One click to sign in.** The login page shows a button per user. Need a variation? The ✎ next
|
|
26
|
+
to a user opens their claims as JSON, ready to edit, without touching the file.
|
|
27
|
+
- **Edits apply live.** Change a claim in the YAML and the next login – or even the next token
|
|
28
|
+
refresh – carries it. No restart.
|
|
29
|
+
- **Runs inside Vite.** `vite --host`, a phone on the LAN, a forwarded port, a dev container: the
|
|
30
|
+
login page is served by your dev server, so it works wherever your app works.
|
|
31
|
+
- **Real OIDC.** Discovery, signed RS256 JWTs, JWKS, authorization code flow with PKCE, refresh
|
|
32
|
+
tokens, userinfo, introspection, logout. Your app uses its normal OIDC client and the same code
|
|
33
|
+
path as in production.
|
|
34
|
+
|
|
35
|
+
> [!WARNING]
|
|
36
|
+
> This is a development tool. Anyone who can reach it can sign in as anyone, with any claims.
|
|
37
|
+
> Never expose it to the internet or ship it to production.
|
|
38
|
+
|
|
39
|
+
## Contents
|
|
40
|
+
|
|
41
|
+
- [Quick start](#quick-start)
|
|
42
|
+
- [Configuration](#configuration)
|
|
43
|
+
- [Why a Vite plugin: `--host`, phones and HTTPS](#why-a-vite-plugin---host-phones-and-https)
|
|
44
|
+
- [Recipes](#recipes)
|
|
45
|
+
- [Endpoints](#endpoints)
|
|
46
|
+
- [Programmatic use and tests](#programmatic-use-and-tests)
|
|
47
|
+
- [Compared to other mocks](#compared-to-other-mocks)
|
|
48
|
+
- [Development](#development)
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npm i -D oidc-mock # or: bun add -d oidc-mock / pnpm add -D oidc-mock
|
|
54
|
+
npx oidc-mock init # writes an example oidc-mock.yaml
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### With Vite (SvelteKit, Nuxt, Astro, Remix, plain Vite, …)
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// vite.config.ts
|
|
61
|
+
import { defineConfig } from 'vite';
|
|
62
|
+
import { sveltekit } from '@sveltejs/kit/vite';
|
|
63
|
+
import { oidcMock } from 'oidc-mock/vite';
|
|
64
|
+
|
|
65
|
+
export default defineConfig({
|
|
66
|
+
plugins: [oidcMock(), sveltekit()]
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
On `vite dev` the plugin prints the discovery URL. Give it to your OIDC client as issuer or
|
|
71
|
+
authority, with any client ID:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
# .env
|
|
75
|
+
OIDC_AUTHORITY=http://127.0.0.1:8090/oidc/.well-known/openid-configuration
|
|
76
|
+
OIDC_CLIENT_ID=my-app
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The plugin only runs in `vite dev` and `vite preview`, never in `vite build`.
|
|
80
|
+
|
|
81
|
+
| Option | Default | Description |
|
|
82
|
+
| ------------------ | ------------------ | ---------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| `config` | `'oidc-mock.yaml'` | Path to the YAML file, relative to the Vite root |
|
|
84
|
+
| `port` | from the YAML | Overrides `port` |
|
|
85
|
+
| `rewriteRedirects` | `true` | Rewrite redirects to the mock into relative ones ([why](#why-a-vite-plugin---host-phones-and-https)) |
|
|
86
|
+
| `preview` | `true` | Also run in `vite preview` |
|
|
87
|
+
|
|
88
|
+
### Standalone
|
|
89
|
+
|
|
90
|
+
For anything that is not Vite – Next.js, Express, a Python backend, a mobile app:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
npx oidc-mock # reads ./oidc-mock.yaml
|
|
94
|
+
npx oidc-mock -c dev/oidc.yaml -p 9000 -H 0.0.0.0
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
oidc-mock listening – 3 users from /path/to/oidc-mock.yaml
|
|
99
|
+
issuer: http://127.0.0.1:8090/oidc
|
|
100
|
+
discovery: http://127.0.0.1:8090/oidc/.well-known/openid-configuration
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
In this mode the browser talks to the mock directly, so it has to reach `host:port`.
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
A complete file with every option and its default:
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
# Where the provider listens (the “back channel”, see below).
|
|
111
|
+
# The issuer is http://<host>:<port><base_path>.
|
|
112
|
+
host: 127.0.0.1
|
|
113
|
+
port: 8090
|
|
114
|
+
base_path: /oidc
|
|
115
|
+
|
|
116
|
+
# Set this only when the mock sits behind a proxy under another URL.
|
|
117
|
+
# issuer: https://auth.dev.example.org/oidc
|
|
118
|
+
|
|
119
|
+
# Signing key, generated on first start. Relative to this file.
|
|
120
|
+
key_file: node_modules/.cache/oidc-mock/signing-key.json
|
|
121
|
+
|
|
122
|
+
# Without `clients`, every client_id is accepted without a secret.
|
|
123
|
+
clients:
|
|
124
|
+
- client_id: my-app # public client, PKCE
|
|
125
|
+
redirect_uris: ['http://*:5173/*', 'https://*:5173/*']
|
|
126
|
+
- client_id: backend
|
|
127
|
+
client_secret: dev-secret # confidential client
|
|
128
|
+
# no redirect_uris: anything goes
|
|
129
|
+
|
|
130
|
+
tokens: # seconds, or 30s / 15m / 1h / 30d
|
|
131
|
+
access_token_ttl: 1h
|
|
132
|
+
id_token_ttl: 1h
|
|
133
|
+
refresh_token_ttl: 30d
|
|
134
|
+
|
|
135
|
+
# The “custom claims” box on the login page.
|
|
136
|
+
custom_login: true
|
|
137
|
+
|
|
138
|
+
users:
|
|
139
|
+
- sub: admin # required, unique, becomes the `sub` claim
|
|
140
|
+
label: Admin # button text; default: name, then email, then sub
|
|
141
|
+
description: Every permission # second line on the button
|
|
142
|
+
claims: # anything; goes into id token, access token and userinfo
|
|
143
|
+
email: admin@example.org
|
|
144
|
+
email_verified: true
|
|
145
|
+
given_name: Ada
|
|
146
|
+
family_name: Admin
|
|
147
|
+
roles: [admin]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
What to know about claims:
|
|
151
|
+
|
|
152
|
+
- **Anything goes.** Claims are copied verbatim, so an array of strings, an array of objects or a
|
|
153
|
+
nested map all work. The [recipes](#recipes) show the shapes of common providers.
|
|
154
|
+
- **The same claims everywhere.** The id token, the access token and `/userinfo` all carry the
|
|
155
|
+
full set, so it does not matter where your app reads a claim from.
|
|
156
|
+
- **Protected claims.** `iss`, `sub`, `aud`, `exp`, `iat`, `nbf` and `jti` are set by the mock. If
|
|
157
|
+
a user's claims contain them, they are ignored.
|
|
158
|
+
|
|
159
|
+
What reloads without a restart:
|
|
160
|
+
|
|
161
|
+
- **Live:** `users`, `clients`, `tokens` and `custom_login`. The file is checked on every request.
|
|
162
|
+
A broken file logs an error and keeps the last good config.
|
|
163
|
+
- **Needs a restart:** `host`, `port`, `base_path`, `issuer` and `key_file`. A warning tells you
|
|
164
|
+
when one of them changed.
|
|
165
|
+
|
|
166
|
+
Refresh tokens remember which user they belong to. For a user from the file, a refresh re-reads
|
|
167
|
+
their claims, so you can take away a role and watch the app react without logging out.
|
|
168
|
+
|
|
169
|
+
## Why a Vite plugin: `--host`, phones and HTTPS
|
|
170
|
+
|
|
171
|
+
An OIDC client discovers its provider once, on the server, and from then on sends the browser to
|
|
172
|
+
the `authorization_endpoint` from that discovery. With a mock at `http://127.0.0.1:8090` that
|
|
173
|
+
breaks in several everyday situations:
|
|
174
|
+
|
|
175
|
+
- **`vite --host` and a phone on the LAN.** The phone opens `http://192.168.1.23:5173` and gets
|
|
176
|
+
redirected to `http://127.0.0.1:8090/…` – which is the phone itself.
|
|
177
|
+
- **Mock in Docker, dev server somewhere else.** Remote dev boxes, Codespaces, SSH port forwards:
|
|
178
|
+
you forwarded the app's port, but not the mock's.
|
|
179
|
+
- **HTTPS dev server.** A self-signed or mkcert certificate is fine for the browser, but a
|
|
180
|
+
server-side fetch from Node to `https://localhost:5173` fails certificate validation.
|
|
181
|
+
|
|
182
|
+
The Vite plugin splits the provider into two channels:
|
|
183
|
+
|
|
184
|
+
```mermaid
|
|
185
|
+
flowchart LR
|
|
186
|
+
B["Browser<br/>(laptop, phone, …)"] -- "app pages and<br/>/oidc/authorize (login page)" --> V["Vite dev server<br/>any host, http or https"]
|
|
187
|
+
A["App server code<br/>(OIDC client)"] -- "discovery, token, JWKS<br/>plain HTTP on loopback" --> M["oidc-mock back channel<br/>127.0.0.1:8090"]
|
|
188
|
+
V -. "same process" .- A
|
|
189
|
+
V -. "same process" .- M
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- **Back channel.** Discovery, token, JWKS, userinfo and introspection run on a loopback port over
|
|
193
|
+
plain HTTP. Only your app's server calls them, so HTTPS certificates and host names never
|
|
194
|
+
matter here.
|
|
195
|
+
- **Front channel.** The Vite server serves the same endpoints under `base_path`. The browser only
|
|
196
|
+
ever needs the login page and the logout endpoint, and those come from wherever it loaded your
|
|
197
|
+
app from.
|
|
198
|
+
- **Redirect rewriting.** When your app redirects to `http://127.0.0.1:8090/oidc/authorize?…`,
|
|
199
|
+
the plugin rewrites the `Location` header to `/oidc/authorize?…`. The browser stays on
|
|
200
|
+
`192.168.1.23:5173`, and since your OIDC client builds `redirect_uri` from the request host, it
|
|
201
|
+
comes back there as well.
|
|
202
|
+
|
|
203
|
+
Your app code does not change. Only its authority URL points at the mock.
|
|
204
|
+
|
|
205
|
+
**Several dev servers at once.** Start a second `vite dev` in the same project, e.g. in a Git
|
|
206
|
+
worktree or next to a test run, and it finds the back channel port taken. If an oidc-mock with
|
|
207
|
+
the same issuer answers there, the second server shares it and serves only the login page. That
|
|
208
|
+
works because codes and refresh tokens are self-contained signed JWTs and both servers use the
|
|
209
|
+
same key file. If something else holds the port, the plugin says so and leaves the dev server
|
|
210
|
+
running.
|
|
211
|
+
|
|
212
|
+
> [!NOTE]
|
|
213
|
+
> Vite refuses unknown host names by default. If you open the dev server by host name instead of
|
|
214
|
+
> by IP (e.g. `my-laptop.local`), add it to
|
|
215
|
+
> [`server.allowedHosts`](https://vite.dev/config/server-options#server-allowedhosts).
|
|
216
|
+
|
|
217
|
+
## Recipes
|
|
218
|
+
|
|
219
|
+
### Role claims in the shape of your production provider
|
|
220
|
+
|
|
221
|
+
Keep the claims shaped like production, so the same parsing code runs in dev:
|
|
222
|
+
|
|
223
|
+
```yaml
|
|
224
|
+
users:
|
|
225
|
+
# Logto: an array of role objects
|
|
226
|
+
- sub: logto-admin
|
|
227
|
+
claims:
|
|
228
|
+
email: admin@example.org
|
|
229
|
+
roles: [{ name: admin }]
|
|
230
|
+
|
|
231
|
+
# Zitadel: a map keyed by role, then by organisation
|
|
232
|
+
- sub: zitadel-admin
|
|
233
|
+
claims:
|
|
234
|
+
email: admin@example.org
|
|
235
|
+
'urn:zitadel:iam:org:project:roles':
|
|
236
|
+
admin: { '123456789': example.org }
|
|
237
|
+
|
|
238
|
+
# Keycloak: realm roles
|
|
239
|
+
- sub: keycloak-admin
|
|
240
|
+
claims:
|
|
241
|
+
email: admin@example.org
|
|
242
|
+
realm_access: { roles: [admin] }
|
|
243
|
+
|
|
244
|
+
# Auth0 / Entra style: namespaced string array
|
|
245
|
+
- sub: groups-admin
|
|
246
|
+
claims:
|
|
247
|
+
email: admin@example.org
|
|
248
|
+
'https://example.org/groups': [admins, staff]
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Edge cases as permanent presets
|
|
252
|
+
|
|
253
|
+
The users you only test once a year are the ones that break. Make them one click away:
|
|
254
|
+
|
|
255
|
+
```yaml
|
|
256
|
+
users:
|
|
257
|
+
- sub: no-email
|
|
258
|
+
label: Without email
|
|
259
|
+
description: Provider did not release the email scope
|
|
260
|
+
claims: { given_name: Nora }
|
|
261
|
+
|
|
262
|
+
- sub: unverified
|
|
263
|
+
label: Unverified email
|
|
264
|
+
claims: { email: new@example.org, email_verified: false }
|
|
265
|
+
|
|
266
|
+
- sub: unicode
|
|
267
|
+
label: Ünïcödé name
|
|
268
|
+
claims: { email: zoe@example.org, given_name: Zoë, family_name: Ó Súilleabháin-Müller }
|
|
269
|
+
|
|
270
|
+
- sub: no-roles
|
|
271
|
+
label: Roles claim missing
|
|
272
|
+
claims: { email: plain@example.org }
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Logging in from a script or `curl`
|
|
276
|
+
|
|
277
|
+
The login page is a plain HTML form, so a script can sign in without a browser. Send the hidden
|
|
278
|
+
`params` field back unchanged and name a user:
|
|
279
|
+
|
|
280
|
+
```sh
|
|
281
|
+
# 1. Start a login in your app and grab the redirect to the mock
|
|
282
|
+
AUTH=$(curl -s -c jar -o /dev/null -w '%{redirect_url}' http://127.0.0.1:5173/login)
|
|
283
|
+
# 2. Read the hidden params field from the login page
|
|
284
|
+
PARAMS=$(curl -s "$AUTH" | sed -n 's/.*name="params" value="\([^"]*\)".*/\1/p' | head -1 \
|
|
285
|
+
| sed 's/"/"/g; s/'/'"'"'/g; s/</</g; s/>/>/g; s/&/\&/g')
|
|
286
|
+
# 3. Pick a user; the response redirects back to your app with a code
|
|
287
|
+
curl -s -o /dev/null -w '%{redirect_url}' "${AUTH%%\?*}" \
|
|
288
|
+
--data-urlencode "params=$PARAMS" --data-urlencode "sub=admin"
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Then open the returned URL with the same cookie jar (`curl -b jar -c jar …`) to finish the login
|
|
292
|
+
in your app. For custom claims, send `custom=1`, `custom_sub=…` and `custom_claims=<json>` instead
|
|
293
|
+
of `sub`.
|
|
294
|
+
|
|
295
|
+
## Endpoints
|
|
296
|
+
|
|
297
|
+
All below the issuer, e.g. `http://127.0.0.1:8090/oidc`:
|
|
298
|
+
|
|
299
|
+
| Path | Supports |
|
|
300
|
+
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
301
|
+
| `/.well-known/openid-configuration` | discovery; `/.well-known/oauth-authorization-server` too |
|
|
302
|
+
| `/jwks` | the RS256 public key |
|
|
303
|
+
| `/authorize` | `response_type=code`; PKCE `S256` and `plain`; `state`, `nonce`; `prompt=none` answers `login_required`; RFC 9207 `iss` in the response |
|
|
304
|
+
| `/token` | `authorization_code`, `refresh_token`; client auth `none`, `client_secret_basic`, `client_secret_post` |
|
|
305
|
+
| `/userinfo` | `GET` and `POST`, bearer token |
|
|
306
|
+
| `/introspect` | access, refresh and id tokens |
|
|
307
|
+
| `/revoke` | accepts and ignores |
|
|
308
|
+
| `/end_session` | redirects to `post_logout_redirect_uri` with `state`, or shows a “signed out” page |
|
|
309
|
+
|
|
310
|
+
What the tokens contain:
|
|
311
|
+
|
|
312
|
+
- **`id_token`:** issued when the scope contains `openid`.
|
|
313
|
+
- **`refresh_token`:** issued when the scope contains `offline_access`.
|
|
314
|
+
- **Access token:** a JWT (`typ: at+jwt`) with the client ID as audience, so clients that verify
|
|
315
|
+
it locally against the JWKS work as well.
|
|
316
|
+
- **Codes:** single-use, valid for two minutes.
|
|
317
|
+
- **CORS:** open on all JSON endpoints, so browser-only SPAs can call the token endpoint directly.
|
|
318
|
+
|
|
319
|
+
What is deliberately missing: implicit and hybrid flows, client credentials, device flow, dynamic
|
|
320
|
+
client registration, `request` objects, encrypted tokens and consent screens. If you need one of
|
|
321
|
+
them, open an issue.
|
|
322
|
+
|
|
323
|
+
## Programmatic use and tests
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
import { startServer } from 'oidc-mock';
|
|
327
|
+
|
|
328
|
+
const mock = await startServer({
|
|
329
|
+
inline: {
|
|
330
|
+
port: 0, // pick a free port
|
|
331
|
+
users: [{ sub: 'alice', claims: { email: 'alice@example.org', roles: ['admin'] } }]
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
mock.issuer; // http://127.0.0.1:54321/oidc
|
|
336
|
+
mock.discoveryUrl; // …/.well-known/openid-configuration
|
|
337
|
+
await mock.close();
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
`startServer({ config: 'path/to/oidc-mock.yaml' })` loads a file instead. To mount the provider
|
|
341
|
+
in an existing server, `createProvider()` returns an object whose
|
|
342
|
+
`handle(req, res): Promise<boolean>` works with `node:http`, Express and Connect.
|
|
343
|
+
|
|
344
|
+
## Compared to other mocks
|
|
345
|
+
|
|
346
|
+
| Mock | Login page | Config | Runs in |
|
|
347
|
+
| ------------------------------------------------------------------------- | --------------------------------- | ---------------------- | ---------------------- |
|
|
348
|
+
| **oidc-mock** | one click per user, editable | YAML, live reload | Node / Vite dev server |
|
|
349
|
+
| [navikt/mock-oauth2-server](https://github.com/navikt/mock-oauth2-server) | text field for JSON claims | JSON env var | JVM / Docker |
|
|
350
|
+
| [oauth2-mock-server](https://github.com/axa-group/oauth2-mock-server) | none, approves every request | code, event hooks | Node |
|
|
351
|
+
| [oidc-provider](https://github.com/panva/node-oidc-provider) | username only | code, many options | Node |
|
|
352
|
+
| [Soluto/oidc-server-mock](https://github.com/Soluto/oidc-server-mock) | username and password | JSON / YAML | .NET / Docker |
|
|
353
|
+
| [Dex](https://dexidp.io) | username and password | YAML, no custom claims | Go / Docker |
|
|
354
|
+
|
|
355
|
+
For automated tests without a browser, `oauth2-mock-server` is a fine choice. oidc-mock is meant
|
|
356
|
+
for the times a human clicks through the app.
|
|
357
|
+
|
|
358
|
+
## Development
|
|
359
|
+
|
|
360
|
+
```sh
|
|
361
|
+
bun install
|
|
362
|
+
bun test # end-to-end with openid-client, plus the Vite plugin behind a foreign host
|
|
363
|
+
bun run dev # CLI with examples/oidc-mock.yaml, restarts on change
|
|
364
|
+
bun run typecheck
|
|
365
|
+
bun run build # → dist/
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Releases: bump `version` in `package.json`, then push a tag `v<version>`. The workflow tests,
|
|
369
|
+
builds, publishes to npm with provenance (trusted publishing, no token) and creates a GitHub
|
|
370
|
+
release with the tarball attached.
|
|
371
|
+
|
|
372
|
+
```sh
|
|
373
|
+
git tag v0.1.1 && git push --tags
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The runtime needs Node 20 or later. Bun is only used for development. The dependencies are
|
|
377
|
+
[`jose`](https://github.com/panva/jose), [`yaml`](https://github.com/eemeli/yaml) and
|
|
378
|
+
[`zod`](https://zod.dev).
|
|
379
|
+
|
|
380
|
+
## License
|
|
381
|
+
|
|
382
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { copyFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { ConfigError } from './config.js';
|
|
7
|
+
import { startServer } from './server.js';
|
|
8
|
+
const usage = `Usage:
|
|
9
|
+
oidc-mock [--config oidc-mock.yaml] [--port 8090] [--host 127.0.0.1]
|
|
10
|
+
oidc-mock init [file] write an example config
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
-c, --config YAML file (default: oidc-mock.yaml)
|
|
14
|
+
-p, --port overrides "port" from the config
|
|
15
|
+
-H, --host overrides "host" from the config
|
|
16
|
+
-h, --help show this help`;
|
|
17
|
+
const { values, positionals } = parseArgs({
|
|
18
|
+
allowPositionals: true,
|
|
19
|
+
options: {
|
|
20
|
+
config: { type: 'string', short: 'c', default: 'oidc-mock.yaml' },
|
|
21
|
+
port: { type: 'string', short: 'p' },
|
|
22
|
+
host: { type: 'string', short: 'H' },
|
|
23
|
+
help: { type: 'boolean', short: 'h' }
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
if (values.help) {
|
|
27
|
+
console.log(usage);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
if (positionals[0] === 'init') {
|
|
31
|
+
const target = resolve(positionals[1] ?? values.config);
|
|
32
|
+
if (existsSync(target)) {
|
|
33
|
+
console.error(`${target} already exists.`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const example = resolve(dirname(fileURLToPath(import.meta.url)), '../examples/oidc-mock.yaml');
|
|
37
|
+
copyFileSync(example, target);
|
|
38
|
+
console.log(`Wrote ${target}`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
if (positionals.length) {
|
|
42
|
+
console.error(`Unknown command "${positionals[0]}".\n\n${usage}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
if (!existsSync(values.config)) {
|
|
46
|
+
console.error(`${resolve(values.config)} not found. Create one with: oidc-mock init`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const mock = await startServer({
|
|
51
|
+
config: values.config,
|
|
52
|
+
port: values.port === undefined ? undefined : Number(values.port),
|
|
53
|
+
host: values.host
|
|
54
|
+
});
|
|
55
|
+
const users = mock.config().users.length;
|
|
56
|
+
console.log(`oidc-mock listening – ${users} user${users === 1 ? '' : 's'} from ${resolve(values.config)}`);
|
|
57
|
+
console.log(` issuer: ${mock.issuer}`);
|
|
58
|
+
console.log(` discovery: ${mock.discoveryUrl}`);
|
|
59
|
+
const stop = () => mock.close().then(() => process.exit(0));
|
|
60
|
+
process.on('SIGINT', stop);
|
|
61
|
+
process.on('SIGTERM', stop);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const code = error.code;
|
|
65
|
+
if (error instanceof ConfigError)
|
|
66
|
+
console.error(error.message);
|
|
67
|
+
else if (code === 'EADDRINUSE' || code === 'EACCES') {
|
|
68
|
+
const { address, port } = error;
|
|
69
|
+
console.error(`Cannot listen on ${address}:${port} (${code}). Pick another port with --port or in the config.`);
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
console.error(error);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
declare const userSchema: z.ZodObject<{
|
|
3
|
+
sub: z.ZodString;
|
|
4
|
+
label: z.ZodOptional<z.ZodString>;
|
|
5
|
+
description: z.ZodOptional<z.ZodString>;
|
|
6
|
+
claims: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
declare const clientSchema: z.ZodObject<{
|
|
9
|
+
client_id: z.ZodString;
|
|
10
|
+
client_secret: z.ZodOptional<z.ZodString>;
|
|
11
|
+
redirect_uris: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
declare const configSchema: z.ZodObject<{
|
|
14
|
+
host: z.ZodDefault<z.ZodString>;
|
|
15
|
+
port: z.ZodDefault<z.ZodNumber>;
|
|
16
|
+
base_path: z.ZodPipe<z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<string, string>>, z.ZodTransform<string, string>>;
|
|
17
|
+
issuer: z.ZodOptional<z.ZodURL>;
|
|
18
|
+
key_file: z.ZodDefault<z.ZodString>;
|
|
19
|
+
clients: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
20
|
+
client_id: z.ZodString;
|
|
21
|
+
client_secret: z.ZodOptional<z.ZodString>;
|
|
22
|
+
redirect_uris: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
23
|
+
}, z.core.$strip>>>;
|
|
24
|
+
tokens: z.ZodPrefault<z.ZodObject<{
|
|
25
|
+
access_token_ttl: z.ZodDefault<z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>>;
|
|
26
|
+
id_token_ttl: z.ZodDefault<z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>>;
|
|
27
|
+
refresh_token_ttl: z.ZodDefault<z.ZodUnion<readonly [z.ZodNumber, z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>]>>;
|
|
28
|
+
}, z.core.$strip>>;
|
|
29
|
+
custom_login: z.ZodDefault<z.ZodBoolean>;
|
|
30
|
+
users: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
31
|
+
sub: z.ZodString;
|
|
32
|
+
label: z.ZodOptional<z.ZodString>;
|
|
33
|
+
description: z.ZodOptional<z.ZodString>;
|
|
34
|
+
claims: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
35
|
+
}, z.core.$strip>>>;
|
|
36
|
+
}, z.core.$strip>;
|
|
37
|
+
export type MockUser = z.infer<typeof userSchema>;
|
|
38
|
+
export type MockClient = z.infer<typeof clientSchema>;
|
|
39
|
+
export type MockConfig = z.infer<typeof configSchema> & {
|
|
40
|
+
/** Absolute path of the YAML file, if the config came from one. */
|
|
41
|
+
file?: string;
|
|
42
|
+
/** `issuer` with the default filled in. */
|
|
43
|
+
issuer: string;
|
|
44
|
+
};
|
|
45
|
+
export type MockConfigInput = z.input<typeof configSchema>;
|
|
46
|
+
export declare class ConfigError extends Error {
|
|
47
|
+
}
|
|
48
|
+
export declare function resolveConfig(input: unknown, file?: string): MockConfig;
|
|
49
|
+
export declare function loadConfigFile(path: string): MockConfig;
|
|
50
|
+
/**
|
|
51
|
+
* Returns the current config and re-reads the file whenever it changed, so edits to users and
|
|
52
|
+
* claims apply to the next login without a restart. `host`, `port`, `base_path`, `issuer` and
|
|
53
|
+
* `key_file` stay as they were at start – changing them needs a restart, and a warning says so.
|
|
54
|
+
*/
|
|
55
|
+
export declare function watchConfig(initial: MockConfig): () => MockConfig;
|
|
56
|
+
export declare function findClient(config: MockConfig, clientId: string): MockClient | undefined;
|
|
57
|
+
export declare function redirectUriAllowed(client: MockClient, redirectUri: string): boolean;
|
|
58
|
+
export declare function userLabel(user: MockUser): string;
|
|
59
|
+
export {};
|