@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/dist/browser.js +374 -111
- 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 +19 -5
- package/package.json +1 -1
- package/src/oidc.discovery.mjs +88 -5
- package/src/oidc.mockserver.mjs +7 -2
- package/src/oidcmw.mjs +199 -35
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
|
|
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
|
}
|
|
@@ -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:
|
|
222
|
+
nonce: isRefresh ? undefined : userStore.get('pending_nonce')
|
|
139
223
|
})
|
|
140
|
-
|
|
141
|
-
|
|
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
|
}
|