@muze-nl/metro-oidc 0.9.0 → 0.9.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/docs/reference.md CHANGED
@@ -29,11 +29,23 @@ const api = client('https://example.solidcommunity.net/')
29
29
  }))
30
30
  ```
31
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.
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. `oidcmw()` checks the status of the response itself, so add middleware that turns responses into errors or data, such as `thrower()` and `getdata()`, after it: `client.with(oidcmw(options)).with(thrower())`.
33
33
 
34
- Important options: `issuer`, `client_info`, `client`, `openid_configuration`, `oauth2`, `store`, `scope`, `nonce`, `use_dpop`, `force_authorization`, and `authorize_callback`.
34
+ Important options: `issuer`, `client_info`, `webid`, `login_hint`, `expected_claims`, `client`, `openid_configuration`, `oauth2`, `store`, `scope`, `nonce`, `use_dpop`, `force_authorization`, and `authorize_callback`.
35
35
 
36
- `use_dpop` defaults to `true`. Disable it only for providers or tests that do not support DPoP.
36
+ `use_dpop` defaults to `true`. Disable it only for providers or tests that do not support DPoP. With `use_dpop`, only DPoP-bound access tokens are accepted: a Bearer token from the issuer is refused, as RFC 9449 section 5.10 requires when DPoP protection matters.
37
+
38
+ ### Which user
39
+
40
+ Tokens are stored per issuer. To keep different users at the same issuer apart, tell `oidcmw()` which user it acts for:
41
+
42
+ - `webid`: the user's WebID. It is sent to the issuer as `login_hint`, and the ID token's `webid` claim must match it. Older Solid issuers without a `webid` claim are matched on `sub`.
43
+ - `login_hint`: without a WebID, a hint for the issuer about which user should log in.
44
+ - `expected_claims`: without a WebID, claims the ID token must contain, e.g. `{ sub: '…' }`.
45
+
46
+ With `webid` or `login_hint`, tokens and the ID token are stored for that user only, so logging in as another user never reuses them. An ID token for a different user than expected is refused and not stored, even when the issuer still has a login session for that other user. Pass the same `webid` or `login_hint` to `idToken()` and `idTokenClaims()`.
47
+
48
+ Without a `scope` option, `oidcmw()` requests `openid webid` when a `webid` is given or the issuer lists `webid` in its `scopes_supported`: Solid-OIDC issuers only include the `webid` claim for that scope. Otherwise it requests `openid`.
37
49
 
38
50
  ## `discover(options)`
39
51
 
@@ -43,6 +55,8 @@ const config = await discover({ issuer: 'https://solidcommunity.net/' })
43
55
 
44
56
  Fetches OIDC discovery metadata. Pass a Metro client with `client` when you want custom middleware or tests.
45
57
 
58
+ The metadata must be for the requested issuer (only a trailing slash may differ), must contain `authorization_endpoint`, `token_endpoint` and `jwks_uri`, and the issuer and all its endpoints must use `https`. Plain `http` is only accepted on the local machine (`localhost`, `127.0.0.1`, `[::1]`). These checks always run; the complete metadata schema is only checked when assertions are enabled.
59
+
46
60
  ## `register(options)`
47
61
 
48
62
  ```js
@@ -55,7 +69,7 @@ const info = await register({
55
69
  })
56
70
  ```
57
71
 
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.
72
+ 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. It then registers a public client: it asks for `token_endpoint_auth_method: 'none'`, `grant_types: ['authorization_code', 'refresh_token']` and `response_types: ['code']`, unless `client_info` says otherwise, and it uses the authentication method the issuer registered. A public client's secret, if an issuer returns one anyway, is not stored.
59
73
 
60
74
  ## ID token helpers
61
75
 
@@ -64,7 +78,7 @@ const raw = idToken({ issuer: 'https://solidcommunity.net/' })
64
78
  const claims = idTokenClaims({ issuer: 'https://solidcommunity.net/' })
65
79
  ```
66
80
 
67
- Returns the stored raw ID token or validated claims. Pass the same `issuer` or `store` used by the middleware.
81
+ Returns the stored raw ID token or validated claims. Pass the same `issuer` and `webid` or `login_hint`, or the same `store`, used by the middleware.
68
82
 
69
83
  ## `validateIdToken(idToken, options)`
70
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muze-nl/metro-oidc",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "oidc client middleware for @muze-nl/metro",
5
5
  "type": "module",
6
6
  "source": [
@@ -30,10 +30,13 @@ export default async function oidcDiscovery(options={}) {
30
30
 
31
31
  options = Object.assign({},defaultOptions,options)
32
32
  options.client = options.client.with(throwermw()).with(jsonmw())
33
+ requireSecureURL('issuer', options.issuer)
33
34
 
34
- const TestSucceeded = false
35
35
  function MustUseHTTPS(url) {
36
- return TestSucceeded // FIXME todo
36
+ if (isSecureURL(url)) {
37
+ return false
38
+ }
39
+ return error('url must use https', url)
37
40
  }
38
41
 
39
42
  // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
@@ -91,9 +94,89 @@ export default async function oidcDiscovery(options={}) {
91
94
  configURL
92
95
  )
93
96
  const openid_config = response.data
97
+ // the full metadata schema is a diagnostic, only checked when assertions
98
+ // are enabled; the checks that protect the login always run
94
99
  assert(openid_config, openid_provider_metadata)
95
-
96
- // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationValidation
97
- assert(openid_config.issuer, options.issuer)
100
+ checkProviderMetadata(openid_config, options.issuer)
98
101
  return openid_config
102
+ }
103
+
104
+ const REQUIRED_ENDPOINTS = ['authorization_endpoint', 'token_endpoint', 'jwks_uri']
105
+
106
+ /**
107
+ * The metadata the login depends on, checked whether or not assertions are
108
+ * enabled: the document must describe the issuer it was fetched for
109
+ * (Discovery section 4.3), so iss checks compare against the right issuer,
110
+ * and every endpoint that receives codes, tokens or keys must use https.
111
+ */
112
+ function checkProviderMetadata(config, issuer)
113
+ {
114
+ if (!config || typeof config !== 'object') {
115
+ throw metro.metroError('metro.oidc.discovery: openid-configuration for '+issuer+' is not a JSON object')
116
+ }
117
+ if (!sameIssuer(config.issuer, issuer)) {
118
+ throw metro.metroError('metro.oidc.discovery: openid-configuration is for issuer '+config.issuer+', expected '+issuer)
119
+ }
120
+ requireSecureURL('issuer', config.issuer)
121
+ for (const name of REQUIRED_ENDPOINTS) {
122
+ if (!config[name]) {
123
+ throw metro.metroError('metro.oidc.discovery: openid-configuration for '+issuer+' has no '+name)
124
+ }
125
+ requireSecureURL(name, config[name])
126
+ }
127
+ if (config.registration_endpoint) {
128
+ requireSecureURL('registration_endpoint', config.registration_endpoint)
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Issuer identifiers must be identical. The only difference allowed is a
134
+ * trailing slash, which WebID profiles and discovery documents often
135
+ * disagree on.
136
+ */
137
+ function sameIssuer(discovered, requested)
138
+ {
139
+ if (typeof discovered != 'string') {
140
+ return false
141
+ }
142
+ return withoutTrailingSlash(discovered) === withoutTrailingSlash(String(requested))
143
+ }
144
+
145
+ function withoutTrailingSlash(value)
146
+ {
147
+ return value.replace(/\/$/, '')
148
+ }
149
+
150
+ function requireSecureURL(name, value)
151
+ {
152
+ if (!isSecureURL(value)) {
153
+ throw metro.metroError('metro.oidc.discovery: '+name+' must use https: '+value)
154
+ }
155
+ }
156
+
157
+ /**
158
+ * https is required; plain http is only accepted on the local machine, for
159
+ * development servers.
160
+ */
161
+ function isSecureURL(value)
162
+ {
163
+ let url
164
+ try {
165
+ url = new URL(String(value))
166
+ }
167
+ catch(e) {
168
+ return false
169
+ }
170
+ if (url.protocol == 'https:') {
171
+ return true
172
+ }
173
+ return url.protocol == 'http:' && isLoopback(url.hostname)
174
+ }
175
+
176
+ function isLoopback(hostname)
177
+ {
178
+ return hostname == 'localhost'
179
+ || hostname.endsWith('.localhost')
180
+ || hostname == '127.0.0.1'
181
+ || hostname == '[::1]'
99
182
  }
@@ -73,7 +73,12 @@ export default function oidcmockserver(options = {}) {
73
73
  client_secret: options.client_secret,
74
74
  redirect_uri: options.redirect_uri,
75
75
  scope: 'openid profile email',
76
- id_token: tokenContext => idToken(tokenContext),
76
+ id_token: tokenContext => {
77
+ if (tokenContext.grant_type == 'refresh_token' && options.refreshIdToken === false) {
78
+ return undefined
79
+ }
80
+ return idToken(tokenContext)
81
+ },
77
82
  requirePKCE: options.requirePKCE ?? false
78
83
  })
79
84
  const clients = new Map()
@@ -109,7 +114,7 @@ export default function oidcmockserver(options = {}) {
109
114
  userinfo_endpoint: endpoint('/userinfo/'),
110
115
  jwks_uri: endpoint('/jwks/'),
111
116
  registration_endpoint: endpoint('/register/'),
112
- scopes_supported: ['openid', 'profile', 'email'],
117
+ scopes_supported: options.scopes_supported ?? ['openid', 'profile', 'email'],
113
118
  response_types_supported: ['code', 'id_token', 'id_token token'],
114
119
  grant_types_supported: ['authorization_code', 'refresh_token'],
115
120
  subject_types_supported: ['public'],
package/src/oidcmw.mjs CHANGED
@@ -9,6 +9,30 @@ import jsonmw from '@muze-nl/metro-middleware/json'
9
9
  import throwermw from '@muze-nl/metro-middleware/thrower'
10
10
  import { validateIdToken } from './oidc.jwt.mjs'
11
11
 
12
+ const pendingClientSetups = new Map()
13
+
14
+ /**
15
+ * A browser app cannot keep a client secret, so it registers as a public
16
+ * client (Solid-OIDC section 11.3: secrets should not be stored in browser
17
+ * storage). The refresh_token grant is requested so issuers hand out refresh
18
+ * tokens. Metadata supplied by the app takes precedence.
19
+ */
20
+ const BROWSER_CLIENT_METADATA = {
21
+ token_endpoint_auth_method: 'none',
22
+ grant_types: ['authorization_code', 'refresh_token'],
23
+ response_types: ['code']
24
+ }
25
+
26
+ function sharedClientSetup(key, setup) {
27
+ if (!pendingClientSetups.has(key)) {
28
+ const pending = setup().finally(() => {
29
+ pendingClientSetups.delete(key)
30
+ })
31
+ pendingClientSetups.set(key, pending)
32
+ }
33
+ return pendingClientSetups.get(key)
34
+ }
35
+
12
36
  export default function oidcmw(options={}) {
13
37
 
14
38
  const defaultOptions = {
@@ -34,8 +58,18 @@ export default function oidcmw(options={}) {
34
58
  openid_configuration: Optional()
35
59
  })
36
60
 
61
+ // Discovery and client registration belong to the issuer. Tokens belong to
62
+ // the user they were issued for, so a login as one user can never reuse
63
+ // another user's tokens at the same issuer.
64
+ const account = accountFor(options)
65
+ const expectedClaims = expectedClaimsFor(options)
66
+ const site = siteFor(options.issuer, account)
67
+ const clientSetupKey = options.issuer + ' '
68
+ + JSON.stringify(requestedClientInfo?.redirect_uris ?? null)
69
+ let userStore = options.store
37
70
  if (!options.store) {
38
71
  options.store = oidcStore(options.issuer)
72
+ userStore = oidcStore(site)
39
73
  }
40
74
  if (!options.openid_configuration && options.store.has('openid_configuration')) {
41
75
  options.openid_configuration = options.store.get('openid_configuration')
@@ -47,56 +81,95 @@ export default function oidcmw(options={}) {
47
81
  }
48
82
  }
49
83
 
50
- return async (req, next) => {
51
- let res
52
- if (!options.force_authorization) {
53
- try {
54
- res = await next(req)
55
- } catch(err) {
56
- res = err?.cause
57
- if (!res || (res.status!=401 && res.status!=403)) {
58
- throw err
59
- }
60
- }
61
- if (res.ok || (res.status!=401 && res.status!=403)) {
62
- return res
63
- }
84
+ /**
85
+ * Discovers the issuer and registers this client once. Concurrent requests
86
+ * wait for the same registration, so tokens are never issued to one
87
+ * registered client while another client_id is stored.
88
+ */
89
+ async function prepareClient()
90
+ {
91
+ if (options.openid_configuration && options.client_info?.client_id) {
92
+ return
64
93
  }
65
- if (!options.openid_configuration) {
66
- options.openid_configuration = await discover({
94
+ const prepared = await sharedClientSetup(clientSetupKey, discoverAndRegister)
95
+ options.openid_configuration = prepared.openid_configuration
96
+ options.client_info = prepared.client_info
97
+ options.store.set('openid_configuration', options.openid_configuration)
98
+ options.store.set('client_info', options.client_info)
99
+ }
100
+
101
+ async function discoverAndRegister()
102
+ {
103
+ let openid_configuration = options.openid_configuration
104
+ if (!openid_configuration) {
105
+ openid_configuration = await discover({
67
106
  issuer: options.issuer,
68
107
  client: options.client.with(options.issuer)
69
108
  })
70
- options.store.set('openid_configuration', options.openid_configuration)
71
109
  }
72
-
73
- if (!options.client_info?.client_id) {
74
- if (!options.openid_configuration.registration_endpoint) {
110
+ let client_info = options.client_info
111
+ if (!client_info?.client_id) {
112
+ if (!openid_configuration.registration_endpoint) {
75
113
  throw metro.metroError('metro.oidcmw: Error: issuer '+options.issuer+' does not support dynamic client registration, but you haven\'t specified a client_id')
76
114
  }
77
- options.client_info = await register({
78
- registration_endpoint: options.openid_configuration.registration_endpoint,
115
+ client_info = await register({
116
+ registration_endpoint: openid_configuration.registration_endpoint,
79
117
  client: options.client,
80
- client_info: options.client_info
118
+ client_info: Object.assign({}, BROWSER_CLIENT_METADATA, client_info)
81
119
  })
82
- options.store.set('client_info', options.client_info)
120
+ if (client_info.token_endpoint_auth_method == 'none') {
121
+ // a public client has no use for a secret, so it is not stored
122
+ delete client_info.client_secret
123
+ }
124
+ }
125
+ return { openid_configuration, client_info }
126
+ }
127
+
128
+ /**
129
+ * The id_token must carry the nonce of the authorization request that
130
+ * produced it. Storing the nonce when that request starts means a request
131
+ * waiting for someone else's login, or the first request after a redirect
132
+ * back from the issuer, cannot replace it.
133
+ */
134
+ function rememberNonce(authorizeCallback)
135
+ {
136
+ if (typeof authorizeCallback != 'function') {
137
+ return authorizeCallback
138
+ }
139
+ return async url => {
140
+ userStore.set('pending_nonce', url.searchParams.get('nonce'))
141
+ return authorizeCallback(url)
142
+ }
143
+ }
144
+
145
+ return async (req, next) => {
146
+ let res
147
+ if (!options.force_authorization) {
148
+ // Middleware that turns responses into errors or data, such as
149
+ // thrower and getdata, belongs outside oidcmw, so the status of the
150
+ // actual response is visible here.
151
+ res = await next(req)
152
+ if (res.ok || (res.status!=401 && res.status!=403)) {
153
+ return res
154
+ }
83
155
  }
156
+ await prepareClient()
84
157
 
85
158
  // now initialize an oauth2 client stack, using options.client as default
86
159
  // with forceAuthentication: true
87
- const scope = options.scope || 'openid'
160
+ const scope = options.scope || defaultScope(options)
88
161
  const nonce = options.nonce || oauth2.generateCodeVerifier(32)
89
- options.store.set('pending_nonce', nonce)
90
162
 
91
163
  const oauth2Options = Object.assign(
92
164
  {
93
- site: options.issuer,
165
+ site,
94
166
  client: options.client,
95
167
  force_authorization: true,
96
- authorize_callback: options.authorize_callback,
168
+ authorize_callback: rememberNonce(options.authorize_callback),
97
169
  oauth2_configuration: {
98
170
  client_id: options.client_info?.client_id,
99
171
  client_secret: options.client_info?.client_secret,
172
+ token_endpoint_auth_method: options.client_info?.token_endpoint_auth_method,
100
173
  grant_type: 'authorization_code',
101
174
  response_type: 'code',
102
175
  response_mode: 'query',
@@ -104,6 +177,11 @@ export default function oidcmw(options={}) {
104
177
  token_endpoint: options.openid_configuration.token_endpoint,
105
178
  scope, //FIXME: should only use scopes supported by server
106
179
  redirect_uri: options.client_info.redirect_uris[0],
180
+ login_hint: options.login_hint ?? options.webid,
181
+ issuer: options.openid_configuration.issuer,
182
+ authorization_response_iss_parameter_supported:
183
+ options.openid_configuration.authorization_response_iss_parameter_supported,
184
+ token_type: options.use_dpop ? 'DPoP' : undefined,
107
185
  nonce
108
186
  }
109
187
  }
@@ -118,7 +196,8 @@ export default function oidcmw(options={}) {
118
196
  return res
119
197
  }
120
198
  const contentType = res.headers.get('content-type')
121
- if (!contentType?.startsWith('application/json')) {
199
+ if (!res.ok || !contentType?.startsWith('application/json')) {
200
+ // oauth2mw reports the token endpoint's own error
122
201
  return res
123
202
  }
124
203
 
@@ -129,16 +208,25 @@ export default function oidcmw(options={}) {
129
208
  }
130
209
 
131
210
  const id_token = data?.id_token
211
+ const isRefresh = await grantTypeOf(req) == 'refresh_token'
212
+ if (isRefresh && !id_token) {
213
+ // a refresh response may omit the id_token; the stored one stays
214
+ return res
215
+ }
132
216
  const jwks = await getJwks()
133
217
  const validation = await validateIdToken(id_token, {
134
218
  issuer: options.openid_configuration.issuer,
135
219
  client_id: options.client_info.client_id,
136
220
  jwks,
137
221
  openid_configuration: options.openid_configuration,
138
- nonce: options.store.get('pending_nonce')
222
+ nonce: isRefresh ? undefined : userStore.get('pending_nonce')
139
223
  })
140
- options.store.set('id_token', id_token)
141
- options.store.set('id_token_claims', validation.claims)
224
+ if (isRefresh) {
225
+ assertSameUserAsLogin(validation.claims, userStore.get('id_token_claims'))
226
+ }
227
+ assertExpectedUser(validation.claims, expectedClaims)
228
+ userStore.set('id_token', id_token)
229
+ userStore.set('id_token_claims', validation.claims)
142
230
  return res
143
231
  }
144
232
 
@@ -156,7 +244,6 @@ export default function oidcmw(options={}) {
156
244
  if (options.use_dpop) {
157
245
  const dpopOptions = {
158
246
  site: options.issuer,
159
- authorization_endpoint: options.openid_configuration.authorization_endpoint,
160
247
  token_endpoint: options.openid_configuration.token_endpoint,
161
248
  dpop_signing_alg_values_supported: options.openid_configuration.dpop_signing_alg_values_supported
162
249
  }
@@ -184,6 +271,83 @@ function clientInfoMatchesRequest(storedClientInfo, requestedClientInfo) {
184
271
  return requestedClientInfo.redirect_uris.every(uri => storedRedirectUris.has(uri))
185
272
  }
186
273
 
274
+ /**
275
+ * A WebID identifies the user when available (Solid-OIDC puts it in the
276
+ * webid claim); otherwise login_hint and expected_claims do.
277
+ */
278
+ function accountFor(options) {
279
+ return options.webid ?? options.login_hint
280
+ }
281
+
282
+ function expectedClaimsFor(options) {
283
+ if (options.webid) {
284
+ return Object.assign({ webid: options.webid }, options.expected_claims)
285
+ }
286
+ return options.expected_claims || {}
287
+ }
288
+
289
+ /**
290
+ * Solid-OIDC issuers only include the webid claim when the webid scope is
291
+ * requested (Solid-OIDC section 7). It is requested when a WebID is given or
292
+ * the issuer lists it in scopes_supported, which marks a Solid-OIDC issuer.
293
+ */
294
+ function defaultScope(options) {
295
+ const supported = options.openid_configuration?.scopes_supported
296
+ const solidIssuer = Array.isArray(supported) && supported.includes('webid')
297
+ if (options.webid || solidIssuer) {
298
+ return 'openid webid'
299
+ }
300
+ return 'openid'
301
+ }
302
+
303
+ function siteFor(issuer, account) {
304
+ if (!account) {
305
+ return issuer
306
+ }
307
+ return issuer + '|' + account
308
+ }
309
+
310
+ /**
311
+ * Refuses tokens issued for another user than the one requested, e.g. when
312
+ * the issuer still has a login session for a different account.
313
+ */
314
+ function assertExpectedUser(claims, expectedClaims) {
315
+ for (const [name, expected] of Object.entries(expectedClaims)) {
316
+ const actual = claimValue(claims, name)
317
+ if (actual !== expected) {
318
+ throw metro.metroError('metro.oidcmw: id_token is for a different user: '
319
+ + 'expected ' + name + ' ' + expected + ', got ' + actual)
320
+ }
321
+ }
322
+ }
323
+
324
+ async function grantTypeOf(req) {
325
+ const body = await req.clone().text()
326
+ return new URLSearchParams(body).get('grant_type')
327
+ }
328
+
329
+ /**
330
+ * An id_token from a refresh has no nonce of its own. It must describe the
331
+ * same login: same issuer (checked by validateIdToken) and same subject.
332
+ */
333
+ function assertSameUserAsLogin(claims, loginClaims) {
334
+ if (loginClaims && claims.sub !== loginClaims.sub) {
335
+ throw metro.metroError('metro.oidcmw: refreshed id_token is for a different user: '
336
+ + 'expected sub ' + loginClaims.sub + ', got ' + claims.sub)
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Solid-OIDC issuers put the WebID in the webid claim; older issuers only
342
+ * use sub for it.
343
+ */
344
+ function claimValue(claims, name) {
345
+ if (name == 'webid' && claims.webid === undefined) {
346
+ return claims.sub
347
+ }
348
+ return claims[name]
349
+ }
350
+
187
351
  export function isRedirected() {
188
352
  return oauth2.isRedirected()
189
353
  }
@@ -193,7 +357,7 @@ export function idToken(options) {
193
357
  if (!options.issuer) {
194
358
  throw metro.metroError('Must supply options.issuer or options.store to get the id_token')
195
359
  }
196
- options.store = oidcStore(options.issuer)
360
+ options.store = oidcStore(siteFor(options.issuer, accountFor(options)))
197
361
  }
198
362
  return options.store.get('id_token')
199
363
  }
@@ -203,7 +367,7 @@ export function idTokenClaims(options) {
203
367
  if (!options.issuer) {
204
368
  throw metro.metroError('Must supply options.issuer or options.store to get the id_token claims')
205
369
  }
206
- options.store = oidcStore(options.issuer)
370
+ options.store = oidcStore(siteFor(options.issuer, accountFor(options)))
207
371
  }
208
372
  return options.store.get('id_token_claims')
209
373
  }