@tdacorp/identity-client 0.2.4 → 0.2.6

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
@@ -302,6 +302,26 @@ breaking rename (`middleware.ts` → `proxy.ts`) to keep working on Next.js
302
302
  package ships Route Handlers a consuming app mounts wherever it wants
303
303
  instead.
304
304
 
305
+ Both factories' `redirectUri` accepts a function as well as a plain string —
306
+ `(request) => string`, useful when the app is reachable at more than one
307
+ origin (a preview deployment per branch, say):
308
+
309
+ ```ts
310
+ redirectUri: (request) => `${request.nextUrl.origin}/api/auth/callback`,
311
+ ```
312
+
313
+ The resolved value must be identical on both the login and callback route's
314
+ own `redirectUri`, since the token endpoint checks it against what the
315
+ authorization request used (RFC 6749 §4.1.3) — true automatically for
316
+ `request.nextUrl.origin`, since one browser session's login and callback
317
+ requests share a domain.
318
+
319
+ `createCallbackRoute` also accepts `clientAuthMethod`, the same option
320
+ `exchangeAuthorizationCode` takes directly (see "Token exchange, refresh,
321
+ and client credentials" above) — set it to `'client_secret_post'` if your
322
+ relying party is registered on the identity platform for that method
323
+ instead of the default `client_secret_basic`.
324
+
305
325
  ## Limitations in v0.1
306
326
 
307
327
  - **No built-in fallback for an `indeterminate` `permits()` result.** That is
@@ -1,6 +1,7 @@
1
1
  // src/discovery.ts
2
2
  import { createRemoteJWKSet } from "jose";
3
3
  var DISCOVERY_CACHE_TTL_MS = 6e5;
4
+ var FETCH_TIMEOUT_MS = 2500;
4
5
  var CACHE_MAX_ENTRIES = 100;
5
6
  var discoveryCache = /* @__PURE__ */ new Map();
6
7
  var discoveryPending = /* @__PURE__ */ new Map();
@@ -71,7 +72,10 @@ async function fetchDiscovery(issuerUrl) {
71
72
  }
72
73
  const pending = discoveryPending.get(issuerUrl);
73
74
  if (pending) return pending;
74
- const promise = fetch(discoveryUrl(issuerUrl), { headers: { Accept: "application/json" } }).then(
75
+ const promise = fetch(discoveryUrl(issuerUrl), {
76
+ headers: { Accept: "application/json" },
77
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
78
+ }).then(
75
79
  async (response) => {
76
80
  if (!response.ok) {
77
81
  throw new Error(`Discovery fetch for ${issuerUrl} failed: HTTP ${response.status}`);
@@ -128,6 +132,7 @@ function generateState() {
128
132
  }
129
133
 
130
134
  // src/token.ts
135
+ var FETCH_TIMEOUT_MS2 = 2500;
131
136
  var TokenRequestError = class extends Error {
132
137
  status;
133
138
  error;
@@ -176,14 +181,29 @@ async function postTokenRequest(tokenEndpoint, clientId, clientSecret, clientAut
176
181
  } else {
177
182
  body.set("client_id", clientId);
178
183
  }
179
- return fetch(tokenEndpoint, { method: "POST", headers, body: body.toString() });
184
+ return fetch(tokenEndpoint, {
185
+ method: "POST",
186
+ headers,
187
+ body: body.toString(),
188
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS2)
189
+ });
190
+ }
191
+ async function postTokenRequestOrThrow(tokenEndpoint, clientId, clientSecret, clientAuthMethod, params) {
192
+ try {
193
+ return await postTokenRequest(tokenEndpoint, clientId, clientSecret, clientAuthMethod, params);
194
+ } catch (error) {
195
+ throw new TokenRequestError({
196
+ error: "network_failure",
197
+ errorDescription: error instanceof Error ? error.message : "Failed to reach the token endpoint"
198
+ });
199
+ }
180
200
  }
181
201
  async function readTokenResponseBody(response) {
182
202
  return await response.json().catch(() => null);
183
203
  }
184
204
  async function exchangeAuthorizationCode(options) {
185
205
  const discovery = await fetchDiscoveryForTokenRequest(options.issuer);
186
- const response = await postTokenRequest(
206
+ const response = await postTokenRequestOrThrow(
187
207
  discovery.token_endpoint,
188
208
  options.clientId,
189
209
  options.clientSecret,
@@ -203,7 +223,7 @@ async function exchangeAuthorizationCode(options) {
203
223
  }
204
224
  async function clientCredentialsGrant(options) {
205
225
  const discovery = await fetchDiscoveryForTokenRequest(options.issuer);
206
- const response = await postTokenRequest(
226
+ const response = await postTokenRequestOrThrow(
207
227
  discovery.token_endpoint,
208
228
  options.clientId,
209
229
  options.clientSecret,
package/dist/index.cjs CHANGED
@@ -38,6 +38,7 @@ module.exports = __toCommonJS(index_exports);
38
38
  // src/discovery.ts
39
39
  var import_jose = require("jose");
40
40
  var DISCOVERY_CACHE_TTL_MS = 6e5;
41
+ var FETCH_TIMEOUT_MS = 2500;
41
42
  var CACHE_MAX_ENTRIES = 100;
42
43
  var discoveryCache = /* @__PURE__ */ new Map();
43
44
  var discoveryPending = /* @__PURE__ */ new Map();
@@ -108,7 +109,10 @@ async function fetchDiscovery(issuerUrl) {
108
109
  }
109
110
  const pending = discoveryPending.get(issuerUrl);
110
111
  if (pending) return pending;
111
- const promise = fetch(discoveryUrl(issuerUrl), { headers: { Accept: "application/json" } }).then(
112
+ const promise = fetch(discoveryUrl(issuerUrl), {
113
+ headers: { Accept: "application/json" },
114
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
115
+ }).then(
112
116
  async (response) => {
113
117
  if (!response.ok) {
114
118
  throw new Error(`Discovery fetch for ${issuerUrl} failed: HTTP ${response.status}`);
@@ -165,6 +169,7 @@ function generateState() {
165
169
  }
166
170
 
167
171
  // src/token.ts
172
+ var FETCH_TIMEOUT_MS2 = 2500;
168
173
  var TokenRequestError = class extends Error {
169
174
  status;
170
175
  error;
@@ -213,14 +218,29 @@ async function postTokenRequest(tokenEndpoint, clientId, clientSecret, clientAut
213
218
  } else {
214
219
  body.set("client_id", clientId);
215
220
  }
216
- return fetch(tokenEndpoint, { method: "POST", headers, body: body.toString() });
221
+ return fetch(tokenEndpoint, {
222
+ method: "POST",
223
+ headers,
224
+ body: body.toString(),
225
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS2)
226
+ });
227
+ }
228
+ async function postTokenRequestOrThrow(tokenEndpoint, clientId, clientSecret, clientAuthMethod, params) {
229
+ try {
230
+ return await postTokenRequest(tokenEndpoint, clientId, clientSecret, clientAuthMethod, params);
231
+ } catch (error) {
232
+ throw new TokenRequestError({
233
+ error: "network_failure",
234
+ errorDescription: error instanceof Error ? error.message : "Failed to reach the token endpoint"
235
+ });
236
+ }
217
237
  }
218
238
  async function readTokenResponseBody(response) {
219
239
  return await response.json().catch(() => null);
220
240
  }
221
241
  async function exchangeAuthorizationCode(options) {
222
242
  const discovery = await fetchDiscoveryForTokenRequest(options.issuer);
223
- const response = await postTokenRequest(
243
+ const response = await postTokenRequestOrThrow(
224
244
  discovery.token_endpoint,
225
245
  options.clientId,
226
246
  options.clientSecret,
@@ -240,7 +260,7 @@ async function exchangeAuthorizationCode(options) {
240
260
  }
241
261
  async function clientCredentialsGrant(options) {
242
262
  const discovery = await fetchDiscoveryForTokenRequest(options.issuer);
243
- const response = await postTokenRequest(
263
+ const response = await postTokenRequestOrThrow(
244
264
  discovery.token_endpoint,
245
265
  options.clientId,
246
266
  options.clientSecret,
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  refreshTokens,
12
12
  verifyAccessToken,
13
13
  verifyIdToken
14
- } from "./chunk-HO6U23GY.js";
14
+ } from "./chunk-36WOXP7V.js";
15
15
  export {
16
16
  CLOCK_SKEW_TOLERANCE_SECONDS,
17
17
  TokenRequestError,
package/dist/next.cjs CHANGED
@@ -29,6 +29,7 @@ var import_server = require("next/server");
29
29
  // src/discovery.ts
30
30
  var import_jose = require("jose");
31
31
  var DISCOVERY_CACHE_TTL_MS = 6e5;
32
+ var FETCH_TIMEOUT_MS = 2500;
32
33
  var CACHE_MAX_ENTRIES = 100;
33
34
  var discoveryCache = /* @__PURE__ */ new Map();
34
35
  var discoveryPending = /* @__PURE__ */ new Map();
@@ -99,7 +100,10 @@ async function fetchDiscovery(issuerUrl) {
99
100
  }
100
101
  const pending = discoveryPending.get(issuerUrl);
101
102
  if (pending) return pending;
102
- const promise = fetch(discoveryUrl(issuerUrl), { headers: { Accept: "application/json" } }).then(
103
+ const promise = fetch(discoveryUrl(issuerUrl), {
104
+ headers: { Accept: "application/json" },
105
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
106
+ }).then(
103
107
  async (response) => {
104
108
  if (!response.ok) {
105
109
  throw new Error(`Discovery fetch for ${issuerUrl} failed: HTTP ${response.status}`);
@@ -156,6 +160,7 @@ function generateState() {
156
160
  }
157
161
 
158
162
  // src/token.ts
163
+ var FETCH_TIMEOUT_MS2 = 2500;
159
164
  var TokenRequestError = class extends Error {
160
165
  status;
161
166
  error;
@@ -204,14 +209,29 @@ async function postTokenRequest(tokenEndpoint, clientId, clientSecret, clientAut
204
209
  } else {
205
210
  body.set("client_id", clientId);
206
211
  }
207
- return fetch(tokenEndpoint, { method: "POST", headers, body: body.toString() });
212
+ return fetch(tokenEndpoint, {
213
+ method: "POST",
214
+ headers,
215
+ body: body.toString(),
216
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS2)
217
+ });
218
+ }
219
+ async function postTokenRequestOrThrow(tokenEndpoint, clientId, clientSecret, clientAuthMethod, params) {
220
+ try {
221
+ return await postTokenRequest(tokenEndpoint, clientId, clientSecret, clientAuthMethod, params);
222
+ } catch (error) {
223
+ throw new TokenRequestError({
224
+ error: "network_failure",
225
+ errorDescription: error instanceof Error ? error.message : "Failed to reach the token endpoint"
226
+ });
227
+ }
208
228
  }
209
229
  async function readTokenResponseBody(response) {
210
230
  return await response.json().catch(() => null);
211
231
  }
212
232
  async function exchangeAuthorizationCode(options) {
213
233
  const discovery = await fetchDiscoveryForTokenRequest(options.issuer);
214
- const response = await postTokenRequest(
234
+ const response = await postTokenRequestOrThrow(
215
235
  discovery.token_endpoint,
216
236
  options.clientId,
217
237
  options.clientSecret,
@@ -400,6 +420,9 @@ function sanitizeReturnTo(returnTo) {
400
420
  }
401
421
  return returnTo.startsWith("/") ? returnTo : void 0;
402
422
  }
423
+ function resolveRedirectUri(redirectUri, request) {
424
+ return typeof redirectUri === "function" ? redirectUri(request) : redirectUri;
425
+ }
403
426
  function transactionCookieOptions(maxAge) {
404
427
  return {
405
428
  httpOnly: true,
@@ -425,7 +448,7 @@ function createLoginRoute(config) {
425
448
  const discovery = await fetchDiscovery(config.issuer);
426
449
  const authorizeUrl = new URL(discovery.authorization_endpoint);
427
450
  authorizeUrl.searchParams.set("client_id", config.clientId);
428
- authorizeUrl.searchParams.set("redirect_uri", config.redirectUri);
451
+ authorizeUrl.searchParams.set("redirect_uri", resolveRedirectUri(config.redirectUri, request));
429
452
  authorizeUrl.searchParams.set("response_type", "code");
430
453
  authorizeUrl.searchParams.set("scope", config.scope ?? "openid profile email roles");
431
454
  authorizeUrl.searchParams.set("state", state);
@@ -479,8 +502,9 @@ function createCallbackRoute(config) {
479
502
  issuer: config.issuer,
480
503
  clientId: config.clientId,
481
504
  clientSecret: config.clientSecret,
505
+ clientAuthMethod: config.clientAuthMethod,
482
506
  code,
483
- redirectUri: config.redirectUri,
507
+ redirectUri: resolveRedirectUri(config.redirectUri, request),
484
508
  codeVerifier: transaction.codeVerifier
485
509
  });
486
510
  } catch (error) {
package/dist/next.d.cts CHANGED
@@ -1,13 +1,32 @@
1
- import { NextResponse, NextRequest } from 'next/server';
2
- import { d as TokenSet, I as IdTokenClaims } from './verify-ggSzUrdC.cjs';
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { T as TokenEndpointAuthMethod, 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';
6
6
 
7
+ /**
8
+ * A `redirect_uri` (RFC 6749 §3.1.2), either fixed or derived from the
9
+ * incoming request (e.g. `(request) => new URL('/api/auth/callback',
10
+ * request.nextUrl.origin).toString()`, for a deployment reachable at more
11
+ * than one origin -- a preview URL per branch, say).
12
+ *
13
+ * The resolved value MUST be identical on both the `createLoginRoute` call
14
+ * that starts a login and the `createCallbackRoute` call that finishes it
15
+ * (RFC 6749 §4.1.3 requires the token endpoint to see the same
16
+ * `redirect_uri` the authorization request used, and the identity server
17
+ * enforces this). A function form is only safe when it derives the same
18
+ * result from equivalent requests -- ordinarily true for `request.nextUrl
19
+ * .origin`, since the login and callback requests share one browser
20
+ * session and one domain, but not for anything that varies within that,
21
+ * e.g. a path segment.
22
+ */
23
+ type RedirectUriConfig = string | ((request: NextRequest) => string);
7
24
  interface CreateLoginRouteConfig {
8
25
  issuer: string;
9
26
  clientId: string;
10
- redirectUri: string;
27
+ /** See `RedirectUriConfig` -- must resolve to the same value
28
+ * `createCallbackRoute`'s own `redirectUri` does. */
29
+ redirectUri: RedirectUriConfig;
11
30
  /** Space-separated OAuth scopes. Defaults to `"openid profile email
12
31
  * roles"` -- `roles` is included in the default because without it the
13
32
  * minted token carries no `roles` claim, and every `permits()` call
@@ -63,7 +82,19 @@ interface CreateCallbackRouteConfig {
63
82
  clientId: string;
64
83
  /** Omit for a public client authenticating via PKCE alone. */
65
84
  clientSecret?: string;
66
- redirectUri: string;
85
+ /** How to send `clientSecret` to the token endpoint. Defaults to
86
+ * `'client_secret_basic'`, matching `exchangeAuthorizationCode`'s own
87
+ * default -- omitting this field changes nothing for an existing
88
+ * caller. Set it to `'client_secret_post'` if the relying party is
89
+ * registered on the identity platform for that method instead: sending
90
+ * Basic auth to a `client_secret_post`-only client gets `invalid_client`
91
+ * back, since the token endpoint never receives credentials in the form
92
+ * it expects. See `exchangeAuthorizationCode`'s own `clientAuthMethod`
93
+ * docs for the full reasoning. */
94
+ clientAuthMethod?: TokenEndpointAuthMethod;
95
+ /** See `RedirectUriConfig` -- must resolve to the same value
96
+ * `createLoginRoute`'s own `redirectUri` did. */
97
+ redirectUri: RedirectUriConfig;
67
98
  cookieSecret: SealSecret;
68
99
  transactionCookieName?: string;
69
100
  /** Defaults to `clientId`, correct for a standard login where this app is
@@ -92,4 +123,4 @@ interface CreateCallbackRouteConfig {
92
123
  * package stops there. */
93
124
  declare function createCallbackRoute(config: CreateCallbackRouteConfig): (request: NextRequest) => Promise<NextResponse>;
94
125
 
95
- export { type CallbackError, type CreateCallbackRouteConfig, type CreateLoginRouteConfig, createCallbackRoute, createLoginRoute };
126
+ export { type CallbackError, type CreateCallbackRouteConfig, type CreateLoginRouteConfig, type RedirectUriConfig, createCallbackRoute, createLoginRoute };
package/dist/next.d.ts CHANGED
@@ -1,13 +1,32 @@
1
- import { NextResponse, NextRequest } from 'next/server';
2
- import { d as TokenSet, I as IdTokenClaims } from './verify-ggSzUrdC.js';
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { T as TokenEndpointAuthMethod, 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';
6
6
 
7
+ /**
8
+ * A `redirect_uri` (RFC 6749 §3.1.2), either fixed or derived from the
9
+ * incoming request (e.g. `(request) => new URL('/api/auth/callback',
10
+ * request.nextUrl.origin).toString()`, for a deployment reachable at more
11
+ * than one origin -- a preview URL per branch, say).
12
+ *
13
+ * The resolved value MUST be identical on both the `createLoginRoute` call
14
+ * that starts a login and the `createCallbackRoute` call that finishes it
15
+ * (RFC 6749 §4.1.3 requires the token endpoint to see the same
16
+ * `redirect_uri` the authorization request used, and the identity server
17
+ * enforces this). A function form is only safe when it derives the same
18
+ * result from equivalent requests -- ordinarily true for `request.nextUrl
19
+ * .origin`, since the login and callback requests share one browser
20
+ * session and one domain, but not for anything that varies within that,
21
+ * e.g. a path segment.
22
+ */
23
+ type RedirectUriConfig = string | ((request: NextRequest) => string);
7
24
  interface CreateLoginRouteConfig {
8
25
  issuer: string;
9
26
  clientId: string;
10
- redirectUri: string;
27
+ /** See `RedirectUriConfig` -- must resolve to the same value
28
+ * `createCallbackRoute`'s own `redirectUri` does. */
29
+ redirectUri: RedirectUriConfig;
11
30
  /** Space-separated OAuth scopes. Defaults to `"openid profile email
12
31
  * roles"` -- `roles` is included in the default because without it the
13
32
  * minted token carries no `roles` claim, and every `permits()` call
@@ -63,7 +82,19 @@ interface CreateCallbackRouteConfig {
63
82
  clientId: string;
64
83
  /** Omit for a public client authenticating via PKCE alone. */
65
84
  clientSecret?: string;
66
- redirectUri: string;
85
+ /** How to send `clientSecret` to the token endpoint. Defaults to
86
+ * `'client_secret_basic'`, matching `exchangeAuthorizationCode`'s own
87
+ * default -- omitting this field changes nothing for an existing
88
+ * caller. Set it to `'client_secret_post'` if the relying party is
89
+ * registered on the identity platform for that method instead: sending
90
+ * Basic auth to a `client_secret_post`-only client gets `invalid_client`
91
+ * back, since the token endpoint never receives credentials in the form
92
+ * it expects. See `exchangeAuthorizationCode`'s own `clientAuthMethod`
93
+ * docs for the full reasoning. */
94
+ clientAuthMethod?: TokenEndpointAuthMethod;
95
+ /** See `RedirectUriConfig` -- must resolve to the same value
96
+ * `createLoginRoute`'s own `redirectUri` did. */
97
+ redirectUri: RedirectUriConfig;
67
98
  cookieSecret: SealSecret;
68
99
  transactionCookieName?: string;
69
100
  /** Defaults to `clientId`, correct for a standard login where this app is
@@ -92,4 +123,4 @@ interface CreateCallbackRouteConfig {
92
123
  * package stops there. */
93
124
  declare function createCallbackRoute(config: CreateCallbackRouteConfig): (request: NextRequest) => Promise<NextResponse>;
94
125
 
95
- export { type CallbackError, type CreateCallbackRouteConfig, type CreateLoginRouteConfig, createCallbackRoute, createLoginRoute };
126
+ export { type CallbackError, type CreateCallbackRouteConfig, type CreateLoginRouteConfig, type RedirectUriConfig, createCallbackRoute, createLoginRoute };
package/dist/next.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  generateCodeVerifier,
6
6
  generateState,
7
7
  verifyIdToken
8
- } from "./chunk-HO6U23GY.js";
8
+ } from "./chunk-36WOXP7V.js";
9
9
  import {
10
10
  seal,
11
11
  unseal
@@ -27,6 +27,9 @@ function sanitizeReturnTo(returnTo) {
27
27
  }
28
28
  return returnTo.startsWith("/") ? returnTo : void 0;
29
29
  }
30
+ function resolveRedirectUri(redirectUri, request) {
31
+ return typeof redirectUri === "function" ? redirectUri(request) : redirectUri;
32
+ }
30
33
  function transactionCookieOptions(maxAge) {
31
34
  return {
32
35
  httpOnly: true,
@@ -52,7 +55,7 @@ function createLoginRoute(config) {
52
55
  const discovery = await fetchDiscovery(config.issuer);
53
56
  const authorizeUrl = new URL(discovery.authorization_endpoint);
54
57
  authorizeUrl.searchParams.set("client_id", config.clientId);
55
- authorizeUrl.searchParams.set("redirect_uri", config.redirectUri);
58
+ authorizeUrl.searchParams.set("redirect_uri", resolveRedirectUri(config.redirectUri, request));
56
59
  authorizeUrl.searchParams.set("response_type", "code");
57
60
  authorizeUrl.searchParams.set("scope", config.scope ?? "openid profile email roles");
58
61
  authorizeUrl.searchParams.set("state", state);
@@ -106,8 +109,9 @@ function createCallbackRoute(config) {
106
109
  issuer: config.issuer,
107
110
  clientId: config.clientId,
108
111
  clientSecret: config.clientSecret,
112
+ clientAuthMethod: config.clientAuthMethod,
109
113
  code,
110
- redirectUri: config.redirectUri,
114
+ redirectUri: resolveRedirectUri(config.redirectUri, request),
111
115
  codeVerifier: transaction.codeVerifier
112
116
  });
113
117
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tdacorp/identity-client",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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": {