@idp.global/sdk 19.3.0 → 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 (38) hide show
  1. package/changelog.md +40 -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 +3 -1
  14. package/dist_ts_relyingparty/classes.errors.js +2 -1
  15. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +12 -0
  16. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +33 -6
  17. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +102 -7
  18. package/dist_ts_relyingparty/classes.relyingpartysessions.js +228 -29
  19. package/dist_ts_relyingparty/classes.rpmodels.d.ts +6 -4
  20. package/dist_ts_relyingparty/classes.rpmodels.js +8 -6
  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 +3 -3
  24. package/dist_ts_relyingparty/helpers.bearer.js +3 -3
  25. package/dist_ts_relyingparty/interfaces.d.ts +34 -24
  26. package/package.json +2 -1
  27. package/readme.md +215 -19
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts_browser/classes.relyingpartybearerclient.ts +821 -0
  30. package/ts_browser/index.ts +1 -0
  31. package/ts_relyingparty/classes.authorityobserver.ts +80 -12
  32. package/ts_relyingparty/classes.errors.ts +4 -1
  33. package/ts_relyingparty/classes.relyingpartyroutes.ts +38 -7
  34. package/ts_relyingparty/classes.relyingpartysessions.ts +298 -34
  35. package/ts_relyingparty/classes.rpmodels.ts +7 -4
  36. package/ts_relyingparty/contract.bearer.ts +99 -0
  37. package/ts_relyingparty/helpers.bearer.ts +8 -9
  38. package/ts_relyingparty/interfaces.ts +49 -26
@@ -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) {
@@ -13,7 +13,9 @@ export type TRelyingPartyErrorCode =
13
13
  /** a hand-off code that is unknown, expired, already claimed, or claimed with the wrong verifier */
14
14
  | 'invalid_bearer_handoff'
15
15
  /** a refresh token that is unknown, rotated, expired, or of a session that is over */
16
- | 'invalid_bearer_grant';
16
+ | 'invalid_bearer_grant'
17
+ /** a `sessionsOfPrincipal` cursor that no page of it answered */
18
+ | 'invalid_sessions_cursor';
17
19
 
18
20
  const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number>> = {
19
21
  login_method_unavailable: 404,
@@ -25,6 +27,7 @@ const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number
25
27
  return_target_not_allowed: 400,
26
28
  invalid_bearer_handoff: 400,
27
29
  invalid_bearer_grant: 400,
30
+ invalid_sessions_cursor: 400,
28
31
  };
29
32
 
30
33
  /** A request the login flow refuses; the code is safe to answer, the browser learns nothing more. */
@@ -13,6 +13,9 @@ import { sanitizeReturnPath } from './helpers.returnpath.js';
13
13
  import type {
14
14
  IRelyingPartyBearerDelivery,
15
15
  IRelyingPartyBearerLoginRequest,
16
+ IRelyingPartyBearerLogoutAnswer,
17
+ IRelyingPartyBearerSessionAnswer,
18
+ IRelyingPartyBearerTokenAnswer,
16
19
  IRelyingPartyBearerTokens,
17
20
  IRelyingPartySession,
18
21
  IResolvedRelyingPartySession,
@@ -149,6 +152,12 @@ type TBearerApiRoute = 'bearerClaim' | 'bearerRefresh' | 'bearerSession' | 'bear
149
152
  * The four API routes answer CORS requests from the popup return targets'
150
153
  * origins, exactly, and no others; they never allow credentials.
151
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
+ *
152
161
  * `handle()` answers `null` for every other request, so the application
153
162
  * chains its own routes (a local password login) around it. Cookies are only
154
163
  * ever set here: a `/typedrequest` answer cannot carry `Set-Cookie`.
@@ -204,12 +213,15 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
204
213
  return bearerResponse;
205
214
  }
206
215
  }
207
- if (path === paths.login && method === 'GET' && providerLogin) {
208
- return this.login(contextArg);
209
- }
210
216
  if (path === paths.callback && method === 'GET' && providerLogin) {
211
217
  return this.callback(contextArg);
212
218
  }
219
+ if (!this.#sessions.cookieSessionsEnabled) {
220
+ return null;
221
+ }
222
+ if (path === paths.login && method === 'GET' && providerLogin) {
223
+ return this.login(contextArg);
224
+ }
213
225
  if (path === paths.finalize && method === 'GET' && providerLogin) {
214
226
  let location = this.#options.failureLocation;
215
227
  try {
@@ -257,16 +269,20 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
257
269
  // the callback is read from the public origin, never from a forwarded host
258
270
  const callbackUrl = new URL(contextArg.url.pathname + contextArg.url.search, this.#sessions.publicOrigin);
259
271
  if (this.#sessions.bearerLoginEnabled) {
272
+ const cookieSessions = this.#sessions.cookieSessionsEnabled;
260
273
  let delivery: IRelyingPartyBearerDelivery | null;
261
274
  try {
262
275
  delivery = await this.#sessions.bearerDeliveryOf(callbackUrl);
263
276
  } catch (error) {
264
277
  // unknown whose callback this is: answered as the cookie path answers a failure
265
- return this.callbackFailure(error, { 'Set-Cookie': this.#sessions.clearIntentCookie() });
278
+ return this.callbackFailure(error, cookieSessions ? { 'Set-Cookie': this.#sessions.clearIntentCookie() } : {});
266
279
  }
267
280
  if (delivery) {
268
281
  return this.bearerCallback(callbackUrl, delivery);
269
282
  }
283
+ if (!cookieSessions) {
284
+ return this.callbackOfNoBearerLogin(callbackUrl);
285
+ }
270
286
  }
271
287
  const cookieHeader = contextArg.headers.get('cookie');
272
288
  const providerError = callbackUrl.searchParams.get('error');
@@ -298,6 +314,20 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
298
314
  }
299
315
  }
300
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
+
301
331
  /** A callback that failed for a reason other than a refusal: reported when it is the relying party's fault. */
302
332
  private callbackFailure(errorArg: unknown, headersArg: Record<string, string>): Response {
303
333
  const status = this.callbackFailureStatus(errorArg);
@@ -374,7 +404,8 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
374
404
  const refreshToken = bodyField(await this.readJsonBody(contextArg), 'refreshToken');
375
405
  endedLiveSession = refreshToken !== undefined ? await this.#sessions.endBearerSession(refreshToken) : false;
376
406
  }
377
- return { endedLiveSession };
407
+ const answer: IRelyingPartyBearerLogoutAnswer = { endedLiveSession };
408
+ return answer;
378
409
  });
379
410
  }
380
411
 
@@ -491,7 +522,7 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
491
522
  }
492
523
  }
493
524
 
494
- private bearerTokenAnswer(tokensArg: IRelyingPartyBearerTokens): object {
525
+ private bearerTokenAnswer(tokensArg: IRelyingPartyBearerTokens): IRelyingPartyBearerTokenAnswer {
495
526
  return {
496
527
  tokenType: 'Bearer',
497
528
  accessToken: tokensArg.accessToken,
@@ -508,7 +539,7 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
508
539
  };
509
540
  }
510
541
 
511
- private async bearerSessionAnswer(contextArg: IRelyingPartyHttpContext): Promise<object> {
542
+ private async bearerSessionAnswer(contextArg: IRelyingPartyHttpContext): Promise<IRelyingPartyBearerSessionAnswer> {
512
543
  const live = await this.#sessions.resolveBearerSession(contextArg.headers.get('authorization'));
513
544
  return {
514
545
  session: live