@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 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` must be https (loopback hosts
123
- excepted) and same-origin with the issuer. This is what stops a compromised
124
- edge cache or a cached error page from redirecting key material or the token
125
- endpoint somewhere untrusted.
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` and no `roles` claim — verifying it with
203
- `verifyAccessToken` returns the `kind: 'machine'` variant of
204
- `VerifiedAccessToken`, distinct from the `kind: 'user'` variant a normal
205
- login produces, so reading `.sub` or `.roles` off the wrong branch is a
206
- compile error rather than `undefined`.
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-DTUDjR4v.cjs';
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-DTUDjR4v.js';
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
@@ -11,7 +11,7 @@ import {
11
11
  refreshTokens,
12
12
  verifyAccessToken,
13
13
  verifyIdToken
14
- } from "./chunk-6WGVTO7C.js";
14
+ } from "./chunk-TTFIPEZ3.js";
15
15
  export {
16
16
  CLOCK_SKEW_TOLERANCE_SECONDS,
17
17
  TokenRequestError,
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 returnTo = config.getReturnTo?.(request) ?? request.nextUrl.searchParams.get("returnTo") ?? void 0;
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-DTUDjR4v.cjs';
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-DTUDjR4v.js';
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-6WGVTO7C.js";
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 returnTo = config.getReturnTo?.(request) ?? request.nextUrl.searchParams.get("returnTo") ?? void 0;
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 and no
182
- * `roles` claim at all -- there is no subject for a roles claim to describe.
183
- * Before this type existed, `verifyAccessToken` returned one flat claims
184
- * object for both cases, so a caller checking `.sub` on a machine token
185
- * silently got `undefined` instead of a type error telling it this branch
186
- * has no subject.
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 and no
182
- * `roles` claim at all -- there is no subject for a roles claim to describe.
183
- * Before this type existed, `verifyAccessToken` returned one flat claims
184
- * object for both cases, so a caller checking `.sub` on a machine token
185
- * silently got `undefined` instead of a type error telling it this branch
186
- * has no subject.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tdacorp/identity-client",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "OIDC relying-party client for TDACorp Identity: discovery, PKCE, token exchange, token verification and Next.js route helpers.",
5
5
  "license": "MIT",
6
6
  "bugs": {