@muze-nl/metro-oidc 0.6.0 → 0.8.0

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/docs/README.md ADDED
@@ -0,0 +1,30 @@
1
+ ---
2
+ title: '@muze-nl/metro-oidc'
3
+ ---
4
+ # @muze-nl/metro-oidc
5
+
6
+ ```sh
7
+ npm install @muze-nl/metro-core @muze-nl/metro-oidc
8
+ ```
9
+
10
+ ```js
11
+ import { client } from '@muze-nl/metro-core'
12
+ import { oidcmw } from '@muze-nl/metro-oidc'
13
+
14
+ const api = client('https://example.solidcommunity.net/')
15
+ .with(oidcmw({
16
+ issuer: 'https://solidcommunity.net/',
17
+ client_info: {
18
+ client_name: 'My Metro App',
19
+ redirect_uris: [location.href]
20
+ }
21
+ }))
22
+
23
+ const result = api.get('foo')
24
+ ```
25
+
26
+ Use this package when a resource is protected by OpenID Connect. It can discover the issuer metadata, dynamically register a client when supported, configure OAuth2, validate the ID token, and store the resulting ID token claims.
27
+
28
+ ## Reference
29
+
30
+ See [reference](reference.md).
@@ -0,0 +1,97 @@
1
+ ---
2
+ title: 'Reference'
3
+ ---
4
+ # @muze-nl/metro-oidc reference
5
+
6
+ ```js
7
+ import oidc, {
8
+ oidcmw,
9
+ discover,
10
+ register,
11
+ isRedirected,
12
+ idToken,
13
+ idTokenClaims,
14
+ oidcStore,
15
+ validateIdToken
16
+ } from '@muze-nl/metro-oidc'
17
+ ```
18
+
19
+ ## `oidcmw(options)`
20
+
21
+ ```js
22
+ const api = client('https://example.solidcommunity.net/')
23
+ .with(oidcmw({
24
+ issuer: 'https://solidcommunity.net/',
25
+ client_info: {
26
+ client_name: 'My Metro App',
27
+ redirect_uris: [location.href]
28
+ }
29
+ }))
30
+ ```
31
+
32
+ Adds OpenID Connect authorization to a Metro client. By default it tries the request first and authorizes after a `401` or `403`. Set `force_authorization: true` to authorize immediately.
33
+
34
+ Important options: `issuer`, `client_info`, `client`, `openid_configuration`, `oauth2`, `store`, `scope`, `nonce`, `use_dpop`, `force_authorization`, and `authorize_callback`.
35
+
36
+ `use_dpop` defaults to `true`. Disable it only for providers or tests that do not support DPoP.
37
+
38
+ ## `discover(options)`
39
+
40
+ ```js
41
+ const config = await discover({ issuer: 'https://solidcommunity.net/' })
42
+ ```
43
+
44
+ Fetches OIDC discovery metadata. Pass a Metro client with `client` when you want custom middleware or tests.
45
+
46
+ ## `register(options)`
47
+
48
+ ```js
49
+ const info = await register({
50
+ registration_endpoint: config.registration_endpoint,
51
+ client_info: {
52
+ client_name: 'My Metro App',
53
+ redirect_uris: [location.href]
54
+ }
55
+ })
56
+ ```
57
+
58
+ Performs dynamic client registration and returns client information. `oidcmw()` calls this automatically when no `client_info.client_id` is present and the issuer supports registration.
59
+
60
+ ## ID token helpers
61
+
62
+ ```js
63
+ const raw = idToken({ issuer: 'https://solidcommunity.net/' })
64
+ const claims = idTokenClaims({ issuer: 'https://solidcommunity.net/' })
65
+ ```
66
+
67
+ Returns the stored raw ID token or validated claims. Pass the same `issuer` or `store` used by the middleware.
68
+
69
+ ## `validateIdToken(idToken, options)`
70
+
71
+ ```js
72
+ const validation = await validateIdToken(idToken, {
73
+ issuer: config.issuer,
74
+ client_id: clientInfo.client_id,
75
+ jwks,
76
+ openid_configuration: config,
77
+ nonce
78
+ })
79
+ ```
80
+
81
+ Validates the token signature and standard claims, including issuer, audience, expiry, required claims, and nonce.
82
+
83
+ ## Store
84
+
85
+ ```js
86
+ const store = oidcStore('https://solidcommunity.net/')
87
+ ```
88
+
89
+ Creates a simple storage object backed by `localStorage` when available or memory otherwise.
90
+
91
+ ## Test mock server
92
+
93
+ ```js
94
+ import oidcmockserver from '@muze-nl/metro-oidc/testing'
95
+ ```
96
+
97
+ The mock server exposes discovery metadata, dynamic client registration, JWKS, signed ID tokens, userinfo, and OAuth2-backed protected-resource behaviour for tests.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@muze-nl/metro-oidc",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "oidc client middleware for @muze-nl/metro",
5
5
  "type": "module",
6
6
  "source": [
7
7
  "src/browser.mjs"
8
8
  ],
9
- "main": "src/browser.mjs",
9
+ "main": "src/index.mjs",
10
10
  "targets": {
11
11
  "default": {
12
12
  "distDir": "./dist",
@@ -18,23 +18,28 @@
18
18
  "test": "test"
19
19
  },
20
20
  "scripts": {
21
- "test": "tap test/*.mjs",
21
+ "test": "tap --disable-coverage --jobs=1 test/*.mjs",
22
22
  "tap": "tap",
23
- "build": "npx esbuild --bundle src/browser.mjs --outfile=dist/browser.min.js --minify --sourcemap",
24
- "build-dev": "npx esbuild --bundle src/browser.mjs --outfile=dist/browser.js --sourcemap"
23
+ "build": "esbuild --bundle src/browser.mjs --outfile=dist/browser.min.js --minify --sourcemap",
24
+ "build-dev": "esbuild --bundle src/browser.mjs --outfile=dist/browser.js --sourcemap",
25
+ "build:dev": "esbuild --bundle src/browser.mjs --outfile=dist/browser.js --sourcemap"
25
26
  },
26
27
  "repository": {
27
28
  "type": "git",
28
- "url": "git+https://github.com/muze-nl/metro-oidc.git"
29
+ "url": "git+https://github.com/muze-nl/metro.git",
30
+ "directory": "packages/metro-oidc"
29
31
  },
30
32
  "author": "auke@muze.nl",
31
33
  "license": "MIT",
32
34
  "bugs": {
33
- "url": "https://github.com/muze-nl/metro-oidc/issues"
35
+ "url": "https://github.com/muze-nl/metro/issues"
34
36
  },
35
- "homepage": "https://github.com/muze-nl/metro-oidc/#readme",
37
+ "homepage": "https://github.com/muze-nl/metro/tree/main/packages/metro-oidc#readme",
36
38
  "devDependencies": {
37
- "eslint": "^8.13.0"
39
+ "@eslint/js": "^10.0.1",
40
+ "eslint": "^10.5.0",
41
+ "globals": "^17.7.0",
42
+ "tap": "^21.7.4"
38
43
  },
39
44
  "engines": {
40
45
  "node": ">=20.0.0"
@@ -43,11 +48,39 @@
43
48
  "/src",
44
49
  "/dist",
45
50
  "/LICENSE",
46
- "/README.md"
51
+ "/README.md",
52
+ "/docs"
47
53
  ],
48
54
  "dependencies": {
49
- "@muze-nl/assert": "^0.5.1",
50
- "@muze-nl/metro": "^0.6.20",
51
- "@muze-nl/metro-oauth2": "^0.8.1"
52
- }
55
+ "@muze-nl/assert": "^0.6.0",
56
+ "@muze-nl/metro-core": "^0.8.0",
57
+ "@muze-nl/metro-middleware": "^0.8.0",
58
+ "@muze-nl/metro-oauth2": "^0.8.2",
59
+ "jose": "^6.2.3"
60
+ },
61
+ "exports": {
62
+ ".": "./src/index.mjs",
63
+ "./browser": "./src/browser.mjs",
64
+ "./browser.mjs": "./src/browser.mjs",
65
+ "./middleware": "./src/oidcmw.mjs",
66
+ "./middleware.mjs": "./src/oidcmw.mjs",
67
+ "./oidcmw": "./src/oidcmw.mjs",
68
+ "./oidcmw.mjs": "./src/oidcmw.mjs",
69
+ "./discovery": "./src/oidc.discovery.mjs",
70
+ "./discovery.mjs": "./src/oidc.discovery.mjs",
71
+ "./register": "./src/oidc.register.mjs",
72
+ "./register.mjs": "./src/oidc.register.mjs",
73
+ "./store": "./src/oidc.store.mjs",
74
+ "./store.mjs": "./src/oidc.store.mjs",
75
+ "./jwt": "./src/oidc.jwt.mjs",
76
+ "./jwt.mjs": "./src/oidc.jwt.mjs",
77
+ "./testing": "./src/testing.mjs",
78
+ "./src/*": "./src/*",
79
+ "./package.json": "./package.json"
80
+ },
81
+ "sideEffects": [
82
+ "./src/browser.mjs",
83
+ "./dist/browser.js",
84
+ "./dist/browser.min.js"
85
+ ]
53
86
  }
package/src/browser.mjs CHANGED
@@ -1,19 +1,13 @@
1
- import metro from '@muze-nl/metro'
2
- import oauth2 from '@muze-nl/metro-oauth2'
3
- import oidcDiscover from './oidc.discovery.mjs'
4
- import oidcRegister from './oidc.register.mjs'
5
- import oidcmw, {isRedirected, idToken} from './oidcmw.mjs'
1
+ import * as core from '@muze-nl/metro-core'
2
+ import oidc from './index.mjs'
6
3
 
7
- const oidc = {
8
- oidcmw,
9
- discover: oidcDiscover,
10
- register: oidcRegister,
11
- isRedirected,
12
- idToken
13
- }
4
+ const metro = Object.assign({}, core, globalThis.metro || {})
14
5
 
15
- if (!globalThis.metro.oidc) {
16
- globalThis.metro.oidc = oidc
6
+ if (!metro.oidc) {
7
+ metro.oidc = oidc
17
8
  }
18
9
 
19
- export default oidc
10
+ globalThis.metro = metro
11
+
12
+ export * from './index.mjs'
13
+ export default oidc
package/src/index.mjs ADDED
@@ -0,0 +1,20 @@
1
+ import oidcmw, { isRedirected, idToken, idTokenClaims } from './oidcmw.mjs'
2
+ import discover from './oidc.discovery.mjs'
3
+ import register from './oidc.register.mjs'
4
+
5
+ const oidc = {
6
+ oidcmw,
7
+ discover,
8
+ register,
9
+ isRedirected,
10
+ idToken,
11
+ idTokenClaims
12
+ }
13
+
14
+ export default oidc
15
+ export { default as oidcmw } from './oidcmw.mjs'
16
+ export { isRedirected, idToken, idTokenClaims } from './oidcmw.mjs'
17
+ export { default as discover } from './oidc.discovery.mjs'
18
+ export { default as register } from './oidc.register.mjs'
19
+ export { default as oidcStore } from './oidc.store.mjs'
20
+ export { validateIdToken } from './oidc.jwt.mjs'
@@ -2,9 +2,9 @@
2
2
  * This module adheres to OpenID Connect Discovery 1.0 incorporating errata set 2 - december 15, 2023
3
3
  * https://openid.net/specs/openid-connect-discovery-1_0.html
4
4
  */
5
- import metro from '@muze-nl/metro'
6
- import jsonmw from '@muze-nl/metro/src/mw/json.mjs'
7
- import throwermw from '@muze-nl/metro/src/mw/thrower.mjs'
5
+ import * as metro from '@muze-nl/metro-core'
6
+ import jsonmw from '@muze-nl/metro-middleware/json'
7
+ import throwermw from '@muze-nl/metro-middleware/thrower'
8
8
  import { validJWA, MustInclude, validAuthMethods } from './oidc.util.mjs'
9
9
  import { assert, fails, Required, Recommended, Optional, oneOf, anyOf, allOf, validURL, instanceOf, not, error } from '@muze-nl/assert'
10
10
 
@@ -29,6 +29,7 @@ export default async function oidcDiscovery(options={}) {
29
29
  }
30
30
 
31
31
  options = Object.assign({},defaultOptions,options)
32
+ options.client = options.client.with(throwermw()).with(jsonmw())
32
33
 
33
34
  const TestSucceeded = false
34
35
  function MustUseHTTPS(url) {
@@ -0,0 +1,65 @@
1
+ import { createLocalJWKSet, jwtVerify } from 'jose'
2
+ import * as metro from '@muze-nl/metro-core'
3
+
4
+ const jwksCache = new WeakMap()
5
+
6
+ function keySetFor(jwks) {
7
+ if (!jwks || !Array.isArray(jwks.keys)) {
8
+ throw metro.metroError('metro.oidc: Error: jwks must be an object with a keys array')
9
+ }
10
+ if (!jwksCache.has(jwks)) {
11
+ jwksCache.set(jwks, createLocalJWKSet(jwks))
12
+ }
13
+ return jwksCache.get(jwks)
14
+ }
15
+
16
+ function supportedAlgorithms(openidConfiguration = {}) {
17
+ const algorithms = openidConfiguration.id_token_signing_alg_values_supported
18
+ if (Array.isArray(algorithms) && algorithms.length) {
19
+ return algorithms.filter(algorithm => algorithm !== 'none')
20
+ }
21
+ return ['RS256']
22
+ }
23
+
24
+ /**
25
+ * Validate an OpenID Connect ID Token.
26
+ *
27
+ * This intentionally keeps jose behind a tiny Metro/OIDC helper so the public
28
+ * OIDC middleware API can stay small while still delegating JWT/JWS/JWKS
29
+ * validation to a mature library.
30
+ */
31
+ export async function validateIdToken(idToken, options = {}) {
32
+ if (!idToken) {
33
+ throw metro.metroError('metro.oidc: Error: token response did not include an id_token')
34
+ }
35
+ if (!options.jwks) {
36
+ throw metro.metroError('metro.oidc: Error: cannot validate id_token without jwks')
37
+ }
38
+ if (!options.issuer) {
39
+ throw metro.metroError('metro.oidc: Error: cannot validate id_token without issuer')
40
+ }
41
+ if (!options.client_id) {
42
+ throw metro.metroError('metro.oidc: Error: cannot validate id_token without client_id')
43
+ }
44
+
45
+ const { payload, protectedHeader } = await jwtVerify(
46
+ idToken,
47
+ keySetFor(options.jwks),
48
+ {
49
+ issuer: options.issuer,
50
+ audience: options.client_id,
51
+ algorithms: options.algorithms || supportedAlgorithms(options.openid_configuration),
52
+ clockTolerance: options.clockTolerance ?? 60,
53
+ requiredClaims: ['iss', 'sub', 'aud', 'exp', 'iat']
54
+ }
55
+ )
56
+
57
+ if (options.nonce !== undefined && payload.nonce !== options.nonce) {
58
+ throw metro.metroError('metro.oidc: Error: id_token nonce mismatch')
59
+ }
60
+
61
+ return {
62
+ header: protectedHeader,
63
+ claims: payload
64
+ }
65
+ }
@@ -0,0 +1,195 @@
1
+ import * as metro from '@muze-nl/metro-core'
2
+ import oauth2mockserver from '@muze-nl/metro-oauth2/testing'
3
+ import { exportJWK, generateKeyPair, SignJWT } from 'jose'
4
+
5
+ const defaultIssuer = 'https://issuer.example/'
6
+ const defaultRedirectUri = 'https://client.example/callback'
7
+ const jsonHeaders = {
8
+ 'Content-Type': 'application/json'
9
+ }
10
+ const textHeaders = {
11
+ 'Content-Type': 'text/plain'
12
+ }
13
+
14
+ function jsonResponse(body, status = 200, statusText = 'OK') {
15
+ return metro.response({
16
+ status,
17
+ statusText,
18
+ headers: jsonHeaders,
19
+ body: JSON.stringify(body)
20
+ })
21
+ }
22
+
23
+ function errorResponse(error, description, status = 400) {
24
+ return jsonResponse({
25
+ error,
26
+ error_description: description
27
+ }, status, status === 401 ? 'Unauthorized' : 'Bad Request')
28
+ }
29
+
30
+ async function requestData(req) {
31
+ if (req.data instanceof URLSearchParams) {
32
+ return Object.fromEntries(req.data.entries())
33
+ }
34
+ if (req.data && typeof req.data === 'object') {
35
+ return req.data
36
+ }
37
+ if (!req.body) {
38
+ return {}
39
+ }
40
+ const text = await req.clone().text()
41
+ try {
42
+ return JSON.parse(text)
43
+ } catch (e) {
44
+ return Object.fromEntries(new URLSearchParams(text).entries())
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Small OIDC provider mock for Metro tests.
50
+ *
51
+ * It exposes discovery metadata, dynamic client registration, userinfo, JWKS
52
+ * and delegates the OAuth2 authorization/token/protected-resource endpoints to
53
+ * the OAuth2 mock server. Its token endpoint returns real signed ID Tokens so
54
+ * the OIDC client tests exercise actual JWT/JWS/JWKS validation.
55
+ */
56
+ export default function oidcmockserver(options = {}) {
57
+ options = Object.assign({
58
+ issuer: defaultIssuer,
59
+ client_id: 'mockClientId',
60
+ client_secret: 'mockClientSecret',
61
+ redirect_uri: defaultRedirectUri,
62
+ sub: 'mockSubject',
63
+ alg: 'RS256',
64
+ kid: 'mock-signing-key',
65
+ publishSigningKey: true,
66
+ idTokenClaims: {}
67
+ }, options)
68
+
69
+ const issuer = metro.url(options.issuer)
70
+ const keyPair = createSigningKey(options)
71
+ const oauth2 = oauth2mockserver({
72
+ client_id: options.client_id,
73
+ client_secret: options.client_secret,
74
+ redirect_uri: options.redirect_uri,
75
+ scope: 'openid profile email',
76
+ id_token: tokenContext => idToken(tokenContext),
77
+ requirePKCE: options.requirePKCE ?? false
78
+ })
79
+ const clients = new Map()
80
+
81
+ return async (req, next) => {
82
+ const url = metro.url(req.url)
83
+ switch (url.pathname) {
84
+ case '/.well-known/openid-configuration':
85
+ return discovery()
86
+ case '/register/':
87
+ case '/register':
88
+ return register(req)
89
+ case '/userinfo/':
90
+ case '/userinfo':
91
+ return userinfo(req)
92
+ case '/jwks/':
93
+ case '/jwks':
94
+ return jwks()
95
+ default:
96
+ return oauth2(req, next)
97
+ }
98
+ }
99
+
100
+ function endpoint(path) {
101
+ return metro.url(issuer, path).href
102
+ }
103
+
104
+ function discovery() {
105
+ return jsonResponse({
106
+ issuer: issuer.href,
107
+ authorization_endpoint: endpoint('/authorize/'),
108
+ token_endpoint: endpoint('/token/'),
109
+ userinfo_endpoint: endpoint('/userinfo/'),
110
+ jwks_uri: endpoint('/jwks/'),
111
+ registration_endpoint: endpoint('/register/'),
112
+ scopes_supported: ['openid', 'profile', 'email'],
113
+ response_types_supported: ['code', 'id_token', 'id_token token'],
114
+ grant_types_supported: ['authorization_code', 'refresh_token'],
115
+ subject_types_supported: ['public'],
116
+ id_token_signing_alg_values_supported: [options.alg],
117
+ claims_supported: ['iss', 'sub', 'aud', 'exp', 'iat', 'nonce', 'name', 'email']
118
+ })
119
+ }
120
+
121
+ async function jwks() {
122
+ if (!options.publishSigningKey) {
123
+ return jsonResponse({ keys: [] })
124
+ }
125
+ const { publicKey } = await keyPair
126
+ const jwk = await exportJWK(publicKey)
127
+ jwk.kid = options.jwksKid || options.kid
128
+ jwk.alg = options.alg
129
+ jwk.use = 'sig'
130
+ return jsonResponse({ keys: [jwk] })
131
+ }
132
+
133
+ async function register(req) {
134
+ if (req.method !== 'POST') {
135
+ return errorResponse('invalid_request', 'registration endpoint requires POST')
136
+ }
137
+ const body = await requestData(req)
138
+ if (!Array.isArray(body.redirect_uris) || body.redirect_uris.length === 0) {
139
+ return errorResponse('invalid_client_metadata', 'redirect_uris is required')
140
+ }
141
+ const info = {
142
+ ...body,
143
+ client_id: options.client_id,
144
+ client_id_issued_at: Math.floor(Date.now() / 1000),
145
+ token_endpoint_auth_method: options.client_secret ? 'client_secret_post' : 'none'
146
+ }
147
+ if (options.client_secret) {
148
+ info.client_secret = options.client_secret
149
+ }
150
+ clients.set(info.client_id, info)
151
+ return jsonResponse(info, 201, 'Created')
152
+ }
153
+
154
+ function userinfo(req) {
155
+ const auth = req.headers.get('Authorization')
156
+ if (auth !== 'Bearer mockAccessToken') {
157
+ return metro.response({
158
+ status: 401,
159
+ statusText: 'Unauthorized',
160
+ headers: textHeaders,
161
+ body: '401 Unauthorized'
162
+ })
163
+ }
164
+ return jsonResponse({
165
+ sub: options.sub,
166
+ name: 'Mock User',
167
+ email: 'mock@example.test'
168
+ })
169
+ }
170
+
171
+ async function idToken(tokenContext = {}) {
172
+ const now = Math.floor(Date.now() / 1000)
173
+ const authorization = tokenContext.authorization || {}
174
+ const claims = {
175
+ iss: issuer.href,
176
+ sub: options.sub,
177
+ aud: options.client_id,
178
+ exp: now + 3600,
179
+ iat: now,
180
+ ...(authorization.nonce ? { nonce: authorization.nonce } : {}),
181
+ ...options.idTokenClaims
182
+ }
183
+ const { privateKey } = await keyPair
184
+ return new SignJWT(claims)
185
+ .setProtectedHeader({ alg: options.alg, kid: options.kid })
186
+ .sign(privateKey)
187
+ }
188
+ }
189
+
190
+ function createSigningKey(options) {
191
+ return generateKeyPair(options.alg, {
192
+ extractable: true,
193
+ modulusLength: 2048
194
+ })
195
+ }
@@ -3,9 +3,9 @@
3
3
  * incorporating errata set 2 - december 15, 2023
4
4
  * https://openid.net/specs/openid-connect-registration-1_0.html
5
5
  */
6
- import metro from '@muze-nl/metro'
7
- import jsonmw from '@muze-nl/metro/src/mw/json.mjs'
8
- import throwermw from '@muze-nl/metro/src/mw/thrower.mjs'
6
+ import * as metro from '@muze-nl/metro-core'
7
+ import jsonmw from '@muze-nl/metro-middleware/json'
8
+ import throwermw from '@muze-nl/metro-middleware/thrower'
9
9
  import { validJWA, validAuthMethods, MustHave } from './oidc.util.mjs'
10
10
  import { assert, Required, Optional, oneOf, anyOf, validURL, validEmail, not, instanceOf } from '@muze-nl/assert'
11
11
 
@@ -60,6 +60,7 @@ export default async function register(options)
60
60
  }
61
61
 
62
62
  options = Object.assign({}, defaultOptions, options)
63
+ options.client = options.client.with(throwermw()).with(jsonmw())
63
64
  if (!options.client_info) {
64
65
  options.client_info = {}
65
66
  }
@@ -71,8 +72,8 @@ export default async function register(options)
71
72
  body: options.client_info
72
73
  })
73
74
  let info = response.data
74
- if (!info.client_id || !info.client_secret) {
75
- throw metro.metroError('metro.oidc: Error: dynamic registration of client failed, no client_id or client_secret returned', response)
75
+ if (!info.client_id) {
76
+ throw metro.metroError('metro.oidc: Error: dynamic registration of client failed, no client_id returned', response)
76
77
  }
77
78
  options.client_info = Object.assign(options.client_info, info)
78
79
  return options.client_info
package/src/oidc.util.mjs CHANGED
@@ -30,5 +30,5 @@ export const validJWA = [
30
30
 
31
31
  //TODO: add link to spec
32
32
  export const validAuthMethods = [
33
- 'client_secret_post', 'client_secret_basic','client_secret_jwt','private_key_jwt'
33
+ 'none', 'client_secret_post', 'client_secret_basic','client_secret_jwt','private_key_jwt'
34
34
  ]