@idp.global/sdk 19.2.1 → 19.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/changelog.md +58 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_browser/classes.idpclient.js +1 -1
  4. package/dist_ts_browser/classes.idprequests.js +1 -1
  5. package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +276 -0
  6. package/dist_ts_browser/classes.relyingpartybearerclient.js +591 -0
  7. package/dist_ts_browser/index.d.ts +1 -0
  8. package/dist_ts_browser/index.js +2 -1
  9. package/dist_ts_browser/plugins.js +1 -1
  10. package/dist_ts_browser/secret-delivery.js +1 -1
  11. package/dist_ts_relyingparty/classes.authorityobserver.d.ts +23 -0
  12. package/dist_ts_relyingparty/classes.authorityobserver.js +68 -13
  13. package/dist_ts_relyingparty/classes.errors.d.ts +11 -1
  14. package/dist_ts_relyingparty/classes.errors.js +6 -1
  15. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +74 -2
  16. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +289 -14
  17. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +209 -7
  18. package/dist_ts_relyingparty/classes.relyingpartysessions.js +734 -87
  19. package/dist_ts_relyingparty/classes.rpmodels.d.ts +71 -8
  20. package/dist_ts_relyingparty/classes.rpmodels.js +224 -10
  21. package/dist_ts_relyingparty/contract.bearer.d.ts +82 -0
  22. package/dist_ts_relyingparty/contract.bearer.js +9 -0
  23. package/dist_ts_relyingparty/helpers.bearer.d.ts +48 -0
  24. package/dist_ts_relyingparty/helpers.bearer.js +199 -0
  25. package/dist_ts_relyingparty/index.d.ts +1 -0
  26. package/dist_ts_relyingparty/index.js +2 -1
  27. package/dist_ts_relyingparty/interfaces.d.ts +122 -2
  28. package/dist_ts_relyingparty/testing.d.ts +22 -0
  29. package/dist_ts_relyingparty/testing.js +42 -1
  30. package/package.json +9 -11
  31. package/readme.md +353 -8
  32. package/ts/00_commitinfo_data.ts +1 -1
  33. package/ts_browser/classes.relyingpartybearerclient.ts +821 -0
  34. package/ts_browser/index.ts +1 -0
  35. package/ts_relyingparty/classes.authorityobserver.ts +80 -12
  36. package/ts_relyingparty/classes.errors.ts +16 -1
  37. package/ts_relyingparty/classes.relyingpartyroutes.ts +340 -16
  38. package/ts_relyingparty/classes.relyingpartysessions.ts +907 -94
  39. package/ts_relyingparty/classes.rpmodels.ts +131 -9
  40. package/ts_relyingparty/contract.bearer.ts +99 -0
  41. package/ts_relyingparty/helpers.bearer.ts +259 -0
  42. package/ts_relyingparty/index.ts +1 -0
  43. package/ts_relyingparty/interfaces.ts +144 -2
  44. package/ts_relyingparty/testing.ts +60 -0
@@ -1,3 +1,4 @@
1
1
  export * from './classes.idpclient.js';
2
2
  export * from './classes.idprequests.js';
3
3
  export * from './secret-delivery.js';
4
+ export * from './classes.relyingpartybearerclient.js';
@@ -1,6 +1,6 @@
1
1
  import { AuthorityTokenError } from './classes.errors.js';
2
2
  import { OidcAuthorityUnavailableError, type IOidcProvider } from './classes.idpoidcprovider.js';
3
- import type { IAuthorityTokenSource } from './interfaces.js';
3
+ import type { IAuthorityTokenSource, IProviderToken } from './interfaces.js';
4
4
 
5
5
  /** Why an observation refused: no membership at all, or roles the application maps to nothing. */
6
6
  export type TAuthorityRefusalReason = 'idp_membership_absent' | 'idp_roles_unmapped';
@@ -58,6 +58,8 @@ export interface IAuthorityObserverOptions<TGrant> {
58
58
  const DEFAULT_MAX_ENTRIES = 2_000;
59
59
  /** how much room an eviction makes, so it does not run on every insert */
60
60
  const EVICTION_HEADROOM = 0.1;
61
+ /** a session's token is rotated for an inactive answer at most once in this long */
62
+ export const AUTHORITY_FORCED_REFRESH_INTERVAL_MS = 60_000;
61
63
 
62
64
  interface ICachedObservation<TGrant> {
63
65
  outcome: 'active' | 'inactive';
@@ -72,6 +74,14 @@ interface ICachedObservation<TGrant> {
72
74
  reported: boolean;
73
75
  }
74
76
 
77
+ /** The one rotation of a session's token that an inactive answer forced. */
78
+ interface IForcedRefresh {
79
+ startedAt: number;
80
+ /** where its start falls among the observer's inactive answers */
81
+ sequence: number;
82
+ token: Promise<IProviderToken>;
83
+ }
84
+
75
85
  const assertTtl = (valueArg: number, labelArg: string): number => {
76
86
  if (!Number.isSafeInteger(valueArg) || valueArg < 0) {
77
87
  throw new Error(labelArg + ' must be a non-negative integer of milliseconds');
@@ -89,11 +99,28 @@ const assertTtl = (valueArg: number, labelArg: string): number => {
89
99
  * every attempt while the provider is unreachable. A session that is forgotten
90
100
  * while its observation is in flight never stores a positive answer. Nothing
91
101
  * runs on a timer: the cache is pruned when it is written to.
102
+ *
103
+ * idp.global answers inactive both for a login that ended and for a
104
+ * membership that is gone, so an inactive answer rotates the session's token
105
+ * (`rejectedAccessToken`) before it counts. A refused rotation is the ended
106
+ * login: it throws `authority_login_required` and stores nothing. Only an
107
+ * inactive answer on a token from a rotation that started after the
108
+ * observation's own inactive answer is a refusal (`idp_membership_absent`). A session's token is
109
+ * rotated so at most once per `AUTHORITY_FORCED_REFRESH_INTERVAL_MS`. An inactive
110
+ * answer that no rotation followed — it came within that time of the last
111
+ * one, the rotation started before it, or the source did not mark the token
112
+ * as its own rotation — confirms nothing: it throws `authority_unavailable`
113
+ * and stores nothing, and after the interval the next one rotates and
114
+ * decides. One monotonic sequence orders answers and rotation starts, so no
115
+ * token is compared with another and no clock with another's.
92
116
  */
93
117
  export class AuthorityObserver<TGrant> {
94
118
  readonly #cache = new Map<string, ICachedObservation<TGrant>>();
95
119
  readonly #inFlight = new Map<string, Promise<ICachedObservation<TGrant>>>();
96
120
  readonly #forgotten = new Set<string>();
121
+ readonly #forcedRefreshes = new Map<string, IForcedRefresh>();
122
+ /** counts inactive answers and forced rotation starts, in the order they happen */
123
+ #sequence = 0;
97
124
  readonly #provider: IOidcProvider;
98
125
  readonly #tokens: IAuthorityTokenSource;
99
126
  readonly #project: (rolesArg: readonly string[]) => TGrant | undefined;
@@ -149,6 +176,7 @@ export class AuthorityObserver<TGrant> {
149
176
 
150
177
  /** Drops every observation of a session; wire it to the session layer's `onSessionEnded`. */
151
178
  public forgetSession(sessionIdArg: string): void {
179
+ this.#forcedRefreshes.delete(sessionIdArg);
152
180
  const prefix = `${sessionIdArg} `;
153
181
  for (const key of this.#cache.keys()) {
154
182
  if (key.startsWith(prefix)) {
@@ -188,18 +216,15 @@ export class AuthorityObserver<TGrant> {
188
216
  optionsArg: { loginSessionId: string; principalId: string; idpOrganizationId: string },
189
217
  ): Promise<ICachedObservation<TGrant>> {
190
218
  const token = await this.#tokens.liveProviderToken(optionsArg.loginSessionId, optionsArg.principalId);
191
- let answer: Awaited<ReturnType<IOidcProvider['introspectOrganization']>>;
192
- try {
193
- answer = await this.#provider.introspectOrganization({
194
- accessToken: token.accessToken,
195
- subject: token.subject,
196
- organizationId: optionsArg.idpOrganizationId,
197
- });
198
- } catch (error) {
199
- if (error instanceof OidcAuthorityUnavailableError) {
200
- throw new AuthorityTokenError('authority_unavailable', error);
219
+ let answer = await this.introspect(token, optionsArg.idpOrganizationId);
220
+ if (!answer.active) {
221
+ const answeredAt = ++this.#sequence;
222
+ const forced = this.forcedRefresh(optionsArg.loginSessionId, optionsArg.principalId, token.accessToken);
223
+ const rotated = await forced.token;
224
+ if (forced.sequence < answeredAt || rotated.rotatedAfterRejection !== true) {
225
+ throw new AuthorityTokenError('authority_unavailable');
201
226
  }
202
- throw error;
227
+ answer = await this.introspect(rotated, optionsArg.idpOrganizationId);
203
228
  }
204
229
  const grant = answer.active ? this.#project(answer.roles) : undefined;
205
230
  const active = answer.active && grant !== undefined;
@@ -228,6 +253,49 @@ export class AuthorityObserver<TGrant> {
228
253
  return entry;
229
254
  }
230
255
 
256
+ private async introspect(
257
+ tokenArg: IProviderToken,
258
+ organizationIdArg: string,
259
+ ): Promise<Awaited<ReturnType<IOidcProvider['introspectOrganization']>>> {
260
+ try {
261
+ return await this.#provider.introspectOrganization({
262
+ accessToken: tokenArg.accessToken,
263
+ subject: tokenArg.subject,
264
+ organizationId: organizationIdArg,
265
+ });
266
+ } catch (error) {
267
+ if (error instanceof OidcAuthorityUnavailableError) {
268
+ throw new AuthorityTokenError('authority_unavailable', error);
269
+ }
270
+ throw error;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * The session's one forced rotation within `AUTHORITY_FORCED_REFRESH_INTERVAL_MS`,
276
+ * started for `rejectedArg` when there is none. Its failure is every
277
+ * caller's failure until the interval is over.
278
+ */
279
+ private forcedRefresh(sessionIdArg: string, principalIdArg: string, rejectedArg: string): IForcedRefresh {
280
+ const now = this.#now();
281
+ const last = this.#forcedRefreshes.get(sessionIdArg);
282
+ if (last && now - last.startedAt < AUTHORITY_FORCED_REFRESH_INTERVAL_MS) {
283
+ return last;
284
+ }
285
+ for (const [sessionId, forced] of this.#forcedRefreshes) {
286
+ if (now - forced.startedAt >= AUTHORITY_FORCED_REFRESH_INTERVAL_MS) {
287
+ this.#forcedRefreshes.delete(sessionId);
288
+ }
289
+ }
290
+ const forced: IForcedRefresh = {
291
+ startedAt: now,
292
+ sequence: ++this.#sequence,
293
+ token: this.#tokens.liveProviderToken(sessionIdArg, principalIdArg, { rejectedAccessToken: rejectedArg }),
294
+ };
295
+ this.#forcedRefreshes.set(sessionIdArg, forced);
296
+ return forced;
297
+ }
298
+
231
299
  /** Expired entries go first; if that is not enough, the oldest do. */
232
300
  private prune(): void {
233
301
  if (this.#cache.size <= this.#maxEntries) {
@@ -5,7 +5,17 @@ export type TRelyingPartyErrorCode =
5
5
  | 'invalid_login_callback'
6
6
  | 'missing_login_intent'
7
7
  | 'invalid_or_replayed_login'
8
- | 'invalid_login_finalize';
8
+ | 'invalid_login_finalize'
9
+ /** a bearer login start the relying party cannot accept: a missing or malformed parameter */
10
+ | 'invalid_bearer_request'
11
+ /** a bearer client named a return target that is not configured for its kind, exactly */
12
+ | 'return_target_not_allowed'
13
+ /** a hand-off code that is unknown, expired, already claimed, or claimed with the wrong verifier */
14
+ | 'invalid_bearer_handoff'
15
+ /** a refresh token that is unknown, rotated, expired, or of a session that is over */
16
+ | 'invalid_bearer_grant'
17
+ /** a `sessionsOfPrincipal` cursor that no page of it answered */
18
+ | 'invalid_sessions_cursor';
9
19
 
10
20
  const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number>> = {
11
21
  login_method_unavailable: 404,
@@ -13,6 +23,11 @@ const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number
13
23
  missing_login_intent: 400,
14
24
  invalid_or_replayed_login: 400,
15
25
  invalid_login_finalize: 400,
26
+ invalid_bearer_request: 400,
27
+ return_target_not_allowed: 400,
28
+ invalid_bearer_handoff: 400,
29
+ invalid_bearer_grant: 400,
30
+ invalid_sessions_cursor: 400,
16
31
  };
17
32
 
18
33
  /** A request the login flow refuses; the code is safe to answer, the browser learns nothing more. */
@@ -1,8 +1,25 @@
1
1
  import * as plugins from './plugins.js';
2
- import { LoginCallbackError, RelyingPartyError, RelyingPartyRefusal } from './classes.errors.js';
2
+ import { AuthorityTokenError, LoginCallbackError, RelyingPartyError, RelyingPartyRefusal } from './classes.errors.js';
3
3
  import type { RelyingPartySessions } from './classes.relyingpartysessions.js';
4
+ import {
5
+ bearerDeliveryLocation,
6
+ bearerPopupContentSecurityPolicy,
7
+ bearerPopupPage,
8
+ parseBearerAuthorization,
9
+ type TRelyingPartyBearerOutcome,
10
+ } from './helpers.bearer.js';
11
+ import { randomToken } from './helpers.crypto.js';
4
12
  import { sanitizeReturnPath } from './helpers.returnpath.js';
5
- import type { IRelyingPartySession, IResolvedRelyingPartySession } from './interfaces.js';
13
+ import type {
14
+ IRelyingPartyBearerDelivery,
15
+ IRelyingPartyBearerLoginRequest,
16
+ IRelyingPartyBearerLogoutAnswer,
17
+ IRelyingPartyBearerSessionAnswer,
18
+ IRelyingPartyBearerTokenAnswer,
19
+ IRelyingPartyBearerTokens,
20
+ IRelyingPartySession,
21
+ IResolvedRelyingPartySession,
22
+ } from './interfaces.js';
6
23
 
7
24
  /**
8
25
  * The part of an HTTP request context the routes read. `IRequestContext` of
@@ -42,7 +59,7 @@ export interface IRelyingPartyRoutesOptions<TPrincipal extends object | undefine
42
59
  * never changes the answer — it is reported as a `hook_failed` route event.
43
60
  */
44
61
  onLogout?(eventArg: { session: IRelyingPartySession; endedLiveSession: boolean }): Promise<void>;
45
- /** the largest logout body read, in bytes; a longer one is cancelled as soon as it passes the limit */
62
+ /** the largest logout, claim or refresh body read, in bytes; a longer one is cancelled as soon as it passes the limit */
46
63
  maxBodyBytes?: number;
47
64
  /**
48
65
  * diagnostics of a failed start or callback; the default writes one JSON
@@ -55,6 +72,10 @@ export interface IRelyingPartyRoutesOptions<TPrincipal extends object | undefine
55
72
  | 'auth_callback_failed'
56
73
  | 'auth_callback_declined'
57
74
  | 'auth_callback_refused'
75
+ /** a bearer login start named something the relying party does not accept; `code` says what */
76
+ | 'auth_bearer_start_refused'
77
+ /** a bearer claim, refresh, session or logout failed for a reason other than its credential */
78
+ | 'auth_bearer_failed'
58
79
  /** an application hook of the routes threw; the step it belonged to is complete */
59
80
  | 'hook_failed';
60
81
  hook?: 'onLogout';
@@ -87,6 +108,19 @@ const jsonResponse = (bodyArg: object, statusArg: number, headersArg?: Record<st
87
108
  const redirect = (locationArg: string, headersArg?: Record<string, string>): Response =>
88
109
  new Response(null, { status: 302, headers: noStoreHeaders({ Location: locationArg, ...headersArg }) });
89
110
 
111
+ /** A JSON body's field, or `undefined` for anything that is not an object holding it. */
112
+ const bodyField = (bodyArg: unknown, nameArg: string): unknown =>
113
+ bodyArg && typeof bodyArg === 'object' && Object.hasOwn(bodyArg, nameArg) ? Reflect.get(bodyArg, nameArg) : undefined;
114
+
115
+ /** A query parameter given once; `null` when it is repeated, so it never passes as given. */
116
+ const singleParam = (paramsArg: URLSearchParams, nameArg: string): string | null | undefined => {
117
+ const all = paramsArg.getAll(nameArg);
118
+ return all.length === 0 ? undefined : all.length === 1 ? all[0] : null;
119
+ };
120
+
121
+ /** The routes a bearer client calls with `fetch`, possibly from a popup client's origin. */
122
+ type TBearerApiRoute = 'bearerClaim' | 'bearerRefresh' | 'bearerSession' | 'bearerLogout';
123
+
90
124
  /**
91
125
  * The HTTP steps of a relying-party login, all under the sessions' login path
92
126
  * prefix (default `/auth/`):
@@ -100,6 +134,30 @@ const redirect = (locationArg: string, headersArg?: Record<string, string>): Res
100
134
  * - `POST logout` ends the session; it needs this origin and the CSRF token,
101
135
  * and `onLogout` runs before it answers
102
136
  *
137
+ * With bearer mode on (`RelyingPartySessions` `bearer`), the routes of the
138
+ * application's own clients join them; none of them reads or sets a cookie:
139
+ *
140
+ * - `GET bearer/login?client_kind=…&return_target=…&code_challenge=…&code_challenge_method=S256[&state=…]`
141
+ * redirects to the provider
142
+ * - `GET callback` answers a bearer login's callback by delivering the
143
+ * hand-off code — or the `error` — to the return target: a redirect with
144
+ * `code` (or `error`) and `state` in the query, or for a popup a page that
145
+ * posts them to its opener at the registered origin
146
+ * - `POST bearer/claim` `{ code, codeVerifier }` and `POST bearer/refresh`
147
+ * `{ refreshToken }` answer the tokens
148
+ * - `GET bearer/session` answers the session of `Authorization: Bearer <access token>`
149
+ * - `POST bearer/logout` ends the session of the `Authorization` access token
150
+ * or of the body's `{ refreshToken }`
151
+ *
152
+ * The four API routes answer CORS requests from the popup return targets'
153
+ * origins, exactly, and no others; they never allow credentials.
154
+ *
155
+ * In bearer-only mode (`RelyingPartySessions` `cookieSessions: false`) only
156
+ * the bearer routes and the callback answer, and no answer sets a cookie: a
157
+ * callback that is no bearer login fails without clearing an intent cookie
158
+ * that was never set, and `login`, `finalize`, `session` and `logout` answer
159
+ * `null`.
160
+ *
103
161
  * `handle()` answers `null` for every other request, so the application
104
162
  * chains its own routes (a local password login) around it. Cookies are only
105
163
  * ever set here: a `/typedrequest` answer cannot carry `Set-Cookie`.
@@ -115,8 +173,19 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
115
173
  this.#maxBodyBytes = optionsArg.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
116
174
  }
117
175
 
118
- /** The paths the routes answer, for registering them one by one. */
119
- public get paths(): { login: string; callback: string; finalize: string; session: string; logout: string } {
176
+ /** The paths the routes answer, for registering them one by one; the `bearer…` paths answer only in bearer mode. */
177
+ public get paths(): {
178
+ login: string;
179
+ callback: string;
180
+ finalize: string;
181
+ session: string;
182
+ logout: string;
183
+ bearerLogin: string;
184
+ bearerClaim: string;
185
+ bearerRefresh: string;
186
+ bearerSession: string;
187
+ bearerLogout: string;
188
+ } {
120
189
  const prefix = this.#sessions.loginPathPrefix;
121
190
  return {
122
191
  login: prefix + 'login',
@@ -124,6 +193,11 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
124
193
  finalize: prefix + 'finalize',
125
194
  session: prefix + 'session',
126
195
  logout: prefix + 'logout',
196
+ bearerLogin: prefix + 'bearer/login',
197
+ bearerClaim: prefix + 'bearer/claim',
198
+ bearerRefresh: prefix + 'bearer/refresh',
199
+ bearerSession: prefix + 'bearer/session',
200
+ bearerLogout: prefix + 'bearer/logout',
127
201
  };
128
202
  }
129
203
 
@@ -133,12 +207,21 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
133
207
  const method = contextArg.method.toUpperCase();
134
208
  const providerLogin = this.#sessions.providerLoginEnabled;
135
209
 
136
- if (path === paths.login && method === 'GET' && providerLogin) {
137
- return this.login(contextArg);
210
+ if (this.#sessions.bearerLoginEnabled) {
211
+ const bearerResponse = await this.handleBearer(contextArg, path, method);
212
+ if (bearerResponse) {
213
+ return bearerResponse;
214
+ }
138
215
  }
139
216
  if (path === paths.callback && method === 'GET' && providerLogin) {
140
217
  return this.callback(contextArg);
141
218
  }
219
+ if (!this.#sessions.cookieSessionsEnabled) {
220
+ return null;
221
+ }
222
+ if (path === paths.login && method === 'GET' && providerLogin) {
223
+ return this.login(contextArg);
224
+ }
142
225
  if (path === paths.finalize && method === 'GET' && providerLogin) {
143
226
  let location = this.#options.failureLocation;
144
227
  try {
@@ -185,6 +268,22 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
185
268
  private async callback(contextArg: IRelyingPartyHttpContext): Promise<Response> {
186
269
  // the callback is read from the public origin, never from a forwarded host
187
270
  const callbackUrl = new URL(contextArg.url.pathname + contextArg.url.search, this.#sessions.publicOrigin);
271
+ if (this.#sessions.bearerLoginEnabled) {
272
+ const cookieSessions = this.#sessions.cookieSessionsEnabled;
273
+ let delivery: IRelyingPartyBearerDelivery | null;
274
+ try {
275
+ delivery = await this.#sessions.bearerDeliveryOf(callbackUrl);
276
+ } catch (error) {
277
+ // unknown whose callback this is: answered as the cookie path answers a failure
278
+ return this.callbackFailure(error, cookieSessions ? { 'Set-Cookie': this.#sessions.clearIntentCookie() } : {});
279
+ }
280
+ if (delivery) {
281
+ return this.bearerCallback(callbackUrl, delivery);
282
+ }
283
+ if (!cookieSessions) {
284
+ return this.callbackOfNoBearerLogin(callbackUrl);
285
+ }
286
+ }
188
287
  const cookieHeader = contextArg.headers.get('cookie');
189
288
  const providerError = callbackUrl.searchParams.get('error');
190
289
  if (providerError) {
@@ -211,19 +310,244 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
211
310
  }
212
311
  return jsonResponse({ error: 'authentication_failed' }, error.status, clearIntent);
213
312
  }
214
- const status = error instanceof RelyingPartyError ? error.status : 502;
215
- if (status >= 500) {
216
- this.emit({
217
- event: 'auth_callback_failed',
218
- correlationId: plugins.crypto.randomUUID(),
219
- stage: error instanceof LoginCallbackError ? error.stage : 'unclassified',
220
- message: error instanceof Error ? error.message : String(error),
221
- });
313
+ return this.callbackFailure(error, clearIntent);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * A callback of no bearer login in bearer-only mode: no attempt of this
319
+ * relying party can be completed with it, and no cookie is cleared because
320
+ * none was ever set.
321
+ */
322
+ private callbackOfNoBearerLogin(callbackUrlArg: URL): Response {
323
+ const providerError = callbackUrlArg.searchParams.get('error');
324
+ if (providerError) {
325
+ this.emit({ event: 'auth_callback_declined', code: providerError.slice(0, MAX_PROVIDER_ERROR_CODE_LENGTH) });
326
+ return redirect(this.#options.failureLocation);
327
+ }
328
+ return this.callbackFailure(new RelyingPartyError('invalid_login_callback'), {});
329
+ }
330
+
331
+ /** A callback that failed for a reason other than a refusal: reported when it is the relying party's fault. */
332
+ private callbackFailure(errorArg: unknown, headersArg: Record<string, string>): Response {
333
+ const status = this.callbackFailureStatus(errorArg);
334
+ return jsonResponse({ error: 'authentication_failed' }, status, headersArg);
335
+ }
336
+
337
+ private callbackFailureStatus(errorArg: unknown): number {
338
+ const status = errorArg instanceof RelyingPartyError ? errorArg.status : 502;
339
+ if (status >= 500) {
340
+ this.emit({
341
+ event: 'auth_callback_failed',
342
+ correlationId: plugins.crypto.randomUUID(),
343
+ stage: errorArg instanceof LoginCallbackError ? errorArg.stage : 'unclassified',
344
+ message: errorArg instanceof Error ? errorArg.message : String(errorArg),
345
+ });
346
+ }
347
+ return status;
348
+ }
349
+
350
+ // ==========================================================================
351
+ // bearer hand-off
352
+ // ==========================================================================
353
+
354
+ private async handleBearer(contextArg: IRelyingPartyHttpContext, pathArg: string, methodArg: string): Promise<Response | null> {
355
+ const paths = this.paths;
356
+ if (pathArg === paths.bearerLogin) {
357
+ return methodArg === 'GET' ? this.bearerLogin(contextArg) : null;
358
+ }
359
+ const route = (['bearerClaim', 'bearerRefresh', 'bearerSession', 'bearerLogout'] as const)
360
+ .find((routeArg: TBearerApiRoute) => paths[routeArg] === pathArg);
361
+ if (!route) {
362
+ return null;
363
+ }
364
+ const corsHeaders = this.bearerCorsHeaders(contextArg);
365
+ if (methodArg === 'OPTIONS') {
366
+ if (!corsHeaders) {
367
+ return jsonResponse({ error: 'forbidden' }, 403, { Vary: 'Origin' });
368
+ }
369
+ return new Response(null, {
370
+ status: 204,
371
+ headers: noStoreHeaders({
372
+ ...corsHeaders,
373
+ 'Access-Control-Allow-Methods': 'GET, POST',
374
+ 'Access-Control-Allow-Headers': 'authorization, content-type',
375
+ 'Access-Control-Max-Age': '600',
376
+ }),
377
+ });
378
+ }
379
+ const headers = corsHeaders ?? { Vary: 'Origin' };
380
+ if (route === 'bearerSession') {
381
+ return methodArg === 'GET' ? this.bearerApi(headers, () => this.bearerSessionAnswer(contextArg)) : null;
382
+ }
383
+ if (methodArg !== 'POST') {
384
+ return null;
385
+ }
386
+ if (route === 'bearerClaim') {
387
+ return this.bearerApi(headers, async () => {
388
+ const body = await this.readJsonBody(contextArg);
389
+ return this.bearerTokenAnswer(
390
+ await this.#sessions.claimBearerHandoff(bodyField(body, 'code'), bodyField(body, 'codeVerifier')),
391
+ );
392
+ });
393
+ }
394
+ if (route === 'bearerRefresh') {
395
+ return this.bearerApi(headers, async () => {
396
+ const body = await this.readJsonBody(contextArg);
397
+ return this.bearerTokenAnswer(await this.#sessions.refreshBearerTokens(bodyField(body, 'refreshToken')));
398
+ });
399
+ }
400
+ return this.bearerApi(headers, async () => {
401
+ const accessToken = parseBearerAuthorization(contextArg.headers.get('authorization'));
402
+ let endedLiveSession = accessToken ? await this.#sessions.endBearerSession(accessToken) : false;
403
+ if (!endedLiveSession) {
404
+ const refreshToken = bodyField(await this.readJsonBody(contextArg), 'refreshToken');
405
+ endedLiveSession = refreshToken !== undefined ? await this.#sessions.endBearerSession(refreshToken) : false;
406
+ }
407
+ const answer: IRelyingPartyBearerLogoutAnswer = { endedLiveSession };
408
+ return answer;
409
+ });
410
+ }
411
+
412
+ /**
413
+ * The CORS answer for a request from a popup return target's origin, which
414
+ * is matched exactly; `null` for every other origin, which gets none.
415
+ * Credentials are never allowed: bearer mode carries no cookie.
416
+ */
417
+ private bearerCorsHeaders(contextArg: IRelyingPartyHttpContext): Record<string, string> | null {
418
+ const origin = contextArg.headers.get('origin');
419
+ const popupOrigins = this.#sessions.bearerReturnTargets?.popup ?? [];
420
+ if (origin === null || !popupOrigins.includes(origin)) {
421
+ return null;
422
+ }
423
+ return { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' };
424
+ }
425
+
426
+ private async bearerLogin(contextArg: IRelyingPartyHttpContext): Promise<Response> {
427
+ const query = contextArg.url.searchParams;
428
+ const state = singleParam(query, 'state');
429
+ // every field is checked by startBearerLogin, which takes them as untrusted input
430
+ const request = {
431
+ clientKind: singleParam(query, 'client_kind'),
432
+ returnTarget: singleParam(query, 'return_target'),
433
+ codeChallenge: singleParam(query, 'code_challenge'),
434
+ codeChallengeMethod: singleParam(query, 'code_challenge_method'),
435
+ ...(state !== undefined ? { state } : {}),
436
+ } as unknown as IRelyingPartyBearerLoginRequest;
437
+ try {
438
+ const start = await this.#sessions.startBearerLogin(request);
439
+ return redirect(start.authorizationUrl.href, { 'Referrer-Policy': 'no-referrer' });
440
+ } catch (error) {
441
+ if (error instanceof RelyingPartyError && error.status < 500) {
442
+ this.emit({ event: 'auth_bearer_start_refused', code: error.code });
443
+ return jsonResponse({ error: error.code }, error.status);
222
444
  }
223
- return jsonResponse({ error: 'authentication_failed' }, status, clearIntent);
445
+ this.emit({ event: 'auth_login_start_failed', message: error instanceof Error ? error.message : String(error) });
446
+ return jsonResponse({ error: 'authentication_unavailable' }, 503);
224
447
  }
225
448
  }
226
449
 
450
+ /**
451
+ * The callback of a bearer login, delivered to its return target whatever
452
+ * the outcome: the hand-off code, `access_denied` when the provider came
453
+ * back without one, the code of an application refusal (its `location` is
454
+ * a page of the cookie flow and not followed), or `authentication_failed`.
455
+ */
456
+ private async bearerCallback(callbackUrlArg: URL, deliveryArg: IRelyingPartyBearerDelivery): Promise<Response> {
457
+ const providerError = callbackUrlArg.searchParams.get('error');
458
+ if (providerError) {
459
+ await this.#sessions.abandonBearerLogin(callbackUrlArg);
460
+ this.emit({ event: 'auth_callback_declined', code: providerError.slice(0, MAX_PROVIDER_ERROR_CODE_LENGTH) });
461
+ return this.deliverBearerOutcome(deliveryArg, { error: 'access_denied' });
462
+ }
463
+ try {
464
+ const handoff = await this.#sessions.completeBearerLogin(callbackUrlArg);
465
+ return this.deliverBearerOutcome(handoff.delivery, { code: handoff.code });
466
+ } catch (error) {
467
+ if (error instanceof RelyingPartyRefusal) {
468
+ this.emit({ event: 'auth_callback_refused', code: error.code });
469
+ return this.deliverBearerOutcome(deliveryArg, { error: error.code });
470
+ }
471
+ this.callbackFailureStatus(error);
472
+ return this.deliverBearerOutcome(deliveryArg, { error: 'authentication_failed' });
473
+ }
474
+ }
475
+
476
+ /**
477
+ * A redirect to the return target with the outcome in its query, or for a
478
+ * popup the page that posts it to the opener. Neither sends a referrer, and
479
+ * the popup page runs its one nonce script and nothing else.
480
+ */
481
+ private deliverBearerOutcome(deliveryArg: IRelyingPartyBearerDelivery, outcomeArg: TRelyingPartyBearerOutcome): Response {
482
+ if (deliveryArg.clientKind !== 'popup') {
483
+ return redirect(
484
+ bearerDeliveryLocation(deliveryArg, outcomeArg, this.#sessions.publicOrigin),
485
+ { 'Referrer-Policy': 'no-referrer' },
486
+ );
487
+ }
488
+ const nonce = randomToken(16);
489
+ return new Response(bearerPopupPage(deliveryArg, outcomeArg, nonce), {
490
+ status: 200,
491
+ headers: noStoreHeaders({
492
+ 'Content-Type': 'text/html; charset=utf-8',
493
+ 'Content-Security-Policy': bearerPopupContentSecurityPolicy(nonce),
494
+ 'X-Content-Type-Options': 'nosniff',
495
+ 'Referrer-Policy': 'no-referrer',
496
+ }),
497
+ });
498
+ }
499
+
500
+ /**
501
+ * Runs one bearer API step and answers its JSON. A refused credential
502
+ * answers its code with 400; an unreachable provider answers
503
+ * `authority_unavailable` with 503 and spends nothing; any other failure is
504
+ * reported and answers `authentication_unavailable`.
505
+ */
506
+ private async bearerApi(headersArg: Record<string, string>, stepArg: () => Promise<object>): Promise<Response> {
507
+ try {
508
+ return jsonResponse(await stepArg(), 200, headersArg);
509
+ } catch (error) {
510
+ if (error instanceof RelyingPartyError && error.status < 500) {
511
+ return jsonResponse({ error: error.code }, error.status, headersArg);
512
+ }
513
+ if (error instanceof AuthorityTokenError && error.code === 'authority_unavailable') {
514
+ return jsonResponse({ error: error.code }, error.status, headersArg);
515
+ }
516
+ this.emit({
517
+ event: 'auth_bearer_failed',
518
+ correlationId: plugins.crypto.randomUUID(),
519
+ message: error instanceof Error ? error.message : String(error),
520
+ });
521
+ return jsonResponse({ error: 'authentication_unavailable' }, 503, headersArg);
522
+ }
523
+ }
524
+
525
+ private bearerTokenAnswer(tokensArg: IRelyingPartyBearerTokens): IRelyingPartyBearerTokenAnswer {
526
+ return {
527
+ tokenType: 'Bearer',
528
+ accessToken: tokensArg.accessToken,
529
+ accessTokenExpiresAt: tokensArg.accessTokenExpiresAt,
530
+ // counted on the relying party's clock, so a client with another clock can use it
531
+ expiresIn: Math.max(0, Math.floor((tokensArg.accessTokenExpiresAt - tokensArg.issuedAt) / 1_000)),
532
+ refreshToken: tokensArg.refreshToken,
533
+ refreshTokenExpiresAt: tokensArg.refreshTokenExpiresAt,
534
+ session: {
535
+ sessionId: tokensArg.session.id,
536
+ principalId: tokensArg.session.principalId,
537
+ expiresAt: tokensArg.session.expiresAt,
538
+ },
539
+ };
540
+ }
541
+
542
+ private async bearerSessionAnswer(contextArg: IRelyingPartyHttpContext): Promise<IRelyingPartyBearerSessionAnswer> {
543
+ const live = await this.#sessions.resolveBearerSession(contextArg.headers.get('authorization'));
544
+ return {
545
+ session: live
546
+ ? { sessionId: live.session.id, principalId: live.session.principalId, expiresAt: live.session.expiresAt }
547
+ : null,
548
+ };
549
+ }
550
+
227
551
  private async logout(contextArg: IRelyingPartyHttpContext): Promise<Response> {
228
552
  if (contextArg.headers.get('origin') !== this.#sessions.publicOrigin) {
229
553
  return jsonResponse({ error: 'forbidden' }, 403);