@maronn-openid-connect/cli 0.7.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/README.md +83 -7
- package/dist/frameworks/hono/index.d.ts.map +1 -1
- package/dist/frameworks/hono/index.js +18 -9
- package/dist/frameworks/hono/index.js.map +1 -1
- package/dist/frameworks/hono/pages.d.ts +7 -6
- package/dist/frameworks/hono/pages.d.ts.map +1 -1
- package/dist/frameworks/hono/pages.js +62 -44
- package/dist/frameworks/hono/pages.js.map +1 -1
- package/dist/frameworks/hono/templates.d.ts +19 -6
- package/dist/frameworks/hono/templates.d.ts.map +1 -1
- package/dist/frameworks/hono/templates.js +439 -304
- package/dist/frameworks/hono/templates.js.map +1 -1
- package/dist/frameworks/hono/views.d.ts +3 -0
- package/dist/frameworks/hono/views.d.ts.map +1 -0
- package/dist/frameworks/hono/views.js +458 -0
- package/dist/frameworks/hono/views.js.map +1 -0
- package/dist/frameworks/nextjs/authorize.d.ts +3 -0
- package/dist/frameworks/nextjs/authorize.d.ts.map +1 -0
- package/dist/frameworks/nextjs/authorize.js +757 -0
- package/dist/frameworks/nextjs/authorize.js.map +1 -0
- package/dist/frameworks/nextjs/ciba.d.ts +8 -0
- package/dist/frameworks/nextjs/ciba.d.ts.map +1 -0
- package/dist/frameworks/nextjs/ciba.js +591 -0
- package/dist/frameworks/nextjs/ciba.js.map +1 -0
- package/dist/frameworks/nextjs/conformance.d.ts +3 -0
- package/dist/frameworks/nextjs/conformance.d.ts.map +1 -0
- package/dist/frameworks/nextjs/conformance.js +2447 -0
- package/dist/frameworks/nextjs/conformance.js.map +1 -0
- package/dist/frameworks/nextjs/device.d.ts +8 -0
- package/dist/frameworks/nextjs/device.d.ts.map +1 -0
- package/dist/frameworks/nextjs/device.js +662 -0
- package/dist/frameworks/nextjs/device.js.map +1 -0
- package/dist/frameworks/nextjs/endpoints.d.ts +7 -0
- package/dist/frameworks/nextjs/endpoints.d.ts.map +1 -0
- package/dist/frameworks/nextjs/endpoints.js +673 -0
- package/dist/frameworks/nextjs/endpoints.js.map +1 -0
- package/dist/frameworks/nextjs/index.d.ts.map +1 -1
- package/dist/frameworks/nextjs/index.js +135 -2
- package/dist/frameworks/nextjs/index.js.map +1 -1
- package/dist/frameworks/nextjs/interaction.d.ts +13 -0
- package/dist/frameworks/nextjs/interaction.d.ts.map +1 -0
- package/dist/frameworks/nextjs/interaction.js +864 -0
- package/dist/frameworks/nextjs/interaction.js.map +1 -0
- package/dist/frameworks/nextjs/logout.d.ts +5 -0
- package/dist/frameworks/nextjs/logout.d.ts.map +1 -0
- package/dist/frameworks/nextjs/logout.js +271 -0
- package/dist/frameworks/nextjs/logout.js.map +1 -0
- package/dist/frameworks/nextjs/metadata.d.ts +6 -0
- package/dist/frameworks/nextjs/metadata.d.ts.map +1 -0
- package/dist/frameworks/nextjs/metadata.js +339 -0
- package/dist/frameworks/nextjs/metadata.js.map +1 -0
- package/dist/frameworks/nextjs/provider.d.ts +6 -0
- package/dist/frameworks/nextjs/provider.d.ts.map +1 -0
- package/dist/frameworks/nextjs/provider.js +797 -0
- package/dist/frameworks/nextjs/provider.js.map +1 -0
- package/dist/frameworks/nextjs/token.d.ts +7 -0
- package/dist/frameworks/nextjs/token.d.ts.map +1 -0
- package/dist/frameworks/nextjs/token.js +1337 -0
- package/dist/frameworks/nextjs/token.js.map +1 -0
- package/dist/frameworks/web-standard/templates.d.ts +1 -13
- package/dist/frameworks/web-standard/templates.d.ts.map +1 -1
- package/dist/frameworks/web-standard/templates.js +12 -1296
- package/dist/frameworks/web-standard/templates.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1337 @@
|
|
|
1
|
+
import { DEFAULT_FEATURES } from '../../features.js';
|
|
2
|
+
import { EXPERIMENTAL_PACKAGE } from '../hono/templates.js';
|
|
3
|
+
const EXPERIMENTAL_NOTICE = ` * Backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable: it may change in
|
|
4
|
+
* a breaking way between releases. Do not build production code on it without
|
|
5
|
+
* pinning the version.`;
|
|
6
|
+
export function nextJsTokenRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
7
|
+
const refresh = features.refreshToken;
|
|
8
|
+
const experimentalGrants = [
|
|
9
|
+
...(features.tokenExchange ? ['token exchange (token-exchange.ts)'] : []),
|
|
10
|
+
...(features.idJag ? ['ID-JAG issuance and redemption (id-jag.ts)'] : []),
|
|
11
|
+
...(features.deviceAuthorizationGrant ? ['device_code (device-code.ts)'] : []),
|
|
12
|
+
...(features.ciba ? ['CIBA (ciba.ts)'] : []),
|
|
13
|
+
];
|
|
14
|
+
const experimentalGrantDoc = experimentalGrants.length > 0
|
|
15
|
+
? `
|
|
16
|
+
*
|
|
17
|
+
* The experimental grants live in the modules next to this file, each
|
|
18
|
+
* dispatched right after client authentication and before core's
|
|
19
|
+
* validateGrantTypeSupported (which does not know their URNs):
|
|
20
|
+
${experimentalGrants.map((grant) => ` * - ${grant}`).join('\n')}`
|
|
21
|
+
: '';
|
|
22
|
+
const coreImports = [
|
|
23
|
+
'validateGrantTypeSupported',
|
|
24
|
+
'resolveAuthenticatedTokenClient',
|
|
25
|
+
'validateClientGrantType',
|
|
26
|
+
'resolveAuthorizationCode',
|
|
27
|
+
'validateAuthorizationCodeUnused',
|
|
28
|
+
'validateAuthorizationCodeClient',
|
|
29
|
+
'validateAuthorizationCodeExpiration',
|
|
30
|
+
'validateAuthorizationCodeRedirectUri',
|
|
31
|
+
'verifyAuthorizationCodePkce',
|
|
32
|
+
'consumeAuthorizationCode',
|
|
33
|
+
'buildValidatedAuthorizationCodeRequest',
|
|
34
|
+
...(refresh
|
|
35
|
+
? [
|
|
36
|
+
'resolveRefreshToken',
|
|
37
|
+
'validateRefreshTokenUnused',
|
|
38
|
+
'validateRefreshTokenClient',
|
|
39
|
+
'validateRefreshTokenExpiration',
|
|
40
|
+
'validateRefreshTokenIdleTimeout',
|
|
41
|
+
'validateRefreshTokenScope',
|
|
42
|
+
'validateRefreshTokenSession',
|
|
43
|
+
'clientAllowsRefreshTokenGrant',
|
|
44
|
+
'buildValidatedRefreshTokenRequest',
|
|
45
|
+
]
|
|
46
|
+
: []),
|
|
47
|
+
'buildAccessTokenPayload',
|
|
48
|
+
'computeAtHash',
|
|
49
|
+
'resolveAcrAmr',
|
|
50
|
+
'buildIdTokenPayload',
|
|
51
|
+
'generateIdToken',
|
|
52
|
+
...(refresh ? ['generateRandomString'] : []),
|
|
53
|
+
'buildAccessTokenAudience',
|
|
54
|
+
'extractClientCredentials',
|
|
55
|
+
'validateClientAuthMethod',
|
|
56
|
+
'verifyClientSecret',
|
|
57
|
+
'TokenError',
|
|
58
|
+
'TokenErrorCode',
|
|
59
|
+
'type AuthorizationCodeData',
|
|
60
|
+
'type TokenRequestParams',
|
|
61
|
+
'type ValidatedTokenRequest',
|
|
62
|
+
];
|
|
63
|
+
const experimentalImports = [
|
|
64
|
+
...(features.tokenExchange
|
|
65
|
+
? [`import { TOKEN_EXCHANGE_GRANT_TYPE, TokenExchangeError } from '${EXPERIMENTAL_PACKAGE}/token-exchange';`]
|
|
66
|
+
: []),
|
|
67
|
+
...(features.idJag
|
|
68
|
+
? [`import {
|
|
69
|
+
IdJagError,
|
|
70
|
+
JWT_BEARER_GRANT_TYPE,${features.tokenExchange ? '' : '\n ID_JAG_TOKEN_TYPE,\n TOKEN_EXCHANGE_GRANT_TYPE,'}
|
|
71
|
+
matchesIdJagIssuanceRequest,
|
|
72
|
+
} from '${EXPERIMENTAL_PACKAGE}/id-jag';`]
|
|
73
|
+
: []),
|
|
74
|
+
...(features.deviceAuthorizationGrant
|
|
75
|
+
? [`import {
|
|
76
|
+
DEVICE_CODE_GRANT_TYPE,
|
|
77
|
+
DeviceAuthorizationError,
|
|
78
|
+
} from '${EXPERIMENTAL_PACKAGE}/device-authorization-grant';`]
|
|
79
|
+
: []),
|
|
80
|
+
...(features.ciba
|
|
81
|
+
? [`import { CIBA_GRANT_TYPE, CibaGrantError } from '${EXPERIMENTAL_PACKAGE}/ciba';`]
|
|
82
|
+
: []),
|
|
83
|
+
];
|
|
84
|
+
const grantModuleImports = [
|
|
85
|
+
...(features.tokenExchange ? [`import { exchangeToken } from './token-exchange';`] : []),
|
|
86
|
+
...(features.idJag ? [`import { issueIdJag, redeemIdJag } from './id-jag';`] : []),
|
|
87
|
+
...(features.deviceAuthorizationGrant ? [`import { redeemDeviceCode } from './device-code';`] : []),
|
|
88
|
+
...(features.ciba ? [`import { redeemCibaRequest } from './ciba';`] : []),
|
|
89
|
+
];
|
|
90
|
+
const importBlock = [...experimentalImports, ...grantModuleImports];
|
|
91
|
+
const experimentalImportBlock = importBlock.length > 0 ? `\n${importBlock.join('\n')}` : '';
|
|
92
|
+
const experimentalDispatch = [
|
|
93
|
+
...(features.idJag
|
|
94
|
+
? [` // EXPERIMENTAL — ID-JAG issuance (Cross-App Access, draft §4.3): a token
|
|
95
|
+
// exchange whose requested_token_type is the ID-JAG URN. Checked BEFORE the
|
|
96
|
+
// plain token exchange, which shares the grant_type URN.
|
|
97
|
+
if (matchesIdJagIssuanceRequest(params)) {
|
|
98
|
+
return await issueIdJag(params, tokenClient, keys);
|
|
99
|
+
}
|
|
100
|
+
${features.tokenExchange ? '' : `
|
|
101
|
+
// Generated without --enable token-exchange: the exchange grant exists only to
|
|
102
|
+
// issue ID-JAGs, so any other requested_token_type is answered with a pointer
|
|
103
|
+
// instead of falling through to unsupported_grant_type (discovery does
|
|
104
|
+
// advertise the exchange grant in this build).
|
|
105
|
+
if (params.grant_type === TOKEN_EXCHANGE_GRANT_TYPE) {
|
|
106
|
+
return oauthError(
|
|
107
|
+
'invalid_request',
|
|
108
|
+
\`This authorization server only supports requested_token_type \${ID_JAG_TOKEN_TYPE} for token exchange\`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
`}
|
|
112
|
+
// EXPERIMENTAL — ID-JAG redemption on the jwt-bearer grant (draft §4.4).
|
|
113
|
+
if (params.grant_type === JWT_BEARER_GRANT_TYPE) {
|
|
114
|
+
return await redeemIdJag(params, tokenClient, keys);
|
|
115
|
+
}
|
|
116
|
+
`]
|
|
117
|
+
: []),
|
|
118
|
+
...(features.tokenExchange
|
|
119
|
+
? [` // EXPERIMENTAL — OAuth 2.0 Token Exchange (RFC 8693 §2.1).
|
|
120
|
+
if (params.grant_type === TOKEN_EXCHANGE_GRANT_TYPE) {
|
|
121
|
+
return await exchangeToken(params, tokenClient, keys);
|
|
122
|
+
}
|
|
123
|
+
`]
|
|
124
|
+
: []),
|
|
125
|
+
...(features.deviceAuthorizationGrant
|
|
126
|
+
? [` // EXPERIMENTAL — OAuth 2.0 Device Authorization Grant (RFC 8628 §3.4).
|
|
127
|
+
if (params.grant_type === DEVICE_CODE_GRANT_TYPE) {
|
|
128
|
+
return await redeemDeviceCode(params, tokenClient, keys);
|
|
129
|
+
}
|
|
130
|
+
`]
|
|
131
|
+
: []),
|
|
132
|
+
...(features.ciba
|
|
133
|
+
? [` // EXPERIMENTAL — CIBA grant (CIBA Core 1.0 §10.1, poll mode).
|
|
134
|
+
if (params.grant_type === CIBA_GRANT_TYPE) {
|
|
135
|
+
return await redeemCibaRequest(params, tokenClient, keys);
|
|
136
|
+
}
|
|
137
|
+
`]
|
|
138
|
+
: []),
|
|
139
|
+
];
|
|
140
|
+
const experimentalDispatchBlock = experimentalDispatch.length > 0
|
|
141
|
+
? `\n${experimentalDispatch.join('\n')}`
|
|
142
|
+
: '';
|
|
143
|
+
const experimentalErrorClasses = [
|
|
144
|
+
...(features.idJag ? ['IdJagError'] : []),
|
|
145
|
+
...(features.tokenExchange ? ['TokenExchangeError'] : []),
|
|
146
|
+
...(features.deviceAuthorizationGrant ? ['DeviceAuthorizationError'] : []),
|
|
147
|
+
...(features.ciba ? ['CibaGrantError'] : []),
|
|
148
|
+
];
|
|
149
|
+
const experimentalErrorMapping = experimentalErrorClasses.length > 0
|
|
150
|
+
? ` if (
|
|
151
|
+
${experimentalErrorClasses.map((name) => ` error instanceof ${name}`).join(' ||\n')}
|
|
152
|
+
) {
|
|
153
|
+
// The experimental grants answer in the RFC 6749 §5.2 shape and always with
|
|
154
|
+
// 400: a 401 can only come from client authentication, which runs before
|
|
155
|
+
// them and throws core's TokenError.
|
|
156
|
+
return oauthError(error.code, error.errorDescription, error.statusCode);
|
|
157
|
+
}
|
|
158
|
+
`
|
|
159
|
+
: '';
|
|
160
|
+
const resolverNames = [
|
|
161
|
+
'authorizationCodeResolver',
|
|
162
|
+
...(refresh ? ['refreshTokenResolver', 'authenticationSessionResolver'] : []),
|
|
163
|
+
];
|
|
164
|
+
const storeNames = ['authCodeStore', 'accessTokenStore', ...(refresh ? ['refreshTokenStore'] : [])];
|
|
165
|
+
const grantTypeSupportedStep = refresh
|
|
166
|
+
? ` // RFC 6749 §5.2: is the grant_type offered by this OP at all?
|
|
167
|
+
// (defaults to ['authorization_code', 'refresh_token'])
|
|
168
|
+
const grantType = validateGrantTypeSupported(params.grant_type);
|
|
169
|
+
`
|
|
170
|
+
: ` // The refresh_token feature is disabled: the OP only offers the
|
|
171
|
+
// authorization_code grant, so refresh_token requests are rejected with
|
|
172
|
+
// unsupported_grant_type (RFC 6749 §5.2).
|
|
173
|
+
const grantType = validateGrantTypeSupported(params.grant_type, ['authorization_code']);
|
|
174
|
+
`;
|
|
175
|
+
const grantValidationStep = refresh
|
|
176
|
+
? ` // Grant-specific validation. Each security rule is a separate core call so
|
|
177
|
+
// it can be removed, replaced, or surrounded with experiment-specific logic.
|
|
178
|
+
let validatedRequest: ValidatedTokenRequest;
|
|
179
|
+
if (grantType === 'refresh_token') {
|
|
180
|
+
// Resolve the presented refresh token and retain its stored grant context.
|
|
181
|
+
const { refreshTokenInfo } = await resolveRefreshToken(params, refreshTokenResolver);
|
|
182
|
+
|
|
183
|
+
// OAuth 2.1 §4.3.1: reject rotation reuse and revoke the token family.
|
|
184
|
+
await validateRefreshTokenUnused(refreshTokenInfo, refreshTokenResolver);
|
|
185
|
+
|
|
186
|
+
// Bind the refresh token to the authenticated client.
|
|
187
|
+
validateRefreshTokenClient(refreshTokenInfo, authenticatedClientId);
|
|
188
|
+
|
|
189
|
+
// Absolute lifetime: expiresAt <= now is expired.
|
|
190
|
+
validateRefreshTokenExpiration(refreshTokenInfo);
|
|
191
|
+
|
|
192
|
+
// Optional inactivity policy. Replace undefined with your timeout in seconds
|
|
193
|
+
// to enable it, or remove this step if your experiment has no idle lifetime.
|
|
194
|
+
validateRefreshTokenIdleTimeout(refreshTokenInfo, undefined);
|
|
195
|
+
|
|
196
|
+
// online refresh token(sessionId を持つ RT)は、束縛先のログインセッションが
|
|
197
|
+
// 生きている間だけ使える。ログアウト・別ユーザーでの再ログインでセッションが
|
|
198
|
+
// 消えれば invalid_grant になる。offline_access が付与された RT は sessionId を
|
|
199
|
+
// 持たないため、このステップを素通りしてログアウト後も使い続けられる。
|
|
200
|
+
await validateRefreshTokenSession(refreshTokenInfo, authenticationSessionResolver);
|
|
201
|
+
|
|
202
|
+
// RFC 6749 §6: requested scope may only narrow the original grant.
|
|
203
|
+
const effectiveScope = validateRefreshTokenScope(params.scope, refreshTokenInfo.scope);
|
|
204
|
+
|
|
205
|
+
validatedRequest = buildValidatedRefreshTokenRequest(
|
|
206
|
+
refreshTokenInfo,
|
|
207
|
+
authenticatedClientId,
|
|
208
|
+
effectiveScope,
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
// Resolve the presented authorization code and retain the non-optional code.
|
|
212
|
+
const { code, authorizationCode } = await resolveAuthorizationCode(
|
|
213
|
+
params,
|
|
214
|
+
authorizationCodeResolver,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// OAuth 2.1 §4.1.2: reject reuse and revoke tokens from the compromised grant.
|
|
218
|
+
await validateAuthorizationCodeUnused(authorizationCode, authorizationCodeResolver);
|
|
219
|
+
|
|
220
|
+
// Bind the authorization code to the authenticated client and its lifetime.
|
|
221
|
+
validateAuthorizationCodeClient(authorizationCode, authenticatedClientId);
|
|
222
|
+
validateAuthorizationCodeExpiration(authorizationCode);
|
|
223
|
+
|
|
224
|
+
// OIDC Core 1.0 §3.1.3.2: bind the token request redirect_uri.
|
|
225
|
+
validateAuthorizationCodeRedirectUri(authorizationCode, params.redirect_uri);
|
|
226
|
+
|
|
227
|
+
// RFC 7636: validate the S256 verifier when the code carries a PKCE binding.
|
|
228
|
+
const codeVerified = await verifyAuthorizationCodePkce(
|
|
229
|
+
authorizationCode,
|
|
230
|
+
params.code_verifier,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Mark used (do not physically delete) so a later replay remains detectable.
|
|
234
|
+
await consumeAuthorizationCode(code, authorizationCodeResolver);
|
|
235
|
+
|
|
236
|
+
validatedRequest = buildValidatedAuthorizationCodeRequest(
|
|
237
|
+
code,
|
|
238
|
+
authorizationCode,
|
|
239
|
+
authenticatedClientId,
|
|
240
|
+
codeVerified,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
`
|
|
244
|
+
: ` // Grant-specific validation (authorization_code only in this configuration).
|
|
245
|
+
// Every security rule remains an independent customization point.
|
|
246
|
+
const { code, authorizationCode } = await resolveAuthorizationCode(
|
|
247
|
+
params,
|
|
248
|
+
authorizationCodeResolver,
|
|
249
|
+
);
|
|
250
|
+
await validateAuthorizationCodeUnused(authorizationCode, authorizationCodeResolver);
|
|
251
|
+
validateAuthorizationCodeClient(authorizationCode, authenticatedClientId);
|
|
252
|
+
validateAuthorizationCodeExpiration(authorizationCode);
|
|
253
|
+
validateAuthorizationCodeRedirectUri(authorizationCode, params.redirect_uri);
|
|
254
|
+
const codeVerified = await verifyAuthorizationCodePkce(
|
|
255
|
+
authorizationCode,
|
|
256
|
+
params.code_verifier,
|
|
257
|
+
);
|
|
258
|
+
await consumeAuthorizationCode(code, authorizationCodeResolver);
|
|
259
|
+
// The cast widens the result back to the ValidatedTokenRequest union: TypeScript
|
|
260
|
+
// narrows a const to its initializer type, which would make the shared
|
|
261
|
+
// refresh_token branches below unreachable (never) even though they compile.
|
|
262
|
+
const validatedRequest = buildValidatedAuthorizationCodeRequest(
|
|
263
|
+
code,
|
|
264
|
+
authorizationCode,
|
|
265
|
+
authenticatedClientId,
|
|
266
|
+
codeVerified,
|
|
267
|
+
) as ValidatedTokenRequest;
|
|
268
|
+
`;
|
|
269
|
+
const refreshIssuanceDecision = refresh
|
|
270
|
+
? ` // --- Refresh Token を発行するかの判定 -------------------------------------
|
|
271
|
+
//
|
|
272
|
+
// RFC 7591 §2 / OIDC Dynamic Client Registration 1.0 §2: grant_types の既定は
|
|
273
|
+
// ["authorization_code"]。refresh_token を登録していないクライアントへ RT を渡しても、
|
|
274
|
+
// 次に grant_type=refresh_token を出した瞬間 validateClientGrantType が
|
|
275
|
+
// unauthorized_client で拒否する。一度も使えない長期資格情報を保存させるだけなので
|
|
276
|
+
// (RFC 9700 §4.14)、登録が無ければ発行しない。
|
|
277
|
+
const clientAllowsRefreshGrant = clientAllowsRefreshTokenGrant(tokenClient);
|
|
278
|
+
|
|
279
|
+
// RFC 6749 §6 / OIDC Core 1.0 §11: refresh 時の scope 縮小は当該リクエストの access token /
|
|
280
|
+
// ID Token の権限縮小として扱い、refresh token rotation の可否とは切り離す。rotation 可否は
|
|
281
|
+
// 「元の grant が offline_access を持っていたか」で判断する。
|
|
282
|
+
// - authorization_code grant: 今回付与された scope に offline_access があるか。
|
|
283
|
+
// - refresh_token grant: 元 refresh token の grant が offline_access を持っていたか
|
|
284
|
+
// (validatedRequest.hadOfflineAccess)。縮小後 scope から offline_access を落としても
|
|
285
|
+
// 元 grant の権限は失われないため rotation を継続する。
|
|
286
|
+
const grantHasOfflineAccess =
|
|
287
|
+
clientAllowsRefreshGrant &&
|
|
288
|
+
(validatedRequest.grantType === 'refresh_token'
|
|
289
|
+
? validatedRequest.hadOfflineAccess
|
|
290
|
+
: validatedRequest.scope.includes('offline_access'));
|
|
291
|
+
|
|
292
|
+
// online refresh token の束縛先セッション。
|
|
293
|
+
// OIDC Core 1.0 §11 は offline_access を「End-User が居ない(not logged in)ときにも
|
|
294
|
+
// 使える Refresh Token」と定義したうえで、Refresh Token の利用がその用途に限られない
|
|
295
|
+
// ことも明示している("The Authorization Server MAY grant Refresh Tokens in other
|
|
296
|
+
// contexts")。この OP はその other contexts を online refresh token として実装し、
|
|
297
|
+
// ログインセッションへ束縛する。offline_access がある grant は束縛しない。
|
|
298
|
+
// - authorization_code grant: 認可コードが持つ sessionId(ログイン時に確立したもの)。
|
|
299
|
+
// - refresh_token grant: 元 RT の束縛をそのまま引き継ぎ、rotation で外れないようにする。
|
|
300
|
+
const boundSessionId = grantHasOfflineAccess ? undefined : validatedRequest.sessionId;
|
|
301
|
+
|
|
302
|
+
// 束縛先が分からなければ online refresh token は発行しない。ブラウザセッションを
|
|
303
|
+
// 持たない経路(device authorization grant)が該当する。ログアウトで止まる保証を
|
|
304
|
+
// 付けられない RT を配らないための fail-closed。
|
|
305
|
+
const issueRefreshToken =
|
|
306
|
+
clientAllowsRefreshGrant &&
|
|
307
|
+
(grantHasOfflineAccess ||
|
|
308
|
+
(config.onlineRefreshTokenEnabled && boundSessionId !== undefined));
|
|
309
|
+
|
|
310
|
+
`
|
|
311
|
+
: '';
|
|
312
|
+
const refreshTokenValue = refresh
|
|
313
|
+
? 'issueRefreshToken ? generateRandomString(32) : undefined'
|
|
314
|
+
: 'undefined /* the refresh_token feature is disabled: never issue one */';
|
|
315
|
+
const refreshTokenPersistence = refresh
|
|
316
|
+
? ` // Store the new refresh token for rotation (OAuth 2.1 §4.3.1). grantId /
|
|
317
|
+
// audience / authTime / nonce / acr / amr / azp propagate through rotations,
|
|
318
|
+
// so descendants can be revoked on code reuse, the audience never expands,
|
|
319
|
+
// and refresh で再発行する ID Token は OIDC Core 1.0 §12.1 に従い初回認証時の値を保持する。
|
|
320
|
+
if (tokenResponse.refresh_token) {
|
|
321
|
+
// authTime はここで必ず確定する: authorization_code 経由は authCode.authTime、
|
|
322
|
+
// refresh_token 経由は validatedRequest.authTime(前段で代入済み)。
|
|
323
|
+
const rtAuthTime = authTime;
|
|
324
|
+
if (rtAuthTime === undefined) {
|
|
325
|
+
throw new TokenError(
|
|
326
|
+
TokenErrorCode.InvalidGrant,
|
|
327
|
+
'authTime is required to issue a refresh token',
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
// OAuth 2.1 §6.1: refresh token は initial issuance からの absolute lifetime のみで失効する。
|
|
331
|
+
// rotation を跨いで originalIssuedAt を引き継ぎ、expiresAt はそこからの絶対的な期限で固定する。
|
|
332
|
+
// sliding expiry は持たないため、リフレッシュを繰り返しても失効時刻は前に進まず、
|
|
333
|
+
// 漏洩 RT の長期 abuse を防ぐ。
|
|
334
|
+
const originalIssuedAt =
|
|
335
|
+
validatedRequest.grantType === 'refresh_token'
|
|
336
|
+
? validatedRequest.originalIssuedAt
|
|
337
|
+
: issuedAt;
|
|
338
|
+
// RFC 6749 §6: 縮小後 scope(validatedRequest.scope)から offline_access が落ちても、
|
|
339
|
+
// grant が offline_access を持つ限り次回以降の rotation を継続できるよう、永続化する
|
|
340
|
+
// refresh token の scope には offline_access を保持する。access token は
|
|
341
|
+
// validatedRequest.scope をそのまま使うため、当該リクエストの権限は縮小されたままになる。
|
|
342
|
+
const refreshTokenScope =
|
|
343
|
+
grantHasOfflineAccess && !validatedRequest.scope.includes('offline_access')
|
|
344
|
+
? [...validatedRequest.scope, 'offline_access']
|
|
345
|
+
: validatedRequest.scope;
|
|
346
|
+
await refreshTokenStore.set(tokenResponse.refresh_token, {
|
|
347
|
+
subject,
|
|
348
|
+
clientId: validatedRequest.clientId,
|
|
349
|
+
scope: refreshTokenScope,
|
|
350
|
+
expiresAt: originalIssuedAt + config.refreshTokenAbsoluteLifetime,
|
|
351
|
+
originalIssuedAt,
|
|
352
|
+
used: false,
|
|
353
|
+
grantId: validatedRequest.grantId,
|
|
354
|
+
iat: issuedAt,
|
|
355
|
+
issuer: config.issuer,
|
|
356
|
+
audience: effectiveAudience,
|
|
357
|
+
authTime: rtAuthTime,
|
|
358
|
+
nonce,
|
|
359
|
+
// OIDC Core 1.0 §12.1: refresh で再発行する ID Token は初回認証時の acr / amr を保持する。
|
|
360
|
+
acr: validatedRequest.grantType === 'refresh_token' ? validatedRequest.acr : resolvedAcr,
|
|
361
|
+
amr: validatedRequest.grantType === 'refresh_token' ? validatedRequest.amr : resolvedAmr,
|
|
362
|
+
azp: validatedRequest.grantType === 'refresh_token' ? validatedRequest.azp : undefined,
|
|
363
|
+
// online refresh token の束縛。undefined なら offline refresh token として
|
|
364
|
+
// セッションから独立し、ログアウト後も使える。
|
|
365
|
+
sessionId: boundSessionId,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// OAuth 2.1 §4.3.1: ローテーションは新トークン保存成功後に旧 RT を失効する。
|
|
370
|
+
// 失敗時にユーザーがリフレッシュ不能になることを防ぐため、必ずこの順序にする。
|
|
371
|
+
if (validatedRequest.grantType === 'refresh_token' && params.refresh_token) {
|
|
372
|
+
await refreshTokenResolver.revokeRefreshToken(params.refresh_token);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
`
|
|
376
|
+
: '';
|
|
377
|
+
return `/**
|
|
378
|
+
* Token Endpoint (OIDC Core 1.0 §3.1.3).
|
|
379
|
+
*
|
|
380
|
+
* Authenticates the client, validates the grant it presents and answers with
|
|
381
|
+
* the token response: authorization_code and refresh_token here.${experimentalGrantDoc}
|
|
382
|
+
*/
|
|
383
|
+
import {
|
|
384
|
+
${coreImports.map((name) => ` ${name},`).join('\n')}
|
|
385
|
+
} from '${corePkg}';${experimentalImportBlock}
|
|
386
|
+
import type { RegisteredClient } from '../_oidc-provider/config';
|
|
387
|
+
import {
|
|
388
|
+
accessTokenIssuer,
|
|
389
|
+
acrResolver,
|
|
390
|
+
clientResolver,
|
|
391
|
+
config,
|
|
392
|
+
loadSigningKeys,
|
|
393
|
+
resolvers,
|
|
394
|
+
selectIdTokenSigningKey,
|
|
395
|
+
stores,
|
|
396
|
+
} from '../_oidc-provider/provider';
|
|
397
|
+
import {
|
|
398
|
+
clientCors,
|
|
399
|
+
corsPreflight,
|
|
400
|
+
isFormUrlEncoded,
|
|
401
|
+
noStoreJson,
|
|
402
|
+
oauthError,
|
|
403
|
+
signingKeysUnavailable,
|
|
404
|
+
uniqueParams,
|
|
405
|
+
withCors,
|
|
406
|
+
} from '../_oidc-provider/http';
|
|
407
|
+
|
|
408
|
+
export const dynamic = 'force-dynamic';
|
|
409
|
+
export const runtime = 'nodejs';
|
|
410
|
+
|
|
411
|
+
const { ${resolverNames.join(', ')} } = resolvers;
|
|
412
|
+
const { ${storeNames.join(', ')} } = stores;
|
|
413
|
+
|
|
414
|
+
export async function POST(request: Request): Promise<Response> {
|
|
415
|
+
return withCors(request, clientCors, await token(request));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function OPTIONS(request: Request): Response {
|
|
419
|
+
return corsPreflight(request, clientCors);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function token(request: Request): Promise<Response> {
|
|
423
|
+
const keys = await loadSigningKeys().catch(() => null);
|
|
424
|
+
if (!keys) return signingKeysUnavailable();
|
|
425
|
+
|
|
426
|
+
// RFC 6749 §4.1.3 / OIDC Core 1.0 §3.1.3.1: the Token Request body MUST be
|
|
427
|
+
// application/x-www-form-urlencoded; anything else is rejected unparsed.
|
|
428
|
+
if (!isFormUrlEncoded(request)) {
|
|
429
|
+
return oauthError('invalid_request', 'Token requests must use application/x-www-form-urlencoded');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// RFC 6749 §3.2: token endpoint request parameters MUST NOT be repeated.
|
|
433
|
+
const { params: rawParams, duplicateKey } = uniqueParams(new URLSearchParams(await request.text()));
|
|
434
|
+
if (duplicateKey !== undefined) {
|
|
435
|
+
return oauthError('invalid_request', \`Parameter "\${duplicateKey}" must not be repeated\`);
|
|
436
|
+
}
|
|
437
|
+
if (!isTokenRequestParams(rawParams)) {
|
|
438
|
+
return oauthError('invalid_request', 'Missing required parameter: grant_type');
|
|
439
|
+
}
|
|
440
|
+
const params = rawParams;
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
// --- Client authentication pipeline -------------------------------------
|
|
444
|
+
// OAuth 2.1 §2.3 / OIDC Core 1.0 §9: client_secret_basic / client_secret_post.
|
|
445
|
+
// Each step below is an independent core function, called in the same order
|
|
446
|
+
// as core's authenticateClient(). Replace verifyClientSecret with your own
|
|
447
|
+
// assertion check (e.g. private_key_jwt) without touching the rest.
|
|
448
|
+
|
|
449
|
+
// Read the presented credentials and which method was actually used.
|
|
450
|
+
const presentedCredentials = extractClientCredentials({
|
|
451
|
+
params,
|
|
452
|
+
authorizationHeader: request.headers.get('Authorization') ?? '',
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// RFC 6749 §5.2: the presented client_id must resolve to a registered client.
|
|
456
|
+
const tokenClient = await resolveAuthenticatedTokenClient(
|
|
457
|
+
presentedCredentials.clientId,
|
|
458
|
+
clientResolver,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
// OIDC Core 1.0 §9: the method used must match the registered
|
|
462
|
+
// token_endpoint_auth_method (blocks auth method downgrade / public-client mixups).
|
|
463
|
+
validateClientAuthMethod(tokenClient, presentedCredentials);
|
|
464
|
+
|
|
465
|
+
// OAuth 2.1 §7.4.1: constant-time client_secret comparison.
|
|
466
|
+
await verifyClientSecret(tokenClient, presentedCredentials.clientSecret);
|
|
467
|
+
|
|
468
|
+
const authenticatedClientId = presentedCredentials.clientId;
|
|
469
|
+
${experimentalDispatchBlock}
|
|
470
|
+
// --- Token request validation pipeline --------------------------------
|
|
471
|
+
// Each step below is an independent core function, called in the same order
|
|
472
|
+
// as core's validateTokenRequest(). Delete a call to drop that validation,
|
|
473
|
+
// or insert your own logic between steps.
|
|
474
|
+
|
|
475
|
+
${grantTypeSupportedStep}
|
|
476
|
+
// RFC 6749 §5.2: per-client grant_type authorization (unauthorized_client).
|
|
477
|
+
validateClientGrantType(tokenClient, grantType);
|
|
478
|
+
|
|
479
|
+
${grantValidationStep}
|
|
480
|
+
// T-022: the ID Token is signed with the registered ID Token key whose alg
|
|
481
|
+
// matches the client's id_token_signed_response_alg (RS256 by default).
|
|
482
|
+
const idTokenAlg = (tokenClient as RegisteredClient).idTokenSignedResponseAlg;
|
|
483
|
+
const idTokenKey = selectIdTokenSigningKey(keys, idTokenAlg);
|
|
484
|
+
if (!idTokenKey) {
|
|
485
|
+
return oauthError(
|
|
486
|
+
'server_error',
|
|
487
|
+
\`No ID Token signing key registered for alg "\${idTokenAlg ?? 'RS256'}"\`,
|
|
488
|
+
500,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let subject: string;
|
|
493
|
+
let authTime: number | undefined;
|
|
494
|
+
let nonce: string | undefined;
|
|
495
|
+
|
|
496
|
+
if (validatedRequest.grantType === 'authorization_code') {
|
|
497
|
+
// The store holds what the consent step stored: createAuthorizationCode()'s
|
|
498
|
+
// record, which also carries the authenticated subject and auth_time.
|
|
499
|
+
const authCode = (await authCodeStore.get(validatedRequest.code)) as
|
|
500
|
+
| AuthorizationCodeData
|
|
501
|
+
| undefined;
|
|
502
|
+
if (!authCode?.subject || !authCode.authTime) {
|
|
503
|
+
throw new TokenError(
|
|
504
|
+
TokenErrorCode.InvalidGrant,
|
|
505
|
+
'Authorization code missing required subject context',
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
subject = authCode.subject;
|
|
509
|
+
authTime = authCode.authTime;
|
|
510
|
+
nonce = validatedRequest.nonce;
|
|
511
|
+
} else {
|
|
512
|
+
// refresh_token grant.
|
|
513
|
+
// OIDC Core 1.0 §12.2: the re-issued ID Token retains iss/sub/aud/exp/iat/
|
|
514
|
+
// auth_time/azp/acr/amr — nonce is NOT in that list. nonce binds an
|
|
515
|
+
// Authentication Request to its ID Token (§2); a refresh has no such
|
|
516
|
+
// request, so carrying the old nonce adds no replay protection. Major OPs
|
|
517
|
+
// (Google, Auth0) omit it on refresh, and so does this one. auth_time is
|
|
518
|
+
// still preserved per §12.1.
|
|
519
|
+
subject = validatedRequest.subject;
|
|
520
|
+
authTime = validatedRequest.authTime;
|
|
521
|
+
nonce = undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// アクセストークンの audience を決定する(合成ポリシーは core の buildAccessTokenAudience に集約)。
|
|
525
|
+
// RFC 9068 §3: JWT access token の aud は非空でなければならない。
|
|
526
|
+
// このアクセストークンは常に OP 自身の UserInfo エンドポイントで使用できるため、UserInfo
|
|
527
|
+
// エンドポイント(discovery が広告する userinfo_endpoint と同じ URL)を aud の恒久メンバとして
|
|
528
|
+
// 必ず含める。resource 指定(validatedRequest.audience)があれば末尾に追加し、UserInfo
|
|
529
|
+
// エンドポイントを取り除くことはしない。重複は除去される。
|
|
530
|
+
// refresh では保存済み aud(既に UserInfo を含む)を引き継ぐため、再計算しても同一集合になる。
|
|
531
|
+
const effectiveAudience = buildAccessTokenAudience({
|
|
532
|
+
userInfoEndpoint: \`\${config.issuer}/userinfo\`,
|
|
533
|
+
requested: validatedRequest.audience,
|
|
534
|
+
issuer: config.issuer,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
${refreshIssuanceDecision} // --- Token response pipeline --------------------------------------------
|
|
538
|
+
// Each step below is an independent core function, called in the same order
|
|
539
|
+
// as core's generateTokenResponse(). Add your own ID Token claims by editing
|
|
540
|
+
// idTokenPayload before it is signed.
|
|
541
|
+
|
|
542
|
+
// One timestamp for the whole response so the issued tokens and the stored
|
|
543
|
+
// token metadata agree on iat / exp.
|
|
544
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
545
|
+
|
|
546
|
+
// RFC 9068 §2.2: iss / sub / aud / exp / iat / scope / client_id.
|
|
547
|
+
// Add access token claims here before the payload is signed.
|
|
548
|
+
const accessTokenPayload = buildAccessTokenPayload({
|
|
549
|
+
issuer: config.issuer,
|
|
550
|
+
subject,
|
|
551
|
+
clientId: validatedRequest.clientId,
|
|
552
|
+
scope: validatedRequest.scope,
|
|
553
|
+
audience: effectiveAudience,
|
|
554
|
+
expiresIn: config.accessTokenExpiresIn,
|
|
555
|
+
issuedAt,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// JWT or opaque, as chosen by config.accessTokenFormat (provider.ts).
|
|
559
|
+
const accessToken = await accessTokenIssuer.issue({
|
|
560
|
+
payload: accessTokenPayload,
|
|
561
|
+
privateKey: keys.general.active.privateKey,
|
|
562
|
+
keyId: keys.general.active.keyId,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// OIDC Core 1.0 §12: refresh_token grant でも id_token は MAY。
|
|
566
|
+
// openid scope を持つ場合は §12.1 に従い初回認証時と同じ auth_time / acr / amr / azp で再発行する。
|
|
567
|
+
// (§12.2 は nonce を再発行 ID Token の保持クレームに挙げないため nonce は refresh では undefined)
|
|
568
|
+
let idToken: string | undefined;
|
|
569
|
+
let resolvedAcr: string | undefined = undefined;
|
|
570
|
+
let resolvedAmr: string[] | undefined = undefined;
|
|
571
|
+
if (validatedRequest.scope.includes('openid')) {
|
|
572
|
+
// OIDC Core 1.0 §3.1.3.6: at_hash binds the ID Token to this access token.
|
|
573
|
+
// The hash function follows the ID Token signing alg.
|
|
574
|
+
const atHash = await computeAtHash(accessToken, idTokenKey.privateKey);
|
|
575
|
+
|
|
576
|
+
// T-015: acr / amr.
|
|
577
|
+
// - authorization_code: ask acrResolver (provider.ts); acr_values and the
|
|
578
|
+
// claims request are forwarded so it can honor them.
|
|
579
|
+
// - refresh_token: the stored acr / amr are reused so OIDC Core 1.0 §12.1
|
|
580
|
+
// "preserve initial auth context" holds; the resolver is bypassed.
|
|
581
|
+
({ acr: resolvedAcr, amr: resolvedAmr } = await resolveAcrAmr({
|
|
582
|
+
subject,
|
|
583
|
+
clientId: validatedRequest.clientId,
|
|
584
|
+
acr: validatedRequest.grantType === 'refresh_token' ? validatedRequest.acr : undefined,
|
|
585
|
+
amr: validatedRequest.grantType === 'refresh_token' ? validatedRequest.amr : undefined,
|
|
586
|
+
acrResolver: validatedRequest.grantType === 'authorization_code' ? acrResolver : undefined,
|
|
587
|
+
requestedAcrValues:
|
|
588
|
+
validatedRequest.grantType === 'authorization_code' ? validatedRequest.acrValues : undefined,
|
|
589
|
+
// OIDC Core 1.0 §5.5: the parsed claims request lets the resolver satisfy
|
|
590
|
+
// id_token member requests (e.g. acr.values).
|
|
591
|
+
claims: validatedRequest.grantType === 'authorization_code' ? validatedRequest.claims : undefined,
|
|
592
|
+
}));
|
|
593
|
+
|
|
594
|
+
const idTokenPayload = buildIdTokenPayload({
|
|
595
|
+
issuer: config.issuer,
|
|
596
|
+
subject,
|
|
597
|
+
clientId: validatedRequest.clientId,
|
|
598
|
+
scope: validatedRequest.scope,
|
|
599
|
+
expiresIn: config.idTokenExpiresIn,
|
|
600
|
+
issuedAt,
|
|
601
|
+
atHash,
|
|
602
|
+
nonce,
|
|
603
|
+
authTime,
|
|
604
|
+
acr: resolvedAcr,
|
|
605
|
+
amr: resolvedAmr,
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// Add your own ID Token claims here, e.g.:
|
|
609
|
+
// idTokenPayload.tenant_id = await lookupTenant(subject);
|
|
610
|
+
|
|
611
|
+
idToken = await generateIdToken({
|
|
612
|
+
payload: idTokenPayload,
|
|
613
|
+
privateKey: idTokenKey.privateKey,
|
|
614
|
+
keyId: idTokenKey.keyId,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// OIDC Core 1.0 §3.1.3.3 / RFC 6749 §5.1: the token response body.
|
|
619
|
+
const tokenResponse = {
|
|
620
|
+
access_token: accessToken,
|
|
621
|
+
token_type: 'Bearer' as const,
|
|
622
|
+
expires_in: config.accessTokenExpiresIn,
|
|
623
|
+
id_token: idToken,
|
|
624
|
+
scope: validatedRequest.scope.join(' '),
|
|
625
|
+
refresh_token: ${refreshTokenValue},
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// Store the access token for the UserInfo / Introspection / Revocation
|
|
629
|
+
// endpoints. iat / nbf / audience / issuer are kept so RFC 7662
|
|
630
|
+
// introspection can echo them; grantId ties the token to its authorization
|
|
631
|
+
// grant so it is revoked with its siblings on code reuse (OAuth 2.1 §4.1.2).
|
|
632
|
+
await accessTokenStore.set(tokenResponse.access_token, {
|
|
633
|
+
sub: subject,
|
|
634
|
+
clientId: validatedRequest.clientId,
|
|
635
|
+
scope: validatedRequest.scope,
|
|
636
|
+
expiresAt: issuedAt + config.accessTokenExpiresIn,
|
|
637
|
+
grantId: validatedRequest.grantId,
|
|
638
|
+
iat: issuedAt,
|
|
639
|
+
// RFC 7519 §4.1.5 / RFC 7662 §2.2: persist nbf (= iat) for JWT and opaque
|
|
640
|
+
// tokens alike so introspection reports a not-yet-valid token inactive.
|
|
641
|
+
nbf: issuedAt,
|
|
642
|
+
audience: effectiveAudience,
|
|
643
|
+
issuer: config.issuer,
|
|
644
|
+
// RFC 9068 §2.2 / RFC 7662 §2.2: the token identifier core minted for this
|
|
645
|
+
// issuance. It also keeps two same-second issuances distinct (RS256 is
|
|
646
|
+
// deterministic), so this store key never collides across grants.
|
|
647
|
+
jti: accessTokenPayload.jti,
|
|
648
|
+
// OIDC Core 1.0 §5.5: the authorization request's claims parameter, so the
|
|
649
|
+
// UserInfo endpoint can honor claims.userinfo members independently of scope.
|
|
650
|
+
claims: validatedRequest.grantType === 'authorization_code' ? validatedRequest.claims : undefined,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
${refreshTokenPersistence} return noStoreJson(tokenResponse);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
${experimentalErrorMapping} if (error instanceof TokenError) {
|
|
656
|
+
// RFC 6750 §3 / OAuth 2.1 §5.2: a 401 carries WWW-Authenticate.
|
|
657
|
+
return oauthError(
|
|
658
|
+
error.error,
|
|
659
|
+
error.errorDescription,
|
|
660
|
+
error.statusCode,
|
|
661
|
+
error.wwwAuthenticate ? { 'WWW-Authenticate': error.wwwAuthenticate } : undefined,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
return oauthError('server_error', undefined, 500);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Narrows raw body params to TokenRequestParams: grant_type is required. */
|
|
669
|
+
function isTokenRequestParams(params: unknown): params is TokenRequestParams {
|
|
670
|
+
if (typeof params !== 'object' || params === null) return false;
|
|
671
|
+
return typeof (params as Record<string, unknown>)['grant_type'] === 'string';
|
|
672
|
+
}
|
|
673
|
+
`;
|
|
674
|
+
}
|
|
675
|
+
export function nextJsTokenExchangeGrantTemplate(corePkg) {
|
|
676
|
+
return `/**
|
|
677
|
+
* EXPERIMENTAL — OAuth 2.0 Token Exchange (RFC 8693 §2.1).
|
|
678
|
+
*
|
|
679
|
+
* Generated because the OP was created with \`--enable token-exchange\`.
|
|
680
|
+
${EXPERIMENTAL_NOTICE}
|
|
681
|
+
*
|
|
682
|
+
* Known limitation: RFC 8693 §2.1 permits repeated \`resource\` / \`audience\`
|
|
683
|
+
* parameters, but the token endpoint rejects any repeated parameter (RFC 6749
|
|
684
|
+
* §3.2), so only a single value of each is supported.
|
|
685
|
+
*/
|
|
686
|
+
import {
|
|
687
|
+
buildAccessTokenAudience,
|
|
688
|
+
buildAccessTokenPayload,
|
|
689
|
+
type TokenClientInfo,
|
|
690
|
+
} from '${corePkg}';
|
|
691
|
+
import {
|
|
692
|
+
buildTokenExchangeResponse,
|
|
693
|
+
processTokenExchangeRequest,
|
|
694
|
+
type ExchangedAccessTokenInfo,
|
|
695
|
+
} from '${EXPERIMENTAL_PACKAGE}/token-exchange';
|
|
696
|
+
import {
|
|
697
|
+
accessTokenIssuer,
|
|
698
|
+
config,
|
|
699
|
+
resolvers,
|
|
700
|
+
stores,
|
|
701
|
+
type ProviderSigningKeys,
|
|
702
|
+
} from '../_oidc-provider/provider';
|
|
703
|
+
import { noStoreJson } from '../_oidc-provider/http';
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Token Exchange settings.
|
|
707
|
+
*
|
|
708
|
+
* - allowedTargets: the audience / resource values a client may ask an
|
|
709
|
+
* exchanged token to be issued for. Empty by default (fail safe): with an
|
|
710
|
+
* empty list every exchange that names a target is rejected with
|
|
711
|
+
* invalid_target, and only scope-narrowing / lifetime-shortening exchanges
|
|
712
|
+
* succeed. Add the identifiers of your downstream services here.
|
|
713
|
+
*/
|
|
714
|
+
export const tokenExchangeConfig = {
|
|
715
|
+
allowedTargets: [] as string[],
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Exchange the subject_token for a new access token. Errors are thrown as
|
|
720
|
+
* TokenExchangeError and answered by the token route.
|
|
721
|
+
*/
|
|
722
|
+
export async function exchangeToken(
|
|
723
|
+
params: Record<string, string>,
|
|
724
|
+
client: TokenClientInfo,
|
|
725
|
+
keys: ProviderSigningKeys,
|
|
726
|
+
): Promise<Response> {
|
|
727
|
+
// Validate the request and derive the issuing material. Each check inside is
|
|
728
|
+
// also exported as its own step function, so you can call them one by one
|
|
729
|
+
// instead and drop or replace individual rules.
|
|
730
|
+
const grant = await processTokenExchangeRequest({
|
|
731
|
+
params,
|
|
732
|
+
client,
|
|
733
|
+
accessTokenResolver: resolvers.accessTokenResolver,
|
|
734
|
+
allowedTargets: tokenExchangeConfig.allowedTargets,
|
|
735
|
+
configuredExpiresIn: config.accessTokenExpiresIn,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// Same aud policy as the standard grants: the UserInfo endpoint stays a
|
|
739
|
+
// permanent member (RFC 9068 §3), so an exchanged token still passes the
|
|
740
|
+
// UserInfo endpoint's audience check.
|
|
741
|
+
const audience = buildAccessTokenAudience({
|
|
742
|
+
userInfoEndpoint: \`\${config.issuer}/userinfo\`,
|
|
743
|
+
requested: grant.requestedAudience,
|
|
744
|
+
issuer: config.issuer,
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
748
|
+
const payload = buildAccessTokenPayload({
|
|
749
|
+
issuer: config.issuer,
|
|
750
|
+
subject: grant.subject,
|
|
751
|
+
clientId: grant.clientId,
|
|
752
|
+
scope: grant.scope,
|
|
753
|
+
audience,
|
|
754
|
+
expiresIn: grant.expiresIn,
|
|
755
|
+
issuedAt,
|
|
756
|
+
});
|
|
757
|
+
const accessToken = await accessTokenIssuer.issue({
|
|
758
|
+
payload: {
|
|
759
|
+
...payload,
|
|
760
|
+
// RFC 8693 §4.1: a delegation exchange records the current actor in the
|
|
761
|
+
// act claim (chains already nested by processTokenExchangeRequest).
|
|
762
|
+
// Impersonation exchanges carry no act claim.
|
|
763
|
+
...(grant.actor === undefined ? {} : { act: grant.actor }),
|
|
764
|
+
},
|
|
765
|
+
privateKey: keys.general.active.privateKey,
|
|
766
|
+
keyId: keys.general.active.keyId,
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
const metadata: ExchangedAccessTokenInfo = {
|
|
770
|
+
// RFC 8693 §1.1: the exchanged token acts as the same subject, but is bound
|
|
771
|
+
// to the client that requested the exchange.
|
|
772
|
+
sub: grant.subject,
|
|
773
|
+
clientId: grant.clientId,
|
|
774
|
+
scope: grant.scope,
|
|
775
|
+
expiresAt: issuedAt + grant.expiresIn,
|
|
776
|
+
// Inherit the subject token's grant so revoking the grant (e.g. on code
|
|
777
|
+
// reuse detection) also kills every token exchanged from it.
|
|
778
|
+
grantId: grant.grantId,
|
|
779
|
+
iat: issuedAt,
|
|
780
|
+
nbf: issuedAt,
|
|
781
|
+
audience,
|
|
782
|
+
issuer: config.issuer,
|
|
783
|
+
// RFC 9068 §2.2 / RFC 7662 §2.2: the exchanged token gets its own jti, so it
|
|
784
|
+
// is a distinct store record even when exchanged twice within one second.
|
|
785
|
+
jti: payload.jti,
|
|
786
|
+
// Persisting act lets a later exchange that presents THIS token as its
|
|
787
|
+
// subject_token pick up the chain (RFC 8693 §4.1 nesting).
|
|
788
|
+
...(grant.actor === undefined ? {} : { act: grant.actor }),
|
|
789
|
+
// The subject token's stored claims parameter (OIDC Core 1.0 §5.5) is
|
|
790
|
+
// deliberately NOT inherited: an exchanged token yields scope-based claims
|
|
791
|
+
// only at the UserInfo endpoint.
|
|
792
|
+
};
|
|
793
|
+
await stores.accessTokenStore.set(accessToken, metadata);
|
|
794
|
+
|
|
795
|
+
// RFC 8693 §2.2.1: access_token / issued_token_type / token_type are
|
|
796
|
+
// REQUIRED; expires_in and scope are always included here.
|
|
797
|
+
return noStoreJson(buildTokenExchangeResponse({
|
|
798
|
+
accessToken,
|
|
799
|
+
expiresIn: grant.expiresIn,
|
|
800
|
+
scope: grant.scope,
|
|
801
|
+
}));
|
|
802
|
+
}
|
|
803
|
+
`;
|
|
804
|
+
}
|
|
805
|
+
export function nextJsIdJagGrantTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
806
|
+
const refreshSubjects = features.refreshToken;
|
|
807
|
+
const refreshConfigDoc = refreshSubjects
|
|
808
|
+
? `
|
|
809
|
+
* - allowRefreshTokenSubjects: whether a refresh token this OP issued may stand
|
|
810
|
+
* in for the ID Token as the subject_token (draft §4.3 MAY), so a client can
|
|
811
|
+
* request a fresh ID-JAG after its ID Token expired without a new SSO round
|
|
812
|
+
* trip. Validated exactly like the standard refresh_token grant (rotation
|
|
813
|
+
* reuse revokes the token family; online tokens require the login session to
|
|
814
|
+
* be alive); the refresh token is NOT consumed. Grants without the openid
|
|
815
|
+
* scope are refused — their refresh token replaces no identity assertion.`
|
|
816
|
+
: '';
|
|
817
|
+
const refreshConfigField = refreshSubjects
|
|
818
|
+
? `
|
|
819
|
+
allowRefreshTokenSubjects: true,`
|
|
820
|
+
: '';
|
|
821
|
+
const refreshSubjectArgs = refreshSubjects
|
|
822
|
+
? `
|
|
823
|
+
...(idJagConfig.allowRefreshTokenSubjects
|
|
824
|
+
? {
|
|
825
|
+
refreshTokenResolver: resolvers.refreshTokenResolver,
|
|
826
|
+
authenticationSessionResolver: resolvers.authenticationSessionResolver,
|
|
827
|
+
}
|
|
828
|
+
: {}),`
|
|
829
|
+
: '';
|
|
830
|
+
const resolversImport = refreshSubjects ? '\n resolvers,' : '';
|
|
831
|
+
return `/**
|
|
832
|
+
* EXPERIMENTAL — Identity Assertion JWT Authorization Grant (ID-JAG), the
|
|
833
|
+
* Cross-App Access flow (draft-ietf-oauth-identity-assertion-authz-grant-04).
|
|
834
|
+
*
|
|
835
|
+
* Generated because the OP was created with \`--enable id-jag\`. The OP plays
|
|
836
|
+
* both roles:
|
|
837
|
+
* - as the IdP it issues ID-JAGs on the token-exchange grant (issueIdJag);
|
|
838
|
+
* - as a resource authorization server it redeems ID-JAGs from trusted IdPs on
|
|
839
|
+
* the jwt-bearer grant (redeemIdJag).
|
|
840
|
+
*
|
|
841
|
+
${EXPERIMENTAL_NOTICE} The underlying specification is an IETF draft and may
|
|
842
|
+
* itself change.
|
|
843
|
+
*/
|
|
844
|
+
import {
|
|
845
|
+
buildAccessTokenAudience,
|
|
846
|
+
buildAccessTokenPayload,
|
|
847
|
+
selectSigningKeyByAlg,
|
|
848
|
+
type JwkSet,
|
|
849
|
+
type SigningKey,
|
|
850
|
+
type TokenClientInfo,
|
|
851
|
+
} from '${corePkg}';
|
|
852
|
+
import {
|
|
853
|
+
TOKEN_TYPE_ID_TOKEN,
|
|
854
|
+
processIdJagIssuanceRequest,
|
|
855
|
+
processIdJagRedemptionRequest,
|
|
856
|
+
resolveIdJagActor,
|
|
857
|
+
type IdJagAccessTokenInfo,
|
|
858
|
+
type IdJagActorTokenResolver,
|
|
859
|
+
type IdJagTrustedIdentityProvider,
|
|
860
|
+
} from '${EXPERIMENTAL_PACKAGE}/id-jag';
|
|
861
|
+
import {
|
|
862
|
+
accessTokenIssuer,
|
|
863
|
+
config,
|
|
864
|
+
idTokenHintJwks,${resolversImport}
|
|
865
|
+
stores,
|
|
866
|
+
type ProviderSigningKeys,
|
|
867
|
+
} from '../_oidc-provider/provider';
|
|
868
|
+
import { noStoreJson, oauthError } from '../_oidc-provider/http';
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Cross-App Access settings.
|
|
872
|
+
*
|
|
873
|
+
* Issuing side (this OP as the IdP, draft §4.3):
|
|
874
|
+
* - allowedAudiences: resource authorization server issuers this IdP may issue
|
|
875
|
+
* an ID-JAG for. Empty by default (fail safe): every issuance request is
|
|
876
|
+
* rejected with invalid_target until you list the peer AS issuers here.
|
|
877
|
+
* Adding an entry grants that cross-app connection on behalf of every user —
|
|
878
|
+
* there is no per-user consent screen in this flow.
|
|
879
|
+
* - idJagLifetimeSeconds: ID-JAG lifetime. Keep it short (draft example: 300);
|
|
880
|
+
* clients are expected to request a fresh one instead of holding it.
|
|
881
|
+
* - allowedScopes: optional cap on the scopes an ID-JAG may carry. undefined
|
|
882
|
+
* passes the requested scopes through (the resource AS applies its own
|
|
883
|
+
* policy again on redemption).${refreshConfigDoc}
|
|
884
|
+
* - allowActorTokens: whether an actor_token (identifying who acts on the
|
|
885
|
+
* subject's behalf) is accepted and recorded as the ID-JAG's act claim
|
|
886
|
+
* (RFC 8693 §4.1). The draft defines no normative actor processing (§9.7
|
|
887
|
+
* sketches extensions), so this is an opt-in extension and defaults to
|
|
888
|
+
* false — an actor_token is rejected until you flip it, whatever else is
|
|
889
|
+
* configured. Every token type identifier RFC 8693 §3 defines is accepted
|
|
890
|
+
* the same way; the type alone decides nothing.
|
|
891
|
+
* - actorTokenResolver: validates the actor_token's CONTENT (signature,
|
|
892
|
+
* revocation, whose token it is) — for every accepted type, this OP's own
|
|
893
|
+
* ID Tokens included. The library only checks the request structure and the
|
|
894
|
+
* shape of what you return. Return the act value ({ sub, act? }) for a valid
|
|
895
|
+
* token, null for an invalid one (answered with a fixed invalid_request), or
|
|
896
|
+
* throw IdJagError to pick the response yourself. The default below handles
|
|
897
|
+
* ID Tokens this OP issued to the authenticated client; extend or replace it
|
|
898
|
+
* to cover the other types. Clearing it rejects every actor_token.
|
|
899
|
+
*
|
|
900
|
+
* Consuming side (this OP as the resource authorization server, draft §4.4):
|
|
901
|
+
* - trustedIdentityProviders: the IdPs whose ID-JAGs are accepted on the
|
|
902
|
+
* jwt-bearer grant. Empty by default (fail safe). Keys come from the inline
|
|
903
|
+
* \`jwks\` when present, otherwise from \`jwksUri\` (fetched and cached below).
|
|
904
|
+
* Never derive the key source from the assertion itself.
|
|
905
|
+
*/
|
|
906
|
+
const defaultIdJagActorTokenResolver: IdJagActorTokenResolver = async ({
|
|
907
|
+
actorToken,
|
|
908
|
+
actorTokenType,
|
|
909
|
+
clientId,
|
|
910
|
+
issuer,
|
|
911
|
+
jwks,
|
|
912
|
+
}) =>
|
|
913
|
+
actorTokenType === TOKEN_TYPE_ID_TOKEN
|
|
914
|
+
? resolveIdJagActor({ actorToken, issuer, clientId, jwks })
|
|
915
|
+
: null;
|
|
916
|
+
|
|
917
|
+
export const idJagConfig = {
|
|
918
|
+
allowedAudiences: [] as string[],
|
|
919
|
+
idJagLifetimeSeconds: 300,
|
|
920
|
+
allowedScopes: undefined as string[] | undefined,${refreshConfigField}
|
|
921
|
+
allowActorTokens: false,
|
|
922
|
+
actorTokenResolver: defaultIdJagActorTokenResolver as IdJagActorTokenResolver | undefined,
|
|
923
|
+
trustedIdentityProviders: [] as Array<{ issuer: string; jwksUri?: string; jwks?: JwkSet }>,
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Issue an ID-JAG (draft §4.3): the subject_token must be an ID Token this OP
|
|
928
|
+
* issued to the authenticated client, and the answer is a signed grant JWT for
|
|
929
|
+
* the resource authorization server named by \`audience\` — not an access token
|
|
930
|
+
* (the response carries token_type N_A). Errors are thrown as IdJagError and
|
|
931
|
+
* answered by the token route.
|
|
932
|
+
*/
|
|
933
|
+
export async function issueIdJag(
|
|
934
|
+
params: Record<string, string>,
|
|
935
|
+
client: TokenClientInfo,
|
|
936
|
+
keys: ProviderSigningKeys,
|
|
937
|
+
): Promise<Response> {
|
|
938
|
+
// Signed with a registered RS256 key so the peer AS can verify it against
|
|
939
|
+
// this OP's JWKS endpoint (same key-selection contract as JARM: RS256 is
|
|
940
|
+
// pinned, the active key may be a different alg).
|
|
941
|
+
let signingKey: SigningKey;
|
|
942
|
+
try {
|
|
943
|
+
signingKey = selectSigningKeyByAlg(keys.general.registered, 'RS256');
|
|
944
|
+
} catch {
|
|
945
|
+
return oauthError('server_error', 'No RS256 signing key registered for ID-JAG issuance', 500);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const response = await processIdJagIssuanceRequest({
|
|
949
|
+
params,
|
|
950
|
+
client,
|
|
951
|
+
issuer: config.issuer,
|
|
952
|
+
// The subject_token is verified against the keys id_token_hint uses (this
|
|
953
|
+
// OP's own ID Token keys); draft §4.3.3 requires its audience to be the
|
|
954
|
+
// authenticated client, which processIdJagIssuanceRequest checks.
|
|
955
|
+
jwks: await idTokenHintJwks(keys),
|
|
956
|
+
signingKey,
|
|
957
|
+
allowedAudiences: idJagConfig.allowedAudiences,
|
|
958
|
+
allowedScopes: idJagConfig.allowedScopes,
|
|
959
|
+
lifetimeSeconds: idJagConfig.idJagLifetimeSeconds,
|
|
960
|
+
// Extension (draft §9.7): when enabled, an actor_token is recorded as the
|
|
961
|
+
// ID-JAG's act claim. Every accepted token type goes through the same
|
|
962
|
+
// resolver, which owns the content validation.
|
|
963
|
+
allowActorTokens: idJagConfig.allowActorTokens,
|
|
964
|
+
...(idJagConfig.actorTokenResolver === undefined
|
|
965
|
+
? {}
|
|
966
|
+
: { actorTokenResolver: idJagConfig.actorTokenResolver }),${refreshSubjectArgs}
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
// The ID-JAG itself is not persisted: it is a self-contained signed grant the
|
|
970
|
+
// peer AS verifies by signature and exp.
|
|
971
|
+
return noStoreJson(response);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Redeem an ID-JAG on the jwt-bearer grant (RFC 7523 §2.1 / draft §4.4). The
|
|
976
|
+
* assertion must be issued by one of idJagConfig.trustedIdentityProviders for
|
|
977
|
+
* THIS issuer and the authenticated client; this OP then issues its own access
|
|
978
|
+
* token. No ID Token (this is not an OIDC authentication) and no refresh token
|
|
979
|
+
* (draft §4.4.3 SHOULD NOT — the still-valid ID-JAG can be presented again).
|
|
980
|
+
*/
|
|
981
|
+
export async function redeemIdJag(
|
|
982
|
+
params: Record<string, string>,
|
|
983
|
+
client: TokenClientInfo,
|
|
984
|
+
keys: ProviderSigningKeys,
|
|
985
|
+
): Promise<Response> {
|
|
986
|
+
const grant = await processIdJagRedemptionRequest({
|
|
987
|
+
params,
|
|
988
|
+
client,
|
|
989
|
+
issuer: config.issuer,
|
|
990
|
+
identityProviders: await resolveTrustedIdentityProviders(),
|
|
991
|
+
configuredExpiresIn: config.accessTokenExpiresIn,
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
// Same aud policy as the standard grants: the UserInfo endpoint stays a
|
|
995
|
+
// permanent member (RFC 9068 §3); the ID-JAG's resource claim (RFC 8707)
|
|
996
|
+
// contributes the requested resources.
|
|
997
|
+
const audience = buildAccessTokenAudience({
|
|
998
|
+
userInfoEndpoint: \`\${config.issuer}/userinfo\`,
|
|
999
|
+
requested: grant.requestedResources,
|
|
1000
|
+
issuer: config.issuer,
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
1004
|
+
const payload = buildAccessTokenPayload({
|
|
1005
|
+
issuer: config.issuer,
|
|
1006
|
+
subject: grant.subject,
|
|
1007
|
+
clientId: grant.clientId,
|
|
1008
|
+
scope: grant.scope,
|
|
1009
|
+
audience,
|
|
1010
|
+
expiresIn: grant.expiresIn,
|
|
1011
|
+
issuedAt,
|
|
1012
|
+
});
|
|
1013
|
+
const accessToken = await accessTokenIssuer.issue({
|
|
1014
|
+
payload: {
|
|
1015
|
+
...payload,
|
|
1016
|
+
// RFC 8693 §4.1: an act claim carried by the ID-JAG is preserved, so
|
|
1017
|
+
// downstream services still see WHO acts on the subject's behalf
|
|
1018
|
+
// (dropping it would silently turn the delegation into impersonation).
|
|
1019
|
+
...(grant.actor === undefined ? {} : { act: grant.actor }),
|
|
1020
|
+
},
|
|
1021
|
+
privateKey: keys.general.active.privateKey,
|
|
1022
|
+
keyId: keys.general.active.keyId,
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
const metadata: IdJagAccessTokenInfo = {
|
|
1026
|
+
// draft §4.4.1: the ID-JAG's sub is used as the local subject directly
|
|
1027
|
+
// (subject resolution by identical sub; JIT provisioning is out of scope).
|
|
1028
|
+
sub: grant.subject,
|
|
1029
|
+
clientId: grant.clientId,
|
|
1030
|
+
scope: grant.scope,
|
|
1031
|
+
expiresAt: issuedAt + grant.expiresIn,
|
|
1032
|
+
// Each redemption is its own grant: revoking one issued token must not
|
|
1033
|
+
// affect tokens from other redemptions of the same (re-presentable) ID-JAG,
|
|
1034
|
+
// so the payload's own jti doubles as the grant id.
|
|
1035
|
+
grantId: payload.jti,
|
|
1036
|
+
iat: issuedAt,
|
|
1037
|
+
nbf: issuedAt,
|
|
1038
|
+
audience,
|
|
1039
|
+
issuer: config.issuer,
|
|
1040
|
+
jti: payload.jti,
|
|
1041
|
+
// The actor is persisted too, so opaque-token introspection and store-based
|
|
1042
|
+
// tooling can surface it just like the JWT claim.
|
|
1043
|
+
...(grant.actor === undefined ? {} : { act: grant.actor }),
|
|
1044
|
+
};
|
|
1045
|
+
await stores.accessTokenStore.set(accessToken, metadata);
|
|
1046
|
+
|
|
1047
|
+
return noStoreJson({
|
|
1048
|
+
access_token: accessToken,
|
|
1049
|
+
token_type: 'Bearer' as const,
|
|
1050
|
+
expires_in: grant.expiresIn,
|
|
1051
|
+
scope: grant.scope.join(' '),
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* jwks_uri cache for trusted identity providers. A fetched JWKS is reused for
|
|
1057
|
+
* 300 seconds, so a key rotation at the IdP can take up to that long to be
|
|
1058
|
+
* picked up. The fetch target comes exclusively from idJagConfig above — never
|
|
1059
|
+
* from request or assertion content — which keeps this endpoint SSRF-free.
|
|
1060
|
+
*/
|
|
1061
|
+
const idJagJwksCache = new Map<string, { jwks: JwkSet; expiresAt: number }>();
|
|
1062
|
+
const ID_JAG_JWKS_CACHE_TTL_MS = 300_000;
|
|
1063
|
+
|
|
1064
|
+
async function resolveTrustedIdentityProviders(): Promise<IdJagTrustedIdentityProvider[]> {
|
|
1065
|
+
const resolved: IdJagTrustedIdentityProvider[] = [];
|
|
1066
|
+
for (const entry of idJagConfig.trustedIdentityProviders) {
|
|
1067
|
+
if (entry.jwks !== undefined) {
|
|
1068
|
+
resolved.push({ issuer: entry.issuer, jwks: entry.jwks });
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
if (entry.jwksUri === undefined) {
|
|
1072
|
+
// An entry with neither jwks nor jwksUri can never verify anything; skip
|
|
1073
|
+
// it so the assertion is answered with the fixed untrusted description.
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const cached = idJagJwksCache.get(entry.jwksUri);
|
|
1077
|
+
if (cached !== undefined && cached.expiresAt > Date.now()) {
|
|
1078
|
+
resolved.push({ issuer: entry.issuer, jwks: cached.jwks });
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
// A failed fetch propagates: the token route turns it into server_error,
|
|
1082
|
+
// which is honest — the assertion was never evaluated, so invalid_grant
|
|
1083
|
+
// would wrongly blame the client for an outage on this side.
|
|
1084
|
+
const response = await fetch(entry.jwksUri);
|
|
1085
|
+
if (!response.ok) {
|
|
1086
|
+
throw new Error(\`Fetching the JWKS of trusted IdP \${entry.issuer} failed with status \${response.status}\`);
|
|
1087
|
+
}
|
|
1088
|
+
const jwks = (await response.json()) as JwkSet;
|
|
1089
|
+
idJagJwksCache.set(entry.jwksUri, { jwks, expiresAt: Date.now() + ID_JAG_JWKS_CACHE_TTL_MS });
|
|
1090
|
+
resolved.push({ issuer: entry.issuer, jwks });
|
|
1091
|
+
}
|
|
1092
|
+
return resolved;
|
|
1093
|
+
}
|
|
1094
|
+
`;
|
|
1095
|
+
}
|
|
1096
|
+
function approvedGrantTokens(input) {
|
|
1097
|
+
return ` // T-022: the ID Token follows the same key-selection rule as the standard
|
|
1098
|
+
// grants — the registered ID Token key whose alg matches the client's
|
|
1099
|
+
// id_token_signed_response_alg, not the general-purpose active key.
|
|
1100
|
+
const idTokenAlg = (client as RegisteredClient).idTokenSignedResponseAlg;
|
|
1101
|
+
const idTokenKey = selectIdTokenSigningKey(keys, idTokenAlg);
|
|
1102
|
+
if (!idTokenKey) {
|
|
1103
|
+
return oauthError(
|
|
1104
|
+
'server_error',
|
|
1105
|
+
\`No ID Token signing key registered for alg "\${idTokenAlg ?? 'RS256'}"\`,
|
|
1106
|
+
500,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Same aud policy as the standard grants: the UserInfo endpoint stays a
|
|
1111
|
+
// permanent member (RFC 9068 §3). ${input.audienceNote}
|
|
1112
|
+
const audience = buildAccessTokenAudience({
|
|
1113
|
+
userInfoEndpoint: \`\${config.issuer}/userinfo\`,
|
|
1114
|
+
issuer: config.issuer,
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
1118
|
+
const accessTokenPayload = buildAccessTokenPayload({
|
|
1119
|
+
issuer: config.issuer,
|
|
1120
|
+
subject: ${input.grant}.subject,
|
|
1121
|
+
clientId: ${input.grant}.clientId,
|
|
1122
|
+
scope: ${input.grant}.scope,
|
|
1123
|
+
audience,
|
|
1124
|
+
expiresIn: config.accessTokenExpiresIn,
|
|
1125
|
+
issuedAt,
|
|
1126
|
+
});
|
|
1127
|
+
const accessToken = await accessTokenIssuer.issue({
|
|
1128
|
+
payload: accessTokenPayload,
|
|
1129
|
+
privateKey: keys.general.active.privateKey,
|
|
1130
|
+
keyId: keys.general.active.keyId,
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// ${input.idTokenNote}
|
|
1134
|
+
const { acr, amr } = await resolveAcrAmr({
|
|
1135
|
+
subject: ${input.grant}.subject,
|
|
1136
|
+
clientId: ${input.grant}.clientId,
|
|
1137
|
+
acrResolver,
|
|
1138
|
+
});
|
|
1139
|
+
const idToken = await generateIdToken({
|
|
1140
|
+
payload: buildIdTokenPayload({
|
|
1141
|
+
issuer: config.issuer,
|
|
1142
|
+
subject: ${input.grant}.subject,
|
|
1143
|
+
clientId: ${input.grant}.clientId,
|
|
1144
|
+
scope: ${input.grant}.scope,
|
|
1145
|
+
expiresIn: config.idTokenExpiresIn,
|
|
1146
|
+
issuedAt,
|
|
1147
|
+
atHash: await computeAtHash(accessToken, idTokenKey.privateKey),
|
|
1148
|
+
authTime: ${input.grant}.authTime,
|
|
1149
|
+
acr,
|
|
1150
|
+
amr,
|
|
1151
|
+
}),
|
|
1152
|
+
privateKey: idTokenKey.privateKey,
|
|
1153
|
+
keyId: idTokenKey.keyId,
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
await stores.accessTokenStore.set(accessToken, {
|
|
1157
|
+
sub: ${input.grant}.subject,
|
|
1158
|
+
clientId: ${input.grant}.clientId,
|
|
1159
|
+
scope: ${input.grant}.scope,
|
|
1160
|
+
expiresAt: issuedAt + config.accessTokenExpiresIn,
|
|
1161
|
+
// ${input.grantIdNote}
|
|
1162
|
+
grantId: ${input.grant}.grantId,
|
|
1163
|
+
iat: issuedAt,
|
|
1164
|
+
nbf: issuedAt,
|
|
1165
|
+
audience,
|
|
1166
|
+
issuer: config.issuer,
|
|
1167
|
+
jti: accessTokenPayload.jti,
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
${input.refreshTokens ? ` // OIDC Core 1.0 §11: offline_access survived the request endpoint's policy
|
|
1171
|
+
// check only if this client may hold refresh tokens, and ${input.offlineNote}
|
|
1172
|
+
// IS the explicit consent §11 asks for. Nothing further to gate on here.
|
|
1173
|
+
const refreshToken = ${input.grant}.scope.includes('offline_access')
|
|
1174
|
+
? generateRandomString(32)
|
|
1175
|
+
: undefined;
|
|
1176
|
+
if (refreshToken) {
|
|
1177
|
+
await stores.refreshTokenStore.set(refreshToken, {
|
|
1178
|
+
subject: ${input.grant}.subject,
|
|
1179
|
+
clientId: ${input.grant}.clientId,
|
|
1180
|
+
scope: ${input.grant}.scope,
|
|
1181
|
+
// OAuth 2.1 §6.1: absolute lifetime from initial issuance; rotations
|
|
1182
|
+
// inherit originalIssuedAt so the deadline never slides forward.
|
|
1183
|
+
expiresAt: issuedAt + config.refreshTokenAbsoluteLifetime,
|
|
1184
|
+
originalIssuedAt: issuedAt,
|
|
1185
|
+
used: false,
|
|
1186
|
+
grantId: ${input.grant}.grantId,
|
|
1187
|
+
iat: issuedAt,
|
|
1188
|
+
issuer: config.issuer,
|
|
1189
|
+
audience,
|
|
1190
|
+
authTime: ${input.grant}.authTime,
|
|
1191
|
+
// ${input.nonceNote}
|
|
1192
|
+
nonce: undefined,
|
|
1193
|
+
acr,
|
|
1194
|
+
amr,
|
|
1195
|
+
azp: undefined,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
` : ''} return noStoreJson({
|
|
1200
|
+
access_token: accessToken,
|
|
1201
|
+
token_type: 'Bearer' as const,
|
|
1202
|
+
expires_in: config.accessTokenExpiresIn,
|
|
1203
|
+
id_token: idToken,
|
|
1204
|
+
scope: ${input.grant}.scope.join(' '),${input.refreshTokens ? `
|
|
1205
|
+
refresh_token: refreshToken,` : ''}
|
|
1206
|
+
});`;
|
|
1207
|
+
}
|
|
1208
|
+
export function nextJsDeviceCodeGrantTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
1209
|
+
const randomStringImport = features.refreshToken ? '\n generateRandomString,' : '';
|
|
1210
|
+
return `/**
|
|
1211
|
+
* EXPERIMENTAL — the device_code grant of the OAuth 2.0 Device Authorization
|
|
1212
|
+
* Grant (RFC 8628 §3.4 / §3.5).
|
|
1213
|
+
*
|
|
1214
|
+
* Generated because the OP was created with \`--enable device-authorization-grant\`.
|
|
1215
|
+
${EXPERIMENTAL_NOTICE}
|
|
1216
|
+
*/
|
|
1217
|
+
import {
|
|
1218
|
+
buildAccessTokenAudience,
|
|
1219
|
+
buildAccessTokenPayload,
|
|
1220
|
+
buildIdTokenPayload,
|
|
1221
|
+
computeAtHash,
|
|
1222
|
+
generateIdToken,${randomStringImport}
|
|
1223
|
+
resolveAcrAmr,
|
|
1224
|
+
type TokenClientInfo,
|
|
1225
|
+
} from '${corePkg}';
|
|
1226
|
+
import { processDeviceCodeGrant } from '${EXPERIMENTAL_PACKAGE}/device-authorization-grant';
|
|
1227
|
+
import type { RegisteredClient } from '../_oidc-provider/config';
|
|
1228
|
+
import {
|
|
1229
|
+
accessTokenIssuer,
|
|
1230
|
+
acrResolver,
|
|
1231
|
+
config,
|
|
1232
|
+
deviceAuthorizationStore,
|
|
1233
|
+
selectIdTokenSigningKey,
|
|
1234
|
+
stores,
|
|
1235
|
+
type ProviderSigningKeys,
|
|
1236
|
+
} from '../_oidc-provider/provider';
|
|
1237
|
+
import { noStoreJson, oauthError } from '../_oidc-provider/http';
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Redeem an approved device authorization for tokens. Every state but
|
|
1241
|
+
* "approved" is thrown as DeviceAuthorizationError — authorization_pending /
|
|
1242
|
+
* slow_down / access_denied / expired_token, plus invalid_request /
|
|
1243
|
+
* invalid_grant / unauthorized_client from §3.4 — and answered by the token
|
|
1244
|
+
* route.
|
|
1245
|
+
*/
|
|
1246
|
+
export async function redeemDeviceCode(
|
|
1247
|
+
params: Record<string, string>,
|
|
1248
|
+
client: TokenClientInfo,
|
|
1249
|
+
keys: ProviderSigningKeys,
|
|
1250
|
+
): Promise<Response> {
|
|
1251
|
+
const deviceGrant = await processDeviceCodeGrant({
|
|
1252
|
+
params,
|
|
1253
|
+
client,
|
|
1254
|
+
store: deviceAuthorizationStore,
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
${approvedGrantTokens({
|
|
1258
|
+
grant: 'deviceGrant',
|
|
1259
|
+
refreshTokens: features.refreshToken,
|
|
1260
|
+
audienceNote: 'RFC 8628 has no resource parameter, so nothing else is\n // requested.',
|
|
1261
|
+
idTokenNote: `The device authorization endpoint requires the openid scope, so an ID Token
|
|
1262
|
+
// is always issued. It carries no nonce (RFC 8628 defines no such parameter,
|
|
1263
|
+
// and OIDC Core 1.0 §2 only requires nonce when the authentication request
|
|
1264
|
+
// carried one) and no c_hash (there is no code).`,
|
|
1265
|
+
grantIdNote: 'Inherit the grantId minted at approval so revoking the grant kills\n // every token issued from this device authorization.',
|
|
1266
|
+
offlineNote: 'the approval screen the user\n // just went through',
|
|
1267
|
+
nonceNote: 'RFC 8628 has no nonce parameter, so the re-issued ID Token has none\n // to preserve either.',
|
|
1268
|
+
})}
|
|
1269
|
+
}
|
|
1270
|
+
`;
|
|
1271
|
+
}
|
|
1272
|
+
export function nextJsCibaGrantTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
1273
|
+
const randomStringImport = features.refreshToken ? '\n generateRandomString,' : '';
|
|
1274
|
+
return `/**
|
|
1275
|
+
* EXPERIMENTAL — the CIBA grant (OpenID Connect Client-Initiated Backchannel
|
|
1276
|
+
* Authentication Core 1.0 §10.1, poll mode).
|
|
1277
|
+
*
|
|
1278
|
+
* Generated because the OP was created with \`--enable ciba\`.
|
|
1279
|
+
${EXPERIMENTAL_NOTICE}
|
|
1280
|
+
*/
|
|
1281
|
+
import {
|
|
1282
|
+
buildAccessTokenAudience,
|
|
1283
|
+
buildAccessTokenPayload,
|
|
1284
|
+
buildIdTokenPayload,
|
|
1285
|
+
computeAtHash,
|
|
1286
|
+
generateIdToken,${randomStringImport}
|
|
1287
|
+
resolveAcrAmr,
|
|
1288
|
+
type TokenClientInfo,
|
|
1289
|
+
} from '${corePkg}';
|
|
1290
|
+
import { processCibaGrant } from '${EXPERIMENTAL_PACKAGE}/ciba';
|
|
1291
|
+
import type { RegisteredClient } from '../_oidc-provider/config';
|
|
1292
|
+
import {
|
|
1293
|
+
accessTokenIssuer,
|
|
1294
|
+
acrResolver,
|
|
1295
|
+
cibaAuthenticationRequestStore,
|
|
1296
|
+
config,
|
|
1297
|
+
selectIdTokenSigningKey,
|
|
1298
|
+
stores,
|
|
1299
|
+
type ProviderSigningKeys,
|
|
1300
|
+
} from '../_oidc-provider/provider';
|
|
1301
|
+
import { noStoreJson, oauthError } from '../_oidc-provider/http';
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Redeem an approved backchannel authentication request for tokens. Every
|
|
1305
|
+
* state but "approved" is thrown as CibaGrantError — authorization_pending /
|
|
1306
|
+
* slow_down / access_denied / expired_token, plus invalid_request /
|
|
1307
|
+
* invalid_grant (CIBA §11) — and answered by the token route.
|
|
1308
|
+
*/
|
|
1309
|
+
export async function redeemCibaRequest(
|
|
1310
|
+
params: Record<string, string>,
|
|
1311
|
+
client: TokenClientInfo,
|
|
1312
|
+
keys: ProviderSigningKeys,
|
|
1313
|
+
): Promise<Response> {
|
|
1314
|
+
const cibaGrant = await processCibaGrant({
|
|
1315
|
+
params,
|
|
1316
|
+
client,
|
|
1317
|
+
store: cibaAuthenticationRequestStore,
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
${approvedGrantTokens({
|
|
1321
|
+
grant: 'cibaGrant',
|
|
1322
|
+
refreshTokens: features.refreshToken,
|
|
1323
|
+
audienceNote: 'CIBA §7.1 has no resource parameter, so nothing else is\n // requested.',
|
|
1324
|
+
idTokenNote: `The backchannel authentication endpoint requires the openid scope, so an ID
|
|
1325
|
+
// Token is always issued. It carries no nonce (CIBA §7.1 defines no such
|
|
1326
|
+
// parameter, and OIDC Core 1.0 §2 only requires nonce when the authentication
|
|
1327
|
+
// request carried one) and no c_hash (there is no code). Poll mode adds no
|
|
1328
|
+
// CIBA-specific claims either — the auth_req_id claim of §10.3.1 belongs to
|
|
1329
|
+
// the push-mode token delivery message.`,
|
|
1330
|
+
grantIdNote: 'Inherit the grantId minted at approval so revoking the grant kills\n // every token issued from this backchannel authentication.',
|
|
1331
|
+
offlineNote: 'the approval screen the user\n // just went through',
|
|
1332
|
+
nonceNote: 'CIBA §7.1 defines no nonce parameter, so the re-issued ID Token has\n // none to preserve either.',
|
|
1333
|
+
})}
|
|
1334
|
+
}
|
|
1335
|
+
`;
|
|
1336
|
+
}
|
|
1337
|
+
//# sourceMappingURL=token.js.map
|