@muze-nl/metro-oidc 0.9.1 → 0.9.3
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/dist/browser.js +364 -114
- package/dist/browser.js.map +3 -3
- package/dist/browser.min.js +3 -3
- package/dist/browser.min.js.map +3 -3
- package/docs/reference.md +23 -5
- package/package.json +1 -1
- package/src/oidc.discovery.mjs +88 -5
- package/src/oidc.mockserver.mjs +7 -2
- package/src/oidcmw.mjs +204 -39
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
|
|
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
|
|
|
@@ -95,3 +109,7 @@ import oidcmockserver from '@muze-nl/metro-oidc/testing'
|
|
|
95
109
|
```
|
|
96
110
|
|
|
97
111
|
The mock server exposes discovery metadata, dynamic client registration, JWKS, signed ID tokens, userinfo, and OAuth2-backed protected-resource behaviour for tests.
|
|
112
|
+
|
|
113
|
+
The middleware captures the token request grant type before sending the request.
|
|
114
|
+
ID-token validation and refresh handling therefore work with transports that
|
|
115
|
+
consume request bodies; callers do not need to buffer or replay token POSTs.
|
package/package.json
CHANGED
package/src/oidc.discovery.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/oidc.mockserver.mjs
CHANGED
|
@@ -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 =>
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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 (!
|
|
74
|
-
if (!
|
|
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
|
-
|
|
78
|
-
registration_endpoint:
|
|
115
|
+
client_info = await register({
|
|
116
|
+
registration_endpoint: openid_configuration.registration_endpoint,
|
|
79
117
|
client: options.client,
|
|
80
|
-
client_info:
|
|
118
|
+
client_info: Object.assign({}, BROWSER_CLIENT_METADATA, client_info)
|
|
81
119
|
})
|
|
82
|
-
|
|
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 ||
|
|
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
|
|
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
|
}
|
|
@@ -111,34 +189,45 @@ export default function oidcmw(options={}) {
|
|
|
111
189
|
)
|
|
112
190
|
|
|
113
191
|
const storeIdToken = async (req, next) => {
|
|
114
|
-
const res = await next(req)
|
|
115
192
|
const tokenEndpoint = metro.url(options.openid_configuration.token_endpoint, { hash: '' }).href
|
|
116
193
|
const requestUrl = metro.url(req.url, { hash: '' }).href
|
|
117
194
|
if (requestUrl !== tokenEndpoint) {
|
|
118
|
-
return
|
|
195
|
+
return next(req)
|
|
119
196
|
}
|
|
197
|
+
// Fetch consumes the outgoing body; inspect the grant before dispatch.
|
|
198
|
+
const isRefresh = await grantTypeOf(req) == 'refresh_token'
|
|
199
|
+
const res = await next(req)
|
|
120
200
|
const contentType = res.headers.get('content-type')
|
|
121
|
-
if (!contentType?.startsWith('application/json')) {
|
|
201
|
+
if (!res.ok || !contentType?.startsWith('application/json')) {
|
|
202
|
+
// oauth2mw reports the token endpoint's own error
|
|
122
203
|
return res
|
|
123
204
|
}
|
|
124
205
|
|
|
125
|
-
let data = res.data
|
|
126
|
-
if (!data) {
|
|
206
|
+
let data = res.data
|
|
207
|
+
if (!data || typeof data !== 'object' || data instanceof ReadableStream) {
|
|
127
208
|
const res2 = res.clone() // otherwise res.body can't be read again
|
|
128
209
|
data = await res2.json()
|
|
129
210
|
}
|
|
130
211
|
|
|
131
212
|
const id_token = data?.id_token
|
|
213
|
+
if (isRefresh && !id_token) {
|
|
214
|
+
// a refresh response may omit the id_token; the stored one stays
|
|
215
|
+
return res
|
|
216
|
+
}
|
|
132
217
|
const jwks = await getJwks()
|
|
133
218
|
const validation = await validateIdToken(id_token, {
|
|
134
219
|
issuer: options.openid_configuration.issuer,
|
|
135
220
|
client_id: options.client_info.client_id,
|
|
136
221
|
jwks,
|
|
137
222
|
openid_configuration: options.openid_configuration,
|
|
138
|
-
nonce:
|
|
223
|
+
nonce: isRefresh ? undefined : userStore.get('pending_nonce')
|
|
139
224
|
})
|
|
140
|
-
|
|
141
|
-
|
|
225
|
+
if (isRefresh) {
|
|
226
|
+
assertSameUserAsLogin(validation.claims, userStore.get('id_token_claims'))
|
|
227
|
+
}
|
|
228
|
+
assertExpectedUser(validation.claims, expectedClaims)
|
|
229
|
+
userStore.set('id_token', id_token)
|
|
230
|
+
userStore.set('id_token_claims', validation.claims)
|
|
142
231
|
return res
|
|
143
232
|
}
|
|
144
233
|
|
|
@@ -156,7 +245,6 @@ export default function oidcmw(options={}) {
|
|
|
156
245
|
if (options.use_dpop) {
|
|
157
246
|
const dpopOptions = {
|
|
158
247
|
site: options.issuer,
|
|
159
|
-
authorization_endpoint: options.openid_configuration.authorization_endpoint,
|
|
160
248
|
token_endpoint: options.openid_configuration.token_endpoint,
|
|
161
249
|
dpop_signing_alg_values_supported: options.openid_configuration.dpop_signing_alg_values_supported
|
|
162
250
|
}
|
|
@@ -184,6 +272,83 @@ function clientInfoMatchesRequest(storedClientInfo, requestedClientInfo) {
|
|
|
184
272
|
return requestedClientInfo.redirect_uris.every(uri => storedRedirectUris.has(uri))
|
|
185
273
|
}
|
|
186
274
|
|
|
275
|
+
/**
|
|
276
|
+
* A WebID identifies the user when available (Solid-OIDC puts it in the
|
|
277
|
+
* webid claim); otherwise login_hint and expected_claims do.
|
|
278
|
+
*/
|
|
279
|
+
function accountFor(options) {
|
|
280
|
+
return options.webid ?? options.login_hint
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function expectedClaimsFor(options) {
|
|
284
|
+
if (options.webid) {
|
|
285
|
+
return Object.assign({ webid: options.webid }, options.expected_claims)
|
|
286
|
+
}
|
|
287
|
+
return options.expected_claims || {}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Solid-OIDC issuers only include the webid claim when the webid scope is
|
|
292
|
+
* requested (Solid-OIDC section 7). It is requested when a WebID is given or
|
|
293
|
+
* the issuer lists it in scopes_supported, which marks a Solid-OIDC issuer.
|
|
294
|
+
*/
|
|
295
|
+
function defaultScope(options) {
|
|
296
|
+
const supported = options.openid_configuration?.scopes_supported
|
|
297
|
+
const solidIssuer = Array.isArray(supported) && supported.includes('webid')
|
|
298
|
+
if (options.webid || solidIssuer) {
|
|
299
|
+
return 'openid webid'
|
|
300
|
+
}
|
|
301
|
+
return 'openid'
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function siteFor(issuer, account) {
|
|
305
|
+
if (!account) {
|
|
306
|
+
return issuer
|
|
307
|
+
}
|
|
308
|
+
return issuer + '|' + account
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Refuses tokens issued for another user than the one requested, e.g. when
|
|
313
|
+
* the issuer still has a login session for a different account.
|
|
314
|
+
*/
|
|
315
|
+
function assertExpectedUser(claims, expectedClaims) {
|
|
316
|
+
for (const [name, expected] of Object.entries(expectedClaims)) {
|
|
317
|
+
const actual = claimValue(claims, name)
|
|
318
|
+
if (actual !== expected) {
|
|
319
|
+
throw metro.metroError('metro.oidcmw: id_token is for a different user: '
|
|
320
|
+
+ 'expected ' + name + ' ' + expected + ', got ' + actual)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function grantTypeOf(req) {
|
|
326
|
+
const body = await req.clone().text()
|
|
327
|
+
return new URLSearchParams(body).get('grant_type')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* An id_token from a refresh has no nonce of its own. It must describe the
|
|
332
|
+
* same login: same issuer (checked by validateIdToken) and same subject.
|
|
333
|
+
*/
|
|
334
|
+
function assertSameUserAsLogin(claims, loginClaims) {
|
|
335
|
+
if (loginClaims && claims.sub !== loginClaims.sub) {
|
|
336
|
+
throw metro.metroError('metro.oidcmw: refreshed id_token is for a different user: '
|
|
337
|
+
+ 'expected sub ' + loginClaims.sub + ', got ' + claims.sub)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Solid-OIDC issuers put the WebID in the webid claim; older issuers only
|
|
343
|
+
* use sub for it.
|
|
344
|
+
*/
|
|
345
|
+
function claimValue(claims, name) {
|
|
346
|
+
if (name == 'webid' && claims.webid === undefined) {
|
|
347
|
+
return claims.sub
|
|
348
|
+
}
|
|
349
|
+
return claims[name]
|
|
350
|
+
}
|
|
351
|
+
|
|
187
352
|
export function isRedirected() {
|
|
188
353
|
return oauth2.isRedirected()
|
|
189
354
|
}
|
|
@@ -193,7 +358,7 @@ export function idToken(options) {
|
|
|
193
358
|
if (!options.issuer) {
|
|
194
359
|
throw metro.metroError('Must supply options.issuer or options.store to get the id_token')
|
|
195
360
|
}
|
|
196
|
-
options.store = oidcStore(options.issuer)
|
|
361
|
+
options.store = oidcStore(siteFor(options.issuer, accountFor(options)))
|
|
197
362
|
}
|
|
198
363
|
return options.store.get('id_token')
|
|
199
364
|
}
|
|
@@ -203,7 +368,7 @@ export function idTokenClaims(options) {
|
|
|
203
368
|
if (!options.issuer) {
|
|
204
369
|
throw metro.metroError('Must supply options.issuer or options.store to get the id_token claims')
|
|
205
370
|
}
|
|
206
|
-
options.store = oidcStore(options.issuer)
|
|
371
|
+
options.store = oidcStore(siteFor(options.issuer, accountFor(options)))
|
|
207
372
|
}
|
|
208
373
|
return options.store.get('id_token_claims')
|
|
209
374
|
}
|