@tdacorp/identity-client 0.2.0 → 0.2.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/README.md +27 -9
- package/dist/{chunk-6WGVTO7C.js → chunk-TTFIPEZ3.js} +17 -1
- package/dist/index.cjs +17 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/next.cjs +23 -1
- package/dist/next.d.cts +13 -3
- package/dist/next.d.ts +13 -3
- package/dist/next.js +14 -2
- package/dist/{verify-DTUDjR4v.d.cts → verify-ggSzUrdC.d.cts} +20 -7
- package/dist/{verify-DTUDjR4v.d.ts → verify-ggSzUrdC.d.ts} +20 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,9 @@ export const GET = createCallbackRoute({
|
|
|
53
53
|
redirectUri: `${process.env.APP_URL}/api/auth/callback`,
|
|
54
54
|
cookieSecret: process.env.COOKIE_SECRET!,
|
|
55
55
|
onSuccess: async (tokens, idClaims, { returnTo }) => {
|
|
56
|
+
// returnTo, when present, has already been validated to a same-app
|
|
57
|
+
// relative path (single leading `/`, never an absolute URL) by
|
|
58
|
+
// createLoginRoute -- safe to resolve against APP_URL like this.
|
|
56
59
|
const response = NextResponse.redirect(new URL(returnTo ?? '/', process.env.APP_URL))
|
|
57
60
|
// Store tokens.accessToken / tokens.refreshToken in your own session --
|
|
58
61
|
// this package stops at verified claims, session storage is your call.
|
|
@@ -61,6 +64,18 @@ export const GET = createCallbackRoute({
|
|
|
61
64
|
})
|
|
62
65
|
```
|
|
63
66
|
|
|
67
|
+
`createLoginRoute` rejects anything but a relative path for `returnTo` --
|
|
68
|
+
an absolute URL, a protocol-relative `//host` value, a backslash, or a
|
|
69
|
+
control character is silently dropped (coerced to `undefined`) rather than
|
|
70
|
+
sealed into the transaction cookie, closing the open-redirect this shape
|
|
71
|
+
would otherwise allow. This validation applies regardless of whether
|
|
72
|
+
`returnTo` came from the default `returnTo` search param or a custom
|
|
73
|
+
`getReturnTo`, since this package has no per-app trusted-origin registry to
|
|
74
|
+
check an absolute URL's origin against. If your app legitimately needs to
|
|
75
|
+
redirect to an absolute URL after login, carry it through your own
|
|
76
|
+
mechanism (a separate cookie your app validates against its own known
|
|
77
|
+
origins) rather than this package's `returnTo`.
|
|
78
|
+
|
|
64
79
|
Both handlers seal the PKCE verifier and CSRF `state` into an encrypted
|
|
65
80
|
cookie (via `./sealed`'s `seal()`/`unseal()`) rather than storing them in
|
|
66
81
|
plaintext — you never handle that transaction cookie directly.
|
|
@@ -119,10 +134,11 @@ const discovery = await fetchDiscovery('https://identity.tdacorp.in')
|
|
|
119
134
|
deduplication — concurrent callers for the same issuer during a cold start
|
|
120
135
|
share one fetch rather than each firing their own. Before the result is
|
|
121
136
|
trusted, it is validated: the document's `issuer` must match the URL you
|
|
122
|
-
called with, and `jwks_uri` / `token_endpoint`
|
|
123
|
-
excepted) and same-origin with the issuer.
|
|
124
|
-
edge cache or a cached error page from
|
|
125
|
-
endpoint
|
|
137
|
+
called with, and `jwks_uri` / `token_endpoint` / `authorization_endpoint`
|
|
138
|
+
must be https (loopback hosts excepted) and same-origin with the issuer.
|
|
139
|
+
This is what stops a compromised edge cache or a cached error page from
|
|
140
|
+
redirecting key material, the token endpoint, or the login page itself
|
|
141
|
+
somewhere untrusted.
|
|
126
142
|
|
|
127
143
|
Most callers reach `fetchDiscovery` indirectly — every function below calls
|
|
128
144
|
it internally.
|
|
@@ -199,11 +215,13 @@ if (outcome.outcome === 'success') {
|
|
|
199
215
|
Machine-to-machine calls with no end user use `clientCredentialsGrant` (RFC
|
|
200
216
|
6749 §4.4) instead, always with a confidential `clientSecret` since there is
|
|
201
217
|
no PKCE verifier to authenticate a public client with. A token minted this
|
|
202
|
-
way carries no `sub`
|
|
203
|
-
`
|
|
204
|
-
`
|
|
205
|
-
|
|
206
|
-
|
|
218
|
+
way carries no `sub` — verifying it with `verifyAccessToken` returns the
|
|
219
|
+
`kind: 'machine'` variant of `VerifiedAccessToken`, distinct from the
|
|
220
|
+
`kind: 'user'` variant a normal login produces, so reading `.sub` off the
|
|
221
|
+
wrong branch is a compile error rather than `undefined`. It can still carry a
|
|
222
|
+
`roles` claim: request `roles` in the client_credentials scope and the server
|
|
223
|
+
resolves it against the requesting client itself as the subject, exactly as
|
|
224
|
+
it does for a user token requesting the same scope.
|
|
207
225
|
|
|
208
226
|
## Verification
|
|
209
227
|
|
|
@@ -35,6 +35,7 @@ function validateDiscoveryDocument(document, issuerUrl) {
|
|
|
35
35
|
assertHttps(issuerUrl, "issuer");
|
|
36
36
|
assertHttps(document.jwks_uri, "jwks_uri");
|
|
37
37
|
assertHttps(document.token_endpoint, "token_endpoint");
|
|
38
|
+
assertHttps(document.authorization_endpoint, "authorization_endpoint");
|
|
38
39
|
if (document.issuer !== issuerUrl) {
|
|
39
40
|
throw new Error(`Discovery: document issuer "${document.issuer}" does not match the configured issuer "${issuerUrl}"`);
|
|
40
41
|
}
|
|
@@ -45,6 +46,9 @@ function validateDiscoveryDocument(document, issuerUrl) {
|
|
|
45
46
|
if (new URL(document.token_endpoint).origin !== issuerOrigin) {
|
|
46
47
|
throw new Error(`Discovery: token_endpoint "${document.token_endpoint}" is not same-origin as issuer "${issuerUrl}"`);
|
|
47
48
|
}
|
|
49
|
+
if (new URL(document.authorization_endpoint).origin !== issuerOrigin) {
|
|
50
|
+
throw new Error(`Discovery: authorization_endpoint "${document.authorization_endpoint}" is not same-origin as issuer "${issuerUrl}"`);
|
|
51
|
+
}
|
|
48
52
|
}
|
|
49
53
|
async function fetchDiscovery(issuerUrl) {
|
|
50
54
|
const cached = discoveryCache.get(issuerUrl);
|
|
@@ -302,6 +306,12 @@ async function verifyIdToken(idToken, options) {
|
|
|
302
306
|
const result = await verifyAgainstIssuer(idToken, options);
|
|
303
307
|
if ("errors" in result) return { success: false, errors: result.errors };
|
|
304
308
|
const { payload } = result;
|
|
309
|
+
if ("token_use" in payload) {
|
|
310
|
+
return {
|
|
311
|
+
success: false,
|
|
312
|
+
errors: [{ code: "wrong-token-type", message: "Token has a token_use claim; an id_token never carries one. This is an access token, not an id_token." }]
|
|
313
|
+
};
|
|
314
|
+
}
|
|
305
315
|
if (typeof payload.sub !== "string") {
|
|
306
316
|
return { success: false, errors: [{ code: "malformed-claims", message: "id_token has no sub claim" }] };
|
|
307
317
|
}
|
|
@@ -311,6 +321,12 @@ async function verifyAccessToken(accessToken, options) {
|
|
|
311
321
|
const result = await verifyAgainstIssuer(accessToken, options);
|
|
312
322
|
if ("errors" in result) return { success: false, errors: result.errors };
|
|
313
323
|
const { payload } = result;
|
|
324
|
+
if (payload.token_use !== "oauth_access") {
|
|
325
|
+
return {
|
|
326
|
+
success: false,
|
|
327
|
+
errors: [{ code: "wrong-token-type", message: 'Token is missing token_use: "oauth_access". This is not an access token (likely an id_token).' }]
|
|
328
|
+
};
|
|
329
|
+
}
|
|
314
330
|
const claims = payload;
|
|
315
331
|
const roles = "roles" in claims ? claims.roles : void 0;
|
|
316
332
|
if (typeof payload.sub === "string") {
|
|
@@ -322,7 +338,7 @@ async function verifyAccessToken(accessToken, options) {
|
|
|
322
338
|
errors: [{ code: "malformed-claims", message: "Access token has neither a sub (user) nor an azp (machine client id)" }]
|
|
323
339
|
};
|
|
324
340
|
}
|
|
325
|
-
return { success: true, data: { kind: "machine", clientId: payload.azp, claims } };
|
|
341
|
+
return { success: true, data: { kind: "machine", clientId: payload.azp, roles, claims } };
|
|
326
342
|
}
|
|
327
343
|
|
|
328
344
|
export {
|
package/dist/index.cjs
CHANGED
|
@@ -72,6 +72,7 @@ function validateDiscoveryDocument(document, issuerUrl) {
|
|
|
72
72
|
assertHttps(issuerUrl, "issuer");
|
|
73
73
|
assertHttps(document.jwks_uri, "jwks_uri");
|
|
74
74
|
assertHttps(document.token_endpoint, "token_endpoint");
|
|
75
|
+
assertHttps(document.authorization_endpoint, "authorization_endpoint");
|
|
75
76
|
if (document.issuer !== issuerUrl) {
|
|
76
77
|
throw new Error(`Discovery: document issuer "${document.issuer}" does not match the configured issuer "${issuerUrl}"`);
|
|
77
78
|
}
|
|
@@ -82,6 +83,9 @@ function validateDiscoveryDocument(document, issuerUrl) {
|
|
|
82
83
|
if (new URL(document.token_endpoint).origin !== issuerOrigin) {
|
|
83
84
|
throw new Error(`Discovery: token_endpoint "${document.token_endpoint}" is not same-origin as issuer "${issuerUrl}"`);
|
|
84
85
|
}
|
|
86
|
+
if (new URL(document.authorization_endpoint).origin !== issuerOrigin) {
|
|
87
|
+
throw new Error(`Discovery: authorization_endpoint "${document.authorization_endpoint}" is not same-origin as issuer "${issuerUrl}"`);
|
|
88
|
+
}
|
|
85
89
|
}
|
|
86
90
|
async function fetchDiscovery(issuerUrl) {
|
|
87
91
|
const cached = discoveryCache.get(issuerUrl);
|
|
@@ -339,6 +343,12 @@ async function verifyIdToken(idToken, options) {
|
|
|
339
343
|
const result = await verifyAgainstIssuer(idToken, options);
|
|
340
344
|
if ("errors" in result) return { success: false, errors: result.errors };
|
|
341
345
|
const { payload } = result;
|
|
346
|
+
if ("token_use" in payload) {
|
|
347
|
+
return {
|
|
348
|
+
success: false,
|
|
349
|
+
errors: [{ code: "wrong-token-type", message: "Token has a token_use claim; an id_token never carries one. This is an access token, not an id_token." }]
|
|
350
|
+
};
|
|
351
|
+
}
|
|
342
352
|
if (typeof payload.sub !== "string") {
|
|
343
353
|
return { success: false, errors: [{ code: "malformed-claims", message: "id_token has no sub claim" }] };
|
|
344
354
|
}
|
|
@@ -348,6 +358,12 @@ async function verifyAccessToken(accessToken, options) {
|
|
|
348
358
|
const result = await verifyAgainstIssuer(accessToken, options);
|
|
349
359
|
if ("errors" in result) return { success: false, errors: result.errors };
|
|
350
360
|
const { payload } = result;
|
|
361
|
+
if (payload.token_use !== "oauth_access") {
|
|
362
|
+
return {
|
|
363
|
+
success: false,
|
|
364
|
+
errors: [{ code: "wrong-token-type", message: 'Token is missing token_use: "oauth_access". This is not an access token (likely an id_token).' }]
|
|
365
|
+
};
|
|
366
|
+
}
|
|
351
367
|
const claims = payload;
|
|
352
368
|
const roles = "roles" in claims ? claims.roles : void 0;
|
|
353
369
|
if (typeof payload.sub === "string") {
|
|
@@ -359,7 +375,7 @@ async function verifyAccessToken(accessToken, options) {
|
|
|
359
375
|
errors: [{ code: "malformed-claims", message: "Access token has neither a sub (user) nor an azp (machine client id)" }]
|
|
360
376
|
};
|
|
361
377
|
}
|
|
362
|
-
return { success: true, data: { kind: "machine", clientId: payload.azp, claims } };
|
|
378
|
+
return { success: true, data: { kind: "machine", clientId: payload.azp, roles, claims } };
|
|
363
379
|
}
|
|
364
380
|
// Annotate the CommonJS export names for ESM import in node:
|
|
365
381
|
0 && (module.exports = {
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRemoteJWKSet } from 'jose';
|
|
2
|
-
export { A as AuthorizationCodeExchangeOptions, C as CLOCK_SKEW_TOLERANCE_SECONDS, a as ClientCredentialsGrantOptions, I as IdTokenClaims, R as RefreshTokensOptions, T as TokenEndpointAuthMethod, b as TokenRefreshOutcome, c as TokenRequestError, d as TokenSet, e as TokenVerificationError, f as TokenVerificationErrorCode, V as VerificationResult, g as VerifiedAccessToken, h as VerifyTokenOptions, i as clientCredentialsGrant, j as exchangeAuthorizationCode, r as refreshTokens, v as verifyAccessToken, k as verifyIdToken } from './verify-
|
|
2
|
+
export { A as AuthorizationCodeExchangeOptions, C as CLOCK_SKEW_TOLERANCE_SECONDS, a as ClientCredentialsGrantOptions, I as IdTokenClaims, R as RefreshTokensOptions, T as TokenEndpointAuthMethod, b as TokenRefreshOutcome, c as TokenRequestError, d as TokenSet, e as TokenVerificationError, f as TokenVerificationErrorCode, V as VerificationResult, g as VerifiedAccessToken, h as VerifyTokenOptions, i as clientCredentialsGrant, j as exchangeAuthorizationCode, r as refreshTokens, v as verifyAccessToken, k as verifyIdToken } from './verify-ggSzUrdC.cjs';
|
|
3
3
|
import '@tdacorp/identity-authz';
|
|
4
4
|
|
|
5
5
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRemoteJWKSet } from 'jose';
|
|
2
|
-
export { A as AuthorizationCodeExchangeOptions, C as CLOCK_SKEW_TOLERANCE_SECONDS, a as ClientCredentialsGrantOptions, I as IdTokenClaims, R as RefreshTokensOptions, T as TokenEndpointAuthMethod, b as TokenRefreshOutcome, c as TokenRequestError, d as TokenSet, e as TokenVerificationError, f as TokenVerificationErrorCode, V as VerificationResult, g as VerifiedAccessToken, h as VerifyTokenOptions, i as clientCredentialsGrant, j as exchangeAuthorizationCode, r as refreshTokens, v as verifyAccessToken, k as verifyIdToken } from './verify-
|
|
2
|
+
export { A as AuthorizationCodeExchangeOptions, C as CLOCK_SKEW_TOLERANCE_SECONDS, a as ClientCredentialsGrantOptions, I as IdTokenClaims, R as RefreshTokensOptions, T as TokenEndpointAuthMethod, b as TokenRefreshOutcome, c as TokenRequestError, d as TokenSet, e as TokenVerificationError, f as TokenVerificationErrorCode, V as VerificationResult, g as VerifiedAccessToken, h as VerifyTokenOptions, i as clientCredentialsGrant, j as exchangeAuthorizationCode, r as refreshTokens, v as verifyAccessToken, k as verifyIdToken } from './verify-ggSzUrdC.js';
|
|
3
3
|
import '@tdacorp/identity-authz';
|
|
4
4
|
|
|
5
5
|
/**
|
package/dist/index.js
CHANGED
package/dist/next.cjs
CHANGED
|
@@ -63,6 +63,7 @@ function validateDiscoveryDocument(document, issuerUrl) {
|
|
|
63
63
|
assertHttps(issuerUrl, "issuer");
|
|
64
64
|
assertHttps(document.jwks_uri, "jwks_uri");
|
|
65
65
|
assertHttps(document.token_endpoint, "token_endpoint");
|
|
66
|
+
assertHttps(document.authorization_endpoint, "authorization_endpoint");
|
|
66
67
|
if (document.issuer !== issuerUrl) {
|
|
67
68
|
throw new Error(`Discovery: document issuer "${document.issuer}" does not match the configured issuer "${issuerUrl}"`);
|
|
68
69
|
}
|
|
@@ -73,6 +74,9 @@ function validateDiscoveryDocument(document, issuerUrl) {
|
|
|
73
74
|
if (new URL(document.token_endpoint).origin !== issuerOrigin) {
|
|
74
75
|
throw new Error(`Discovery: token_endpoint "${document.token_endpoint}" is not same-origin as issuer "${issuerUrl}"`);
|
|
75
76
|
}
|
|
77
|
+
if (new URL(document.authorization_endpoint).origin !== issuerOrigin) {
|
|
78
|
+
throw new Error(`Discovery: authorization_endpoint "${document.authorization_endpoint}" is not same-origin as issuer "${issuerUrl}"`);
|
|
79
|
+
}
|
|
76
80
|
}
|
|
77
81
|
async function fetchDiscovery(issuerUrl) {
|
|
78
82
|
const cached = discoveryCache.get(issuerUrl);
|
|
@@ -276,6 +280,12 @@ async function verifyIdToken(idToken, options) {
|
|
|
276
280
|
const result = await verifyAgainstIssuer(idToken, options);
|
|
277
281
|
if ("errors" in result) return { success: false, errors: result.errors };
|
|
278
282
|
const { payload } = result;
|
|
283
|
+
if ("token_use" in payload) {
|
|
284
|
+
return {
|
|
285
|
+
success: false,
|
|
286
|
+
errors: [{ code: "wrong-token-type", message: "Token has a token_use claim; an id_token never carries one. This is an access token, not an id_token." }]
|
|
287
|
+
};
|
|
288
|
+
}
|
|
279
289
|
if (typeof payload.sub !== "string") {
|
|
280
290
|
return { success: false, errors: [{ code: "malformed-claims", message: "id_token has no sub claim" }] };
|
|
281
291
|
}
|
|
@@ -356,6 +366,17 @@ async function unseal(sealedValue, options) {
|
|
|
356
366
|
var TRANSACTION_TTL_SECONDS = 600;
|
|
357
367
|
var DEFAULT_TRANSACTION_COOKIE = "identity_oauth_txn";
|
|
358
368
|
var TRANSACTION_PURPOSE = "identity-client:oauth-transaction";
|
|
369
|
+
function sanitizeReturnTo(returnTo) {
|
|
370
|
+
if (!returnTo) return void 0;
|
|
371
|
+
if (/[\x00-\x1f]/.test(returnTo)) return void 0;
|
|
372
|
+
if (returnTo.startsWith("//")) return void 0;
|
|
373
|
+
if (returnTo.includes("\\")) return void 0;
|
|
374
|
+
const lower = returnTo.toLowerCase().trim();
|
|
375
|
+
if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:") || lower.startsWith("file:")) {
|
|
376
|
+
return void 0;
|
|
377
|
+
}
|
|
378
|
+
return returnTo.startsWith("/") ? returnTo : void 0;
|
|
379
|
+
}
|
|
359
380
|
function transactionCookieOptions(maxAge) {
|
|
360
381
|
return {
|
|
361
382
|
httpOnly: true,
|
|
@@ -370,7 +391,8 @@ function createLoginRoute(config) {
|
|
|
370
391
|
const state = generateState();
|
|
371
392
|
const codeVerifier = generateCodeVerifier();
|
|
372
393
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
373
|
-
const
|
|
394
|
+
const rawReturnTo = config.getReturnTo?.(request) ?? request.nextUrl.searchParams.get("returnTo") ?? void 0;
|
|
395
|
+
const returnTo = sanitizeReturnTo(rawReturnTo);
|
|
374
396
|
const transaction = { state, codeVerifier, returnTo };
|
|
375
397
|
const sealedTransaction = await seal(transaction, {
|
|
376
398
|
secret: config.cookieSecret,
|
package/dist/next.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse, NextRequest } from 'next/server';
|
|
2
|
-
import { d as TokenSet, I as IdTokenClaims } from './verify-
|
|
2
|
+
import { d as TokenSet, I as IdTokenClaims } from './verify-ggSzUrdC.cjs';
|
|
3
3
|
import { SealSecret } from './sealed.cjs';
|
|
4
4
|
import '@tdacorp/identity-authz';
|
|
5
5
|
import 'jose';
|
|
@@ -22,7 +22,14 @@ interface CreateLoginRouteConfig {
|
|
|
22
22
|
cookieSecret: SealSecret;
|
|
23
23
|
transactionCookieName?: string;
|
|
24
24
|
/** Where to send the user after login completes. Defaults to reading a
|
|
25
|
-
* `returnTo` search param off the incoming request.
|
|
25
|
+
* `returnTo` search param off the incoming request. Validated to a
|
|
26
|
+
* same-app relative path (single leading `/`, no `//` prefix, no
|
|
27
|
+
* backslash, no C0 control character, no `javascript:`/`data:`/
|
|
28
|
+
* `vbscript:`/`file:` scheme) before being sealed into the transaction
|
|
29
|
+
* cookie -- see `sanitizeReturnTo`. An invalid value is silently coerced
|
|
30
|
+
* to `undefined` rather than rejected with an error, since this package
|
|
31
|
+
* has no per-app trusted-origin registry to validate an absolute URL
|
|
32
|
+
* against and a bad `returnTo` shouldn't break the login flow. */
|
|
26
33
|
getReturnTo?: (request: NextRequest) => string | undefined;
|
|
27
34
|
}
|
|
28
35
|
/** Builds a GET Route Handler that starts the OIDC login: generates PKCE and
|
|
@@ -67,7 +74,10 @@ interface CreateCallbackRouteConfig {
|
|
|
67
74
|
* Called once the code has been exchanged and the id_token verified. This
|
|
68
75
|
* package does not decide what happens next -- session creation, cookie
|
|
69
76
|
* choice, redirect target are the consuming app's own concerns -- so this
|
|
70
|
-
* callback's return value IS the route handler's response.
|
|
77
|
+
* callback's return value IS the route handler's response. `ctx.returnTo`,
|
|
78
|
+
* if present, already passed `sanitizeReturnTo`'s validation in
|
|
79
|
+
* `createLoginRoute`, so it is always a same-app relative path, never an
|
|
80
|
+
* absolute URL -- safe to pass straight into `new URL(returnTo, base)`.
|
|
71
81
|
*/
|
|
72
82
|
onSuccess: (tokens: TokenSet, claims: IdTokenClaims, ctx: {
|
|
73
83
|
returnTo?: string;
|
package/dist/next.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse, NextRequest } from 'next/server';
|
|
2
|
-
import { d as TokenSet, I as IdTokenClaims } from './verify-
|
|
2
|
+
import { d as TokenSet, I as IdTokenClaims } from './verify-ggSzUrdC.js';
|
|
3
3
|
import { SealSecret } from './sealed.js';
|
|
4
4
|
import '@tdacorp/identity-authz';
|
|
5
5
|
import 'jose';
|
|
@@ -22,7 +22,14 @@ interface CreateLoginRouteConfig {
|
|
|
22
22
|
cookieSecret: SealSecret;
|
|
23
23
|
transactionCookieName?: string;
|
|
24
24
|
/** Where to send the user after login completes. Defaults to reading a
|
|
25
|
-
* `returnTo` search param off the incoming request.
|
|
25
|
+
* `returnTo` search param off the incoming request. Validated to a
|
|
26
|
+
* same-app relative path (single leading `/`, no `//` prefix, no
|
|
27
|
+
* backslash, no C0 control character, no `javascript:`/`data:`/
|
|
28
|
+
* `vbscript:`/`file:` scheme) before being sealed into the transaction
|
|
29
|
+
* cookie -- see `sanitizeReturnTo`. An invalid value is silently coerced
|
|
30
|
+
* to `undefined` rather than rejected with an error, since this package
|
|
31
|
+
* has no per-app trusted-origin registry to validate an absolute URL
|
|
32
|
+
* against and a bad `returnTo` shouldn't break the login flow. */
|
|
26
33
|
getReturnTo?: (request: NextRequest) => string | undefined;
|
|
27
34
|
}
|
|
28
35
|
/** Builds a GET Route Handler that starts the OIDC login: generates PKCE and
|
|
@@ -67,7 +74,10 @@ interface CreateCallbackRouteConfig {
|
|
|
67
74
|
* Called once the code has been exchanged and the id_token verified. This
|
|
68
75
|
* package does not decide what happens next -- session creation, cookie
|
|
69
76
|
* choice, redirect target are the consuming app's own concerns -- so this
|
|
70
|
-
* callback's return value IS the route handler's response.
|
|
77
|
+
* callback's return value IS the route handler's response. `ctx.returnTo`,
|
|
78
|
+
* if present, already passed `sanitizeReturnTo`'s validation in
|
|
79
|
+
* `createLoginRoute`, so it is always a same-app relative path, never an
|
|
80
|
+
* absolute URL -- safe to pass straight into `new URL(returnTo, base)`.
|
|
71
81
|
*/
|
|
72
82
|
onSuccess: (tokens: TokenSet, claims: IdTokenClaims, ctx: {
|
|
73
83
|
returnTo?: string;
|
package/dist/next.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
generateCodeVerifier,
|
|
6
6
|
generateState,
|
|
7
7
|
verifyIdToken
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-TTFIPEZ3.js";
|
|
9
9
|
import {
|
|
10
10
|
seal,
|
|
11
11
|
unseal
|
|
@@ -16,6 +16,17 @@ import { NextResponse } from "next/server";
|
|
|
16
16
|
var TRANSACTION_TTL_SECONDS = 600;
|
|
17
17
|
var DEFAULT_TRANSACTION_COOKIE = "identity_oauth_txn";
|
|
18
18
|
var TRANSACTION_PURPOSE = "identity-client:oauth-transaction";
|
|
19
|
+
function sanitizeReturnTo(returnTo) {
|
|
20
|
+
if (!returnTo) return void 0;
|
|
21
|
+
if (/[\x00-\x1f]/.test(returnTo)) return void 0;
|
|
22
|
+
if (returnTo.startsWith("//")) return void 0;
|
|
23
|
+
if (returnTo.includes("\\")) return void 0;
|
|
24
|
+
const lower = returnTo.toLowerCase().trim();
|
|
25
|
+
if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:") || lower.startsWith("file:")) {
|
|
26
|
+
return void 0;
|
|
27
|
+
}
|
|
28
|
+
return returnTo.startsWith("/") ? returnTo : void 0;
|
|
29
|
+
}
|
|
19
30
|
function transactionCookieOptions(maxAge) {
|
|
20
31
|
return {
|
|
21
32
|
httpOnly: true,
|
|
@@ -30,7 +41,8 @@ function createLoginRoute(config) {
|
|
|
30
41
|
const state = generateState();
|
|
31
42
|
const codeVerifier = generateCodeVerifier();
|
|
32
43
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
33
|
-
const
|
|
44
|
+
const rawReturnTo = config.getReturnTo?.(request) ?? request.nextUrl.searchParams.get("returnTo") ?? void 0;
|
|
45
|
+
const returnTo = sanitizeReturnTo(rawReturnTo);
|
|
34
46
|
const transaction = { state, codeVerifier, returnTo };
|
|
35
47
|
const sealedTransaction = await seal(transaction, {
|
|
36
48
|
secret: config.cookieSecret,
|
|
@@ -178,12 +178,14 @@ declare function refreshTokens(options: RefreshTokensOptions): Promise<TokenRefr
|
|
|
178
178
|
* read as "no roles" or a machine's `clientId` misread as a user's `sub`.
|
|
179
179
|
*
|
|
180
180
|
* The concrete reason this matters here: this identity server's
|
|
181
|
-
* `client_credentials` grant mints access tokens with no end user
|
|
182
|
-
* `
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
181
|
+
* `client_credentials` grant mints access tokens with no end user -- there
|
|
182
|
+
* is no `sub` for this variant, ever. It CAN still carry a `roles` claim,
|
|
183
|
+
* resolved against the requesting client itself as the subject, whenever the
|
|
184
|
+
* client_credentials request's scope included `roles`; unlike `sub`, `roles`
|
|
185
|
+
* is not tied to there being an end user. Before this type existed,
|
|
186
|
+
* `verifyAccessToken` returned one flat claims object for both cases, so a
|
|
187
|
+
* caller checking `.sub` on a machine token silently got `undefined` instead
|
|
188
|
+
* of a type error telling it this branch has no subject.
|
|
187
189
|
*/
|
|
188
190
|
type VerifiedAccessToken = {
|
|
189
191
|
kind: 'user';
|
|
@@ -196,6 +198,11 @@ type VerifiedAccessToken = {
|
|
|
196
198
|
} | {
|
|
197
199
|
kind: 'machine';
|
|
198
200
|
clientId: string;
|
|
201
|
+
/** Present only when the client_credentials request's scope included
|
|
202
|
+
* `roles` -- the server resolves the claim against the requesting
|
|
203
|
+
* client itself as the subject in that case. Pass to `permits()` from
|
|
204
|
+
* `@tdacorp/identity-authz` to decide a permission locally. */
|
|
205
|
+
roles?: RolesClaim;
|
|
199
206
|
claims: Record<string, unknown>;
|
|
200
207
|
};
|
|
201
208
|
|
|
@@ -239,8 +246,14 @@ declare const CLOCK_SKEW_TOLERANCE_SECONDS = 60;
|
|
|
239
246
|
* - `'malformed-token'`: the token is not a well-formed JWT at all.
|
|
240
247
|
* - `'jwks-unavailable'`: the issuer's discovery document or JWKS could not
|
|
241
248
|
* be fetched -- infrastructure, not a verdict on the token.
|
|
249
|
+
* - `'wrong-token-type'`: the token verified (right issuer, right audience,
|
|
250
|
+
* right signature) but is the OTHER kind of token this server mints for a
|
|
251
|
+
* login -- an id_token presented to `verifyAccessToken`, or an access
|
|
252
|
+
* token presented to `verifyIdToken`. The two share `iss`/`sub`/`aud`/`azp`
|
|
253
|
+
* and are signed by the same key, so nothing else here would catch this;
|
|
254
|
+
* see `token_use` on `IdentityTokenPayload`.
|
|
242
255
|
*/
|
|
243
|
-
type TokenVerificationErrorCode = 'expired' | 'invalid-signature' | 'invalid-issuer' | 'invalid-audience' | 'unauthorized-party' | 'malformed-claims' | 'malformed-token' | 'jwks-unavailable';
|
|
256
|
+
type TokenVerificationErrorCode = 'expired' | 'invalid-signature' | 'invalid-issuer' | 'invalid-audience' | 'unauthorized-party' | 'malformed-claims' | 'malformed-token' | 'jwks-unavailable' | 'wrong-token-type';
|
|
244
257
|
/** One verification failure: a stable `code` to branch on, plus a
|
|
245
258
|
* human-readable `message` for logs. `verifyIdToken`/`verifyAccessToken`
|
|
246
259
|
* return a list of these rather than throwing -- see `VerificationResult`. */
|
|
@@ -178,12 +178,14 @@ declare function refreshTokens(options: RefreshTokensOptions): Promise<TokenRefr
|
|
|
178
178
|
* read as "no roles" or a machine's `clientId` misread as a user's `sub`.
|
|
179
179
|
*
|
|
180
180
|
* The concrete reason this matters here: this identity server's
|
|
181
|
-
* `client_credentials` grant mints access tokens with no end user
|
|
182
|
-
* `
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
181
|
+
* `client_credentials` grant mints access tokens with no end user -- there
|
|
182
|
+
* is no `sub` for this variant, ever. It CAN still carry a `roles` claim,
|
|
183
|
+
* resolved against the requesting client itself as the subject, whenever the
|
|
184
|
+
* client_credentials request's scope included `roles`; unlike `sub`, `roles`
|
|
185
|
+
* is not tied to there being an end user. Before this type existed,
|
|
186
|
+
* `verifyAccessToken` returned one flat claims object for both cases, so a
|
|
187
|
+
* caller checking `.sub` on a machine token silently got `undefined` instead
|
|
188
|
+
* of a type error telling it this branch has no subject.
|
|
187
189
|
*/
|
|
188
190
|
type VerifiedAccessToken = {
|
|
189
191
|
kind: 'user';
|
|
@@ -196,6 +198,11 @@ type VerifiedAccessToken = {
|
|
|
196
198
|
} | {
|
|
197
199
|
kind: 'machine';
|
|
198
200
|
clientId: string;
|
|
201
|
+
/** Present only when the client_credentials request's scope included
|
|
202
|
+
* `roles` -- the server resolves the claim against the requesting
|
|
203
|
+
* client itself as the subject in that case. Pass to `permits()` from
|
|
204
|
+
* `@tdacorp/identity-authz` to decide a permission locally. */
|
|
205
|
+
roles?: RolesClaim;
|
|
199
206
|
claims: Record<string, unknown>;
|
|
200
207
|
};
|
|
201
208
|
|
|
@@ -239,8 +246,14 @@ declare const CLOCK_SKEW_TOLERANCE_SECONDS = 60;
|
|
|
239
246
|
* - `'malformed-token'`: the token is not a well-formed JWT at all.
|
|
240
247
|
* - `'jwks-unavailable'`: the issuer's discovery document or JWKS could not
|
|
241
248
|
* be fetched -- infrastructure, not a verdict on the token.
|
|
249
|
+
* - `'wrong-token-type'`: the token verified (right issuer, right audience,
|
|
250
|
+
* right signature) but is the OTHER kind of token this server mints for a
|
|
251
|
+
* login -- an id_token presented to `verifyAccessToken`, or an access
|
|
252
|
+
* token presented to `verifyIdToken`. The two share `iss`/`sub`/`aud`/`azp`
|
|
253
|
+
* and are signed by the same key, so nothing else here would catch this;
|
|
254
|
+
* see `token_use` on `IdentityTokenPayload`.
|
|
242
255
|
*/
|
|
243
|
-
type TokenVerificationErrorCode = 'expired' | 'invalid-signature' | 'invalid-issuer' | 'invalid-audience' | 'unauthorized-party' | 'malformed-claims' | 'malformed-token' | 'jwks-unavailable';
|
|
256
|
+
type TokenVerificationErrorCode = 'expired' | 'invalid-signature' | 'invalid-issuer' | 'invalid-audience' | 'unauthorized-party' | 'malformed-claims' | 'malformed-token' | 'jwks-unavailable' | 'wrong-token-type';
|
|
244
257
|
/** One verification failure: a stable `code` to branch on, plus a
|
|
245
258
|
* human-readable `message` for logs. `verifyIdToken`/`verifyAccessToken`
|
|
246
259
|
* return a list of these rather than throwing -- see `VerificationResult`. */
|
package/package.json
CHANGED