@tdacorp/identity-client 0.1.0 → 0.2.1

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.
@@ -160,6 +176,23 @@ const tokens = await exchangeAuthorizationCode({
160
176
  })
161
177
  ```
162
178
 
179
+ If your relying party is registered on the identity platform for
180
+ `client_secret_post` rather than the default `client_secret_basic`, pass
181
+ `clientAuthMethod` so credentials are sent the way the token endpoint
182
+ actually expects them:
183
+
184
+ ```ts
185
+ const tokens = await exchangeAuthorizationCode({
186
+ issuer: 'https://identity.tdacorp.in',
187
+ clientId: process.env.IDENTITY_CLIENT_ID!,
188
+ clientSecret: process.env.IDENTITY_CLIENT_SECRET!,
189
+ clientAuthMethod: 'client_secret_post',
190
+ code,
191
+ redirectUri,
192
+ codeVerifier,
193
+ })
194
+ ```
195
+
163
196
  Refresh (RFC 6749 §6), returning a classified outcome instead of throwing —
164
197
  so a transient infrastructure failure is never mistaken for a rejected
165
198
  refresh token:
@@ -182,11 +215,13 @@ if (outcome.outcome === 'success') {
182
215
  Machine-to-machine calls with no end user use `clientCredentialsGrant` (RFC
183
216
  6749 §4.4) instead, always with a confidential `clientSecret` since there is
184
217
  no PKCE verifier to authenticate a public client with. A token minted this
185
- way carries no `sub` and no `roles` claim — verifying it with
186
- `verifyAccessToken` returns the `kind: 'machine'` variant of
187
- `VerifiedAccessToken`, distinct from the `kind: 'user'` variant a normal
188
- login produces, so reading `.sub` or `.roles` off the wrong branch is a
189
- 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.
190
225
 
191
226
  ## Verification
192
227
 
@@ -269,12 +304,6 @@ instead.
269
304
 
270
305
  ## Limitations in v0.1
271
306
 
272
- - **Only `client_secret_basic` client authentication is supported** for the
273
- authorization-code exchange (`exchangeAuthorizationCode` sends an HTTP
274
- Basic `Authorization` header when a `clientSecret` is given). A relying
275
- party configured on TDACorp Identity for `client_secret_post` cannot use
276
- `exchangeAuthorizationCode` as-is today — the token endpoint will reject
277
- the request.
278
307
  - **No built-in fallback for an `indeterminate` `permits()` result.** That is
279
308
  `@tdacorp/identity-authz`'s concern, not this package's, but worth knowing
280
309
  up front since most integrations hit both together — see that package's
@@ -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);
@@ -135,13 +139,16 @@ function toTokenSet(body) {
135
139
  scope: body.scope
136
140
  };
137
141
  }
138
- async function postTokenRequest(tokenEndpoint, clientId, clientSecret, params) {
142
+ async function postTokenRequest(tokenEndpoint, clientId, clientSecret, clientAuthMethod, params) {
139
143
  const body = new URLSearchParams(params);
140
144
  const headers = {
141
145
  "Content-Type": "application/x-www-form-urlencoded",
142
146
  Accept: "application/json"
143
147
  };
144
- if (clientSecret) {
148
+ if (clientSecret && clientAuthMethod === "client_secret_post") {
149
+ body.set("client_id", clientId);
150
+ body.set("client_secret", clientSecret);
151
+ } else if (clientSecret) {
145
152
  headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
146
153
  } else {
147
154
  body.set("client_id", clientId);
@@ -153,12 +160,18 @@ async function readTokenResponseBody(response) {
153
160
  }
154
161
  async function exchangeAuthorizationCode(options) {
155
162
  const discovery = await fetchDiscovery(options.issuer);
156
- const response = await postTokenRequest(discovery.token_endpoint, options.clientId, options.clientSecret, {
157
- grant_type: "authorization_code",
158
- code: options.code,
159
- redirect_uri: options.redirectUri,
160
- code_verifier: options.codeVerifier
161
- });
163
+ const response = await postTokenRequest(
164
+ discovery.token_endpoint,
165
+ options.clientId,
166
+ options.clientSecret,
167
+ options.clientAuthMethod ?? "client_secret_basic",
168
+ {
169
+ grant_type: "authorization_code",
170
+ code: options.code,
171
+ redirect_uri: options.redirectUri,
172
+ code_verifier: options.codeVerifier
173
+ }
174
+ );
162
175
  const body = await readTokenResponseBody(response);
163
176
  if (!response.ok || !body || body.error || typeof body.access_token !== "string") {
164
177
  throw new TokenRequestError({ status: response.status, error: body?.error, errorDescription: body?.error_description });
@@ -167,10 +180,16 @@ async function exchangeAuthorizationCode(options) {
167
180
  }
168
181
  async function clientCredentialsGrant(options) {
169
182
  const discovery = await fetchDiscovery(options.issuer);
170
- const response = await postTokenRequest(discovery.token_endpoint, options.clientId, options.clientSecret, {
171
- grant_type: "client_credentials",
172
- ...options.scope ? { scope: options.scope } : {}
173
- });
183
+ const response = await postTokenRequest(
184
+ discovery.token_endpoint,
185
+ options.clientId,
186
+ options.clientSecret,
187
+ options.clientAuthMethod ?? "client_secret_basic",
188
+ {
189
+ grant_type: "client_credentials",
190
+ ...options.scope ? { scope: options.scope } : {}
191
+ }
192
+ );
174
193
  const body = await readTokenResponseBody(response);
175
194
  if (!response.ok || !body || body.error || typeof body.access_token !== "string") {
176
195
  throw new TokenRequestError({ status: response.status, error: body?.error, errorDescription: body?.error_description });
@@ -185,10 +204,16 @@ async function refreshTokens(options) {
185
204
  let response;
186
205
  try {
187
206
  const discovery = await fetchDiscovery(options.issuer);
188
- response = await postTokenRequest(discovery.token_endpoint, options.clientId, options.clientSecret, {
189
- grant_type: "refresh_token",
190
- refresh_token: options.refreshToken
191
- });
207
+ response = await postTokenRequest(
208
+ discovery.token_endpoint,
209
+ options.clientId,
210
+ options.clientSecret,
211
+ options.clientAuthMethod ?? "client_secret_basic",
212
+ {
213
+ grant_type: "refresh_token",
214
+ refresh_token: options.refreshToken
215
+ }
216
+ );
192
217
  } catch (error) {
193
218
  return { outcome: "transient", error };
194
219
  }
@@ -301,7 +326,7 @@ async function verifyAccessToken(accessToken, options) {
301
326
  errors: [{ code: "malformed-claims", message: "Access token has neither a sub (user) nor an azp (machine client id)" }]
302
327
  };
303
328
  }
304
- return { success: true, data: { kind: "machine", clientId: payload.azp, claims } };
329
+ return { success: true, data: { kind: "machine", clientId: payload.azp, roles, claims } };
305
330
  }
306
331
 
307
332
  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);
@@ -172,13 +176,16 @@ function toTokenSet(body) {
172
176
  scope: body.scope
173
177
  };
174
178
  }
175
- async function postTokenRequest(tokenEndpoint, clientId, clientSecret, params) {
179
+ async function postTokenRequest(tokenEndpoint, clientId, clientSecret, clientAuthMethod, params) {
176
180
  const body = new URLSearchParams(params);
177
181
  const headers = {
178
182
  "Content-Type": "application/x-www-form-urlencoded",
179
183
  Accept: "application/json"
180
184
  };
181
- if (clientSecret) {
185
+ if (clientSecret && clientAuthMethod === "client_secret_post") {
186
+ body.set("client_id", clientId);
187
+ body.set("client_secret", clientSecret);
188
+ } else if (clientSecret) {
182
189
  headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
183
190
  } else {
184
191
  body.set("client_id", clientId);
@@ -190,12 +197,18 @@ async function readTokenResponseBody(response) {
190
197
  }
191
198
  async function exchangeAuthorizationCode(options) {
192
199
  const discovery = await fetchDiscovery(options.issuer);
193
- const response = await postTokenRequest(discovery.token_endpoint, options.clientId, options.clientSecret, {
194
- grant_type: "authorization_code",
195
- code: options.code,
196
- redirect_uri: options.redirectUri,
197
- code_verifier: options.codeVerifier
198
- });
200
+ const response = await postTokenRequest(
201
+ discovery.token_endpoint,
202
+ options.clientId,
203
+ options.clientSecret,
204
+ options.clientAuthMethod ?? "client_secret_basic",
205
+ {
206
+ grant_type: "authorization_code",
207
+ code: options.code,
208
+ redirect_uri: options.redirectUri,
209
+ code_verifier: options.codeVerifier
210
+ }
211
+ );
199
212
  const body = await readTokenResponseBody(response);
200
213
  if (!response.ok || !body || body.error || typeof body.access_token !== "string") {
201
214
  throw new TokenRequestError({ status: response.status, error: body?.error, errorDescription: body?.error_description });
@@ -204,10 +217,16 @@ async function exchangeAuthorizationCode(options) {
204
217
  }
205
218
  async function clientCredentialsGrant(options) {
206
219
  const discovery = await fetchDiscovery(options.issuer);
207
- const response = await postTokenRequest(discovery.token_endpoint, options.clientId, options.clientSecret, {
208
- grant_type: "client_credentials",
209
- ...options.scope ? { scope: options.scope } : {}
210
- });
220
+ const response = await postTokenRequest(
221
+ discovery.token_endpoint,
222
+ options.clientId,
223
+ options.clientSecret,
224
+ options.clientAuthMethod ?? "client_secret_basic",
225
+ {
226
+ grant_type: "client_credentials",
227
+ ...options.scope ? { scope: options.scope } : {}
228
+ }
229
+ );
211
230
  const body = await readTokenResponseBody(response);
212
231
  if (!response.ok || !body || body.error || typeof body.access_token !== "string") {
213
232
  throw new TokenRequestError({ status: response.status, error: body?.error, errorDescription: body?.error_description });
@@ -222,10 +241,16 @@ async function refreshTokens(options) {
222
241
  let response;
223
242
  try {
224
243
  const discovery = await fetchDiscovery(options.issuer);
225
- response = await postTokenRequest(discovery.token_endpoint, options.clientId, options.clientSecret, {
226
- grant_type: "refresh_token",
227
- refresh_token: options.refreshToken
228
- });
244
+ response = await postTokenRequest(
245
+ discovery.token_endpoint,
246
+ options.clientId,
247
+ options.clientSecret,
248
+ options.clientAuthMethod ?? "client_secret_basic",
249
+ {
250
+ grant_type: "refresh_token",
251
+ refresh_token: options.refreshToken
252
+ }
253
+ );
229
254
  } catch (error) {
230
255
  return { outcome: "transient", error };
231
256
  }
@@ -338,7 +363,7 @@ async function verifyAccessToken(accessToken, options) {
338
363
  errors: [{ code: "malformed-claims", message: "Access token has neither a sub (user) nor an azp (machine client id)" }]
339
364
  };
340
365
  }
341
- return { success: true, data: { kind: "machine", clientId: payload.azp, claims } };
366
+ return { success: true, data: { kind: "machine", clientId: payload.azp, roles, claims } };
342
367
  }
343
368
  // Annotate the CommonJS export names for ESM import in node:
344
369
  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 TokenRefreshOutcome, b as TokenRequestError, c as TokenSet, d as TokenVerificationError, e as TokenVerificationErrorCode, V as VerificationResult, f as VerifiedAccessToken, g as VerifyTokenOptions, h as clientCredentialsGrant, i as exchangeAuthorizationCode, r as refreshTokens, v as verifyAccessToken, j as verifyIdToken } from './verify-BiGhwaIz.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-C1Ga6JbS.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 TokenRefreshOutcome, b as TokenRequestError, c as TokenSet, d as TokenVerificationError, e as TokenVerificationErrorCode, V as VerificationResult, f as VerifiedAccessToken, g as VerifyTokenOptions, h as clientCredentialsGrant, i as exchangeAuthorizationCode, r as refreshTokens, v as verifyAccessToken, j as verifyIdToken } from './verify-BiGhwaIz.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-C1Ga6JbS.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-VJSMXIZB.js";
14
+ } from "./chunk-S5WGUOJA.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);
@@ -163,13 +167,16 @@ function toTokenSet(body) {
163
167
  scope: body.scope
164
168
  };
165
169
  }
166
- async function postTokenRequest(tokenEndpoint, clientId, clientSecret, params) {
170
+ async function postTokenRequest(tokenEndpoint, clientId, clientSecret, clientAuthMethod, params) {
167
171
  const body = new URLSearchParams(params);
168
172
  const headers = {
169
173
  "Content-Type": "application/x-www-form-urlencoded",
170
174
  Accept: "application/json"
171
175
  };
172
- if (clientSecret) {
176
+ if (clientSecret && clientAuthMethod === "client_secret_post") {
177
+ body.set("client_id", clientId);
178
+ body.set("client_secret", clientSecret);
179
+ } else if (clientSecret) {
173
180
  headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
174
181
  } else {
175
182
  body.set("client_id", clientId);
@@ -181,12 +188,18 @@ async function readTokenResponseBody(response) {
181
188
  }
182
189
  async function exchangeAuthorizationCode(options) {
183
190
  const discovery = await fetchDiscovery(options.issuer);
184
- const response = await postTokenRequest(discovery.token_endpoint, options.clientId, options.clientSecret, {
185
- grant_type: "authorization_code",
186
- code: options.code,
187
- redirect_uri: options.redirectUri,
188
- code_verifier: options.codeVerifier
189
- });
191
+ const response = await postTokenRequest(
192
+ discovery.token_endpoint,
193
+ options.clientId,
194
+ options.clientSecret,
195
+ options.clientAuthMethod ?? "client_secret_basic",
196
+ {
197
+ grant_type: "authorization_code",
198
+ code: options.code,
199
+ redirect_uri: options.redirectUri,
200
+ code_verifier: options.codeVerifier
201
+ }
202
+ );
190
203
  const body = await readTokenResponseBody(response);
191
204
  if (!response.ok || !body || body.error || typeof body.access_token !== "string") {
192
205
  throw new TokenRequestError({ status: response.status, error: body?.error, errorDescription: body?.error_description });
@@ -347,6 +360,17 @@ async function unseal(sealedValue, options) {
347
360
  var TRANSACTION_TTL_SECONDS = 600;
348
361
  var DEFAULT_TRANSACTION_COOKIE = "identity_oauth_txn";
349
362
  var TRANSACTION_PURPOSE = "identity-client:oauth-transaction";
363
+ function sanitizeReturnTo(returnTo) {
364
+ if (!returnTo) return void 0;
365
+ if (/[\x00-\x1f]/.test(returnTo)) return void 0;
366
+ if (returnTo.startsWith("//")) return void 0;
367
+ if (returnTo.includes("\\")) return void 0;
368
+ const lower = returnTo.toLowerCase().trim();
369
+ if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:") || lower.startsWith("file:")) {
370
+ return void 0;
371
+ }
372
+ return returnTo.startsWith("/") ? returnTo : void 0;
373
+ }
350
374
  function transactionCookieOptions(maxAge) {
351
375
  return {
352
376
  httpOnly: true,
@@ -361,7 +385,8 @@ function createLoginRoute(config) {
361
385
  const state = generateState();
362
386
  const codeVerifier = generateCodeVerifier();
363
387
  const codeChallenge = await generateCodeChallenge(codeVerifier);
364
- const returnTo = config.getReturnTo?.(request) ?? request.nextUrl.searchParams.get("returnTo") ?? void 0;
388
+ const rawReturnTo = config.getReturnTo?.(request) ?? request.nextUrl.searchParams.get("returnTo") ?? void 0;
389
+ const returnTo = sanitizeReturnTo(rawReturnTo);
365
390
  const transaction = { state, codeVerifier, returnTo };
366
391
  const sealedTransaction = await seal(transaction, {
367
392
  secret: config.cookieSecret,
package/dist/next.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { NextResponse, NextRequest } from 'next/server';
2
- import { c as TokenSet, I as IdTokenClaims } from './verify-BiGhwaIz.cjs';
2
+ import { d as TokenSet, I as IdTokenClaims } from './verify-C1Ga6JbS.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 { c as TokenSet, I as IdTokenClaims } from './verify-BiGhwaIz.js';
2
+ import { d as TokenSet, I as IdTokenClaims } from './verify-C1Ga6JbS.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-VJSMXIZB.js";
8
+ } from "./chunk-S5WGUOJA.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,
@@ -31,6 +31,10 @@ declare class TokenRequestError extends Error {
31
31
  errorDescription?: string;
32
32
  });
33
33
  }
34
+ /** The token endpoint client-authentication methods this package supports --
35
+ * see RFC 6749 §2.3.1 and OIDC Discovery 1.0's
36
+ * `token_endpoint_auth_methods_supported`. */
37
+ type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post';
34
38
  /** Options for `exchangeAuthorizationCode`. */
35
39
  interface AuthorizationCodeExchangeOptions {
36
40
  /** The identity server's issuer URL, used to look up `token_endpoint`. */
@@ -46,6 +50,16 @@ interface AuthorizationCodeExchangeOptions {
46
50
  /** The PKCE verifier generated (and kept) alongside the code challenge
47
51
  * sent on the authorization request; see `generateCodeVerifier`. */
48
52
  codeVerifier: string;
53
+ /** How to send `clientSecret` to the token endpoint. Defaults to
54
+ * `'client_secret_basic'`, which is this package's original and only
55
+ * behavior -- omitting this field changes nothing for an existing caller.
56
+ * Set it to `'client_secret_post'` if the relying party is registered on
57
+ * the identity platform for that method instead: sending Basic auth to a
58
+ * `client_secret_post`-only client gets `invalid_client` back, since the
59
+ * token endpoint never receives credentials in the form it expects. An
60
+ * issuer's OIDC discovery document's `token_endpoint_auth_methods_supported`
61
+ * says which methods it actually accepts. */
62
+ clientAuthMethod?: TokenEndpointAuthMethod;
49
63
  }
50
64
  /** RFC 6749 §4.1.3 / RFC 7636 §4.5: exchanges an authorization code for a
51
65
  * token set. Throws `TokenRequestError` on any non-success response. */
@@ -60,6 +74,16 @@ interface ClientCredentialsGrantOptions {
60
74
  clientSecret: string;
61
75
  /** Space-separated scopes to request for the resulting token. */
62
76
  scope?: string;
77
+ /** How to send `clientSecret` to the token endpoint. Defaults to
78
+ * `'client_secret_basic'`, which is this package's original and only
79
+ * behavior -- omitting this field changes nothing for an existing caller.
80
+ * Set it to `'client_secret_post'` if the relying party is registered on
81
+ * the identity platform for that method instead: sending Basic auth to a
82
+ * `client_secret_post`-only client gets `invalid_client` back, since the
83
+ * token endpoint never receives credentials in the form it expects. An
84
+ * issuer's OIDC discovery document's `token_endpoint_auth_methods_supported`
85
+ * says which methods it actually accepts. */
86
+ clientAuthMethod?: TokenEndpointAuthMethod;
63
87
  }
64
88
  /** RFC 6749 §4.4: mints a token with no end user, for service-to-service
65
89
  * calls. Always confidential -- `clientSecret` is required, not optional,
@@ -75,6 +99,16 @@ interface RefreshTokensOptions {
75
99
  clientSecret?: string;
76
100
  /** The refresh token to redeem. */
77
101
  refreshToken: string;
102
+ /** How to send `clientSecret` to the token endpoint. Defaults to
103
+ * `'client_secret_basic'`, which is this package's original and only
104
+ * behavior -- omitting this field changes nothing for an existing caller.
105
+ * Set it to `'client_secret_post'` if the relying party is registered on
106
+ * the identity platform for that method instead: sending Basic auth to a
107
+ * `client_secret_post`-only client gets `invalid_client` back, since the
108
+ * token endpoint never receives credentials in the form it expects. An
109
+ * issuer's OIDC discovery document's `token_endpoint_auth_methods_supported`
110
+ * says which methods it actually accepts. */
111
+ clientAuthMethod?: TokenEndpointAuthMethod;
78
112
  }
79
113
  type TokenRefreshOutcome = {
80
114
  outcome: 'success';
@@ -144,12 +178,14 @@ declare function refreshTokens(options: RefreshTokensOptions): Promise<TokenRefr
144
178
  * read as "no roles" or a machine's `clientId` misread as a user's `sub`.
145
179
  *
146
180
  * The concrete reason this matters here: this identity server's
147
- * `client_credentials` grant mints access tokens with no end user and no
148
- * `roles` claim at all -- there is no subject for a roles claim to describe.
149
- * Before this type existed, `verifyAccessToken` returned one flat claims
150
- * object for both cases, so a caller checking `.sub` on a machine token
151
- * silently got `undefined` instead of a type error telling it this branch
152
- * 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.
153
189
  */
154
190
  type VerifiedAccessToken = {
155
191
  kind: 'user';
@@ -162,6 +198,11 @@ type VerifiedAccessToken = {
162
198
  } | {
163
199
  kind: 'machine';
164
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;
165
206
  claims: Record<string, unknown>;
166
207
  };
167
208
 
@@ -303,4 +344,4 @@ declare function verifyIdToken(idToken: string, options: VerifyTokenOptions): Pr
303
344
  * contract as `verifyIdToken`. */
304
345
  declare function verifyAccessToken(accessToken: string, options: VerifyTokenOptions): Promise<VerificationResult<VerifiedAccessToken>>;
305
346
 
306
- export { type AuthorizationCodeExchangeOptions as A, CLOCK_SKEW_TOLERANCE_SECONDS as C, type IdTokenClaims as I, type RefreshTokensOptions as R, type TokenRefreshOutcome as T, type VerificationResult as V, type ClientCredentialsGrantOptions as a, TokenRequestError as b, type TokenSet as c, type TokenVerificationError as d, type TokenVerificationErrorCode as e, type VerifiedAccessToken as f, type VerifyTokenOptions as g, clientCredentialsGrant as h, exchangeAuthorizationCode as i, verifyIdToken as j, refreshTokens as r, verifyAccessToken as v };
347
+ export { type AuthorizationCodeExchangeOptions as A, CLOCK_SKEW_TOLERANCE_SECONDS as C, type IdTokenClaims as I, type RefreshTokensOptions as R, type TokenEndpointAuthMethod as T, type VerificationResult as V, type ClientCredentialsGrantOptions as a, type TokenRefreshOutcome as b, TokenRequestError as c, type TokenSet as d, type TokenVerificationError as e, type TokenVerificationErrorCode as f, type VerifiedAccessToken as g, type VerifyTokenOptions as h, clientCredentialsGrant as i, exchangeAuthorizationCode as j, verifyIdToken as k, refreshTokens as r, verifyAccessToken as v };
@@ -31,6 +31,10 @@ declare class TokenRequestError extends Error {
31
31
  errorDescription?: string;
32
32
  });
33
33
  }
34
+ /** The token endpoint client-authentication methods this package supports --
35
+ * see RFC 6749 §2.3.1 and OIDC Discovery 1.0's
36
+ * `token_endpoint_auth_methods_supported`. */
37
+ type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post';
34
38
  /** Options for `exchangeAuthorizationCode`. */
35
39
  interface AuthorizationCodeExchangeOptions {
36
40
  /** The identity server's issuer URL, used to look up `token_endpoint`. */
@@ -46,6 +50,16 @@ interface AuthorizationCodeExchangeOptions {
46
50
  /** The PKCE verifier generated (and kept) alongside the code challenge
47
51
  * sent on the authorization request; see `generateCodeVerifier`. */
48
52
  codeVerifier: string;
53
+ /** How to send `clientSecret` to the token endpoint. Defaults to
54
+ * `'client_secret_basic'`, which is this package's original and only
55
+ * behavior -- omitting this field changes nothing for an existing caller.
56
+ * Set it to `'client_secret_post'` if the relying party is registered on
57
+ * the identity platform for that method instead: sending Basic auth to a
58
+ * `client_secret_post`-only client gets `invalid_client` back, since the
59
+ * token endpoint never receives credentials in the form it expects. An
60
+ * issuer's OIDC discovery document's `token_endpoint_auth_methods_supported`
61
+ * says which methods it actually accepts. */
62
+ clientAuthMethod?: TokenEndpointAuthMethod;
49
63
  }
50
64
  /** RFC 6749 §4.1.3 / RFC 7636 §4.5: exchanges an authorization code for a
51
65
  * token set. Throws `TokenRequestError` on any non-success response. */
@@ -60,6 +74,16 @@ interface ClientCredentialsGrantOptions {
60
74
  clientSecret: string;
61
75
  /** Space-separated scopes to request for the resulting token. */
62
76
  scope?: string;
77
+ /** How to send `clientSecret` to the token endpoint. Defaults to
78
+ * `'client_secret_basic'`, which is this package's original and only
79
+ * behavior -- omitting this field changes nothing for an existing caller.
80
+ * Set it to `'client_secret_post'` if the relying party is registered on
81
+ * the identity platform for that method instead: sending Basic auth to a
82
+ * `client_secret_post`-only client gets `invalid_client` back, since the
83
+ * token endpoint never receives credentials in the form it expects. An
84
+ * issuer's OIDC discovery document's `token_endpoint_auth_methods_supported`
85
+ * says which methods it actually accepts. */
86
+ clientAuthMethod?: TokenEndpointAuthMethod;
63
87
  }
64
88
  /** RFC 6749 §4.4: mints a token with no end user, for service-to-service
65
89
  * calls. Always confidential -- `clientSecret` is required, not optional,
@@ -75,6 +99,16 @@ interface RefreshTokensOptions {
75
99
  clientSecret?: string;
76
100
  /** The refresh token to redeem. */
77
101
  refreshToken: string;
102
+ /** How to send `clientSecret` to the token endpoint. Defaults to
103
+ * `'client_secret_basic'`, which is this package's original and only
104
+ * behavior -- omitting this field changes nothing for an existing caller.
105
+ * Set it to `'client_secret_post'` if the relying party is registered on
106
+ * the identity platform for that method instead: sending Basic auth to a
107
+ * `client_secret_post`-only client gets `invalid_client` back, since the
108
+ * token endpoint never receives credentials in the form it expects. An
109
+ * issuer's OIDC discovery document's `token_endpoint_auth_methods_supported`
110
+ * says which methods it actually accepts. */
111
+ clientAuthMethod?: TokenEndpointAuthMethod;
78
112
  }
79
113
  type TokenRefreshOutcome = {
80
114
  outcome: 'success';
@@ -144,12 +178,14 @@ declare function refreshTokens(options: RefreshTokensOptions): Promise<TokenRefr
144
178
  * read as "no roles" or a machine's `clientId` misread as a user's `sub`.
145
179
  *
146
180
  * The concrete reason this matters here: this identity server's
147
- * `client_credentials` grant mints access tokens with no end user and no
148
- * `roles` claim at all -- there is no subject for a roles claim to describe.
149
- * Before this type existed, `verifyAccessToken` returned one flat claims
150
- * object for both cases, so a caller checking `.sub` on a machine token
151
- * silently got `undefined` instead of a type error telling it this branch
152
- * 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.
153
189
  */
154
190
  type VerifiedAccessToken = {
155
191
  kind: 'user';
@@ -162,6 +198,11 @@ type VerifiedAccessToken = {
162
198
  } | {
163
199
  kind: 'machine';
164
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;
165
206
  claims: Record<string, unknown>;
166
207
  };
167
208
 
@@ -303,4 +344,4 @@ declare function verifyIdToken(idToken: string, options: VerifyTokenOptions): Pr
303
344
  * contract as `verifyIdToken`. */
304
345
  declare function verifyAccessToken(accessToken: string, options: VerifyTokenOptions): Promise<VerificationResult<VerifiedAccessToken>>;
305
346
 
306
- export { type AuthorizationCodeExchangeOptions as A, CLOCK_SKEW_TOLERANCE_SECONDS as C, type IdTokenClaims as I, type RefreshTokensOptions as R, type TokenRefreshOutcome as T, type VerificationResult as V, type ClientCredentialsGrantOptions as a, TokenRequestError as b, type TokenSet as c, type TokenVerificationError as d, type TokenVerificationErrorCode as e, type VerifiedAccessToken as f, type VerifyTokenOptions as g, clientCredentialsGrant as h, exchangeAuthorizationCode as i, verifyIdToken as j, refreshTokens as r, verifyAccessToken as v };
347
+ export { type AuthorizationCodeExchangeOptions as A, CLOCK_SKEW_TOLERANCE_SECONDS as C, type IdTokenClaims as I, type RefreshTokensOptions as R, type TokenEndpointAuthMethod as T, type VerificationResult as V, type ClientCredentialsGrantOptions as a, type TokenRefreshOutcome as b, TokenRequestError as c, type TokenSet as d, type TokenVerificationError as e, type TokenVerificationErrorCode as f, type VerifiedAccessToken as g, type VerifyTokenOptions as h, clientCredentialsGrant as i, exchangeAuthorizationCode as j, verifyIdToken as k, refreshTokens as r, verifyAccessToken as v };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tdacorp/identity-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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": {
@@ -50,17 +50,12 @@
50
50
  "publishConfig": {
51
51
  "access": "public"
52
52
  },
53
- "scripts": {
54
- "build": "tsup",
55
- "typecheck": "tsc --noEmit",
56
- "test": "vitest run"
57
- },
58
53
  "files": [
59
54
  "dist"
60
55
  ],
61
56
  "dependencies": {
62
- "@tdacorp/identity-authz": "workspace:*",
63
- "jose": "^6.2.9"
57
+ "jose": "^6.2.9",
58
+ "@tdacorp/identity-authz": "^0.2.0"
64
59
  },
65
60
  "peerDependencies": {
66
61
  "next": ">=14"
@@ -71,10 +66,15 @@
71
66
  }
72
67
  },
73
68
  "devDependencies": {
74
- "@tdacorp/typescript-config": "workspace:*",
75
69
  "next": "^16.3.2",
76
70
  "tsup": "^8.5.1",
77
71
  "typescript": "^5.7.3",
78
- "vitest": "^4.1.11"
72
+ "vitest": "^4.1.11",
73
+ "@tdacorp/typescript-config": "0.0.0"
74
+ },
75
+ "scripts": {
76
+ "build": "tsup",
77
+ "typecheck": "tsc --noEmit",
78
+ "test": "vitest run"
79
79
  }
80
- }
80
+ }