@idp.global/sdk 18.0.1 → 18.2.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 (50) hide show
  1. package/changelog.md +28 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_relyingparty/classes.actorscope.d.ts +23 -0
  4. package/dist_ts_relyingparty/classes.actorscope.js +22 -0
  5. package/dist_ts_relyingparty/classes.authorityobserver.d.ts +81 -0
  6. package/dist_ts_relyingparty/classes.authorityobserver.js +185 -0
  7. package/dist_ts_relyingparty/classes.errors.d.ts +37 -0
  8. package/dist_ts_relyingparty/classes.errors.js +60 -0
  9. package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +142 -0
  10. package/dist_ts_relyingparty/classes.idpoidcprovider.js +255 -0
  11. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +102 -0
  12. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +248 -0
  13. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +223 -0
  14. package/dist_ts_relyingparty/classes.relyingpartysessions.js +825 -0
  15. package/dist_ts_relyingparty/classes.rpmodels.d.ts +87 -0
  16. package/dist_ts_relyingparty/classes.rpmodels.js +302 -0
  17. package/dist_ts_relyingparty/classes.secretbox.d.ts +14 -0
  18. package/dist_ts_relyingparty/classes.secretbox.js +45 -0
  19. package/dist_ts_relyingparty/classes.socketbinding.d.ts +84 -0
  20. package/dist_ts_relyingparty/classes.socketbinding.js +100 -0
  21. package/dist_ts_relyingparty/helpers.cookies.d.ts +11 -0
  22. package/dist_ts_relyingparty/helpers.cookies.js +62 -0
  23. package/dist_ts_relyingparty/helpers.crypto.d.ts +12 -0
  24. package/dist_ts_relyingparty/helpers.crypto.js +40 -0
  25. package/dist_ts_relyingparty/helpers.returnpath.d.ts +9 -0
  26. package/dist_ts_relyingparty/helpers.returnpath.js +39 -0
  27. package/dist_ts_relyingparty/index.d.ts +13 -0
  28. package/dist_ts_relyingparty/index.js +14 -0
  29. package/dist_ts_relyingparty/interfaces.d.ts +142 -0
  30. package/dist_ts_relyingparty/interfaces.js +2 -0
  31. package/dist_ts_relyingparty/plugins.d.ts +10 -0
  32. package/dist_ts_relyingparty/plugins.js +11 -0
  33. package/package.json +13 -10
  34. package/readme.md +215 -4
  35. package/ts/00_commitinfo_data.ts +1 -1
  36. package/ts_relyingparty/classes.actorscope.ts +39 -0
  37. package/ts_relyingparty/classes.authorityobserver.ts +253 -0
  38. package/ts_relyingparty/classes.errors.ts +77 -0
  39. package/ts_relyingparty/classes.idpoidcprovider.ts +386 -0
  40. package/ts_relyingparty/classes.relyingpartyroutes.ts +319 -0
  41. package/ts_relyingparty/classes.relyingpartysessions.ts +1017 -0
  42. package/ts_relyingparty/classes.rpmodels.ts +170 -0
  43. package/ts_relyingparty/classes.secretbox.ts +49 -0
  44. package/ts_relyingparty/classes.socketbinding.ts +156 -0
  45. package/ts_relyingparty/helpers.cookies.ts +79 -0
  46. package/ts_relyingparty/helpers.crypto.ts +49 -0
  47. package/ts_relyingparty/helpers.returnpath.ts +44 -0
  48. package/ts_relyingparty/index.ts +13 -0
  49. package/ts_relyingparty/interfaces.ts +165 -0
  50. package/ts_relyingparty/plugins.ts +17 -0
package/readme.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Reusable TypeScript SDK for building against idp.global from both sides of the wire: browser apps that need SSO, JWT housekeeping, transfer-token handoff, and typed IdP requests; and server apps that need explicit account storage, local password auth, optional idp.global password verification, and confidential OIDC membership introspection.
4
4
 
5
- The package is intentionally split by runtime. Use `@idp.global/sdk/browser` in browser bundles and `@idp.global/sdk/server` in Node.js services. The root `@idp.global/sdk` export does not expose application APIs.
5
+ The package is intentionally split by runtime. Use `@idp.global/sdk/browser` in browser bundles, `@idp.global/sdk/server` in Node.js services and `@idp.global/sdk/relyingparty` for a server application that signs its users in through idp.global. The root `@idp.global/sdk` export does not expose application APIs.
6
6
 
7
7
  ## Issue Reporting and Security
8
8
 
@@ -14,6 +14,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
14
14
  | --- | --- | --- |
15
15
  | Browser | `@idp.global/sdk/browser` | SSO client, JWT and refresh-token storage, login redirects, logout, transfer-token exchange, and typed request shortcuts for user, organization, billing, OIDC, and admin flows. |
16
16
  | Server | `@idp.global/sdk/server` | NoSQL-backed account store, scrypt password hashing, local/idp.global account authentication, a server-side typed-socket client for idp.global password login, and confidential OIDC introspection. |
17
+ | Relying party | `@idp.global/sdk/relyingparty` | The OIDC relying-party session layer: authorization code flow with PKCE on `openid-client`, login attempts and HttpOnly cookie sessions with CSRF tokens on NoSQL, sealed provider tokens rotated under a cross-replica lock, live organization authority, HTTP login routes, a TypedRouter actor scope and TypedSocket session binding. |
17
18
 
18
19
  ## Install
19
20
 
@@ -21,8 +22,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
21
22
  pnpm add @idp.global/sdk
22
23
  ```
23
24
 
24
- The server subpath additionally requires the peer dependency `@lossless.org/client`,
25
- which provides the `SmartdataDb` the account store persists through:
25
+ The server and relying-party subpaths additionally require the peer dependency
26
+ `@lossless.org/client`, which provides the `SmartdataDb` the account store and the
27
+ session models persist through:
26
28
 
27
29
  ```bash
28
30
  pnpm add @lossless.org/client
@@ -349,6 +351,16 @@ existing login and principal binding and make a fresh observation for each
349
351
  operation requiring live authority. The response contains no identity profile
350
352
  or email-based organization inference.
351
353
 
354
+ `AuthorityObserver` in `@idp.global/sdk/relyingparty` follows this contract by
355
+ default: `positiveTtlMs` and `negativeTtlMs` are `0`, so every operation is
356
+ observed freshly. An application may opt into reusing a positive observation for
357
+ a bounded time (`positiveTtlMs`) and, separately, a refusal (`negativeTtlMs`),
358
+ per login session and organization. The trade-off is explicit: a membership
359
+ removed, a role lowered or an app connection revoked at idp.global takes effect
360
+ in that application only once the reused answer expires — up to the configured
361
+ TTL — while ending the login session in the application drops its reused answers
362
+ at once. A failure to observe is never reused.
363
+
352
364
  ### Server-held OIDC UserInfo
353
365
 
354
366
  Use `OidcUserInfoClient` to obtain current profile and organization picker data
@@ -421,6 +433,205 @@ The server export includes these TypeScript types:
421
433
  | `IIdpGlobalServerClientOptions` | Server client configuration with optional `baseUrl` and `backendToken`; defaults to `https://app.idp.global`. |
422
434
  | `IIdpPasswordAuthResult` | Privacy-safe current-user DTO, JWT, and refresh-token result. |
423
435
 
436
+ ## Relying-Party Sessions
437
+
438
+ `@idp.global/sdk/relyingparty` is the session layer of a server application that
439
+ signs its users in through idp.global. The application supplies its database,
440
+ its keys and what a verified subject means to it; the module runs everything in
441
+ between.
442
+
443
+ ```ts
444
+ import * as nosqldb from '@lossless.org/client/nosqldb';
445
+ import {
446
+ AuthorityObserver,
447
+ createRelyingPartyModels,
448
+ IdpOidcProvider,
449
+ RelyingPartyRoutes,
450
+ RelyingPartySessions,
451
+ decodeSecretKey,
452
+ } from '@idp.global/sdk/relyingparty';
453
+
454
+ const provider = new IdpOidcProvider({
455
+ issuer: new URL('https://idp.global'),
456
+ clientId: process.env.IDP_CLIENT_ID!,
457
+ clientSecret: process.env.IDP_CLIENT_SECRET!,
458
+ redirectUri: new URL('/auth/callback', 'https://app.example'),
459
+ });
460
+ await provider.init();
461
+
462
+ const models = createRelyingPartyModels({ db: smartdataDb });
463
+ await models.Session.ensureInitialized();
464
+ await models.LoginAttempt.ensureInitialized();
465
+
466
+ let authority: AuthorityObserver<'manage' | 'read'>;
467
+ const sessions = new RelyingPartySessions({
468
+ models,
469
+ provider,
470
+ publicOrigin: 'https://app.example',
471
+ sessionEncryptionKey: decodeSecretKey(process.env.SESSION_ENCRYPTION_KEY!, 'SESSION_ENCRYPTION_KEY'),
472
+ csrfKey: decodeSecretKey(process.env.CSRF_HMAC_KEY!, 'CSRF_HMAC_KEY'),
473
+ principals: {
474
+ // create or update the local user; throw RelyingPartyRefusal to refuse the login
475
+ admit: async (completion) => ({ principalId: await users.upsertFromIdp(completion) }),
476
+ isActive: async (principalId) => users.isActive(principalId),
477
+ },
478
+ hooks: {
479
+ onSessionEnded: ({ sessionId, revokedHere }) => {
480
+ authority.forgetSession(sessionId);
481
+ if (revokedHere) socketGate.closeSession(sessionId);
482
+ },
483
+ },
484
+ });
485
+
486
+ authority = new AuthorityObserver({
487
+ provider,
488
+ tokens: sessions,
489
+ project: (roles) => (roles.includes('owner') ? 'manage' : roles.includes('viewer') ? 'read' : undefined),
490
+ });
491
+
492
+ const routes = new RelyingPartyRoutes({ sessions, failureLocation: '/?auth=error' });
493
+ typedServer.addRoute('/auth/*', 'ALL', routes.handle);
494
+ ```
495
+
496
+ What it does:
497
+
498
+ - **Login**: `GET /auth/login?return=/path` stores one login attempt (state and
499
+ browser binding stored as SHA-256, PKCE verifier sealed) and redirects to the
500
+ provider. `GET /auth/callback` consumes the attempt exactly once, exchanges
501
+ the code, reads UserInfo with a subject check, calls `principals.admit` and
502
+ opens a session; a provider `error` abandons the attempt. `GET /auth/finalize`
503
+ releases the return path only to the browser holding the session that attempt
504
+ created. Return paths stay on the public origin and outside the login routes.
505
+ Expired attempts are swept when the next login starts.
506
+ - **Sessions**: an HttpOnly, `SameSite=Lax` cookie whose token is stored as a
507
+ hash, touched at most once a minute, with a CSRF token derived per request as
508
+ an HMAC of the cookie token. `GET /auth/session` answers the session and
509
+ `POST /auth/logout` needs the public origin and the CSRF token; its body is
510
+ cancelled as soon as it passes `maxBodyBytes`. Every end of a session is one
511
+ conditional update; its provider tokens go back to idp.global within a
512
+ three-second budget afterwards, and so do the tokens of a login that failed
513
+ after the code exchange.
514
+ - **Provider tokens**: sealed with AES-256-GCM (`SecretBox`) and rotated shortly
515
+ before they expire, once per process and under a lock stored on the session,
516
+ so two replicas never spend one refresh token. A refused refresh ends the
517
+ session; an unreachable provider fails closed.
518
+ - **Live authority**: `AuthorityObserver.observe({ loginSessionId, principalId,
519
+ idpOrganizationId })` introspects the organization with the session's live
520
+ token and projects the role keys through the application's table. It answers
521
+ a grant or throws `AuthorityRefusedError` (with `firstRefusal` once per
522
+ transition, for an audit trail) or `AuthorityTokenError`.
523
+ - **Transports**: `createActorScope()` gives a TypedRouter request scope
524
+ (`addRequestScope`) that runs every dispatch as the actor its trusted local
525
+ data names. `RelyingPartySocketBinding` registers a TypedSocket connection
526
+ from its upgrade `Origin` and `Cookie` plus the page's CSRF token, pins it to
527
+ the session, re-validates it per request and finds a session's connections by
528
+ a server-owned tag. Connection limits and sweeps stay with the application.
529
+
530
+ Nothing runs on a timer, and no token, cookie or sealed value ever reaches a
531
+ log line or an error message.
532
+
533
+ Application hooks never stand in the way of that cleanup. A throwing (or
534
+ rejecting) `onSessionEnded` or `onEvent` is reported as a `hook_failed` event —
535
+ `onEvent`'s own failure on `console.error` — and the provider tokens go back
536
+ regardless. A throwing `onProviderSessionOpened` fails the login: the session it
537
+ opened is revoked, its tokens are handed back and the hook's error is the
538
+ login's cause. Custom `cookieNames` with a `__Host-` or `__Secure-` prefix need
539
+ an https `publicOrigin`; on http the constructor refuses them, because browsers
540
+ would silently drop those cookies.
541
+
542
+ **Adopting existing session data.** The persisted fields and index declarations
543
+ are those nevermind.finance has stored since mainapp 0b693cb. An application
544
+ that already holds such collections binds the models to them and passes its own
545
+ cookie names and CSRF label, so live sessions and sealed tokens keep working:
546
+
547
+ ```ts
548
+ const models = createRelyingPartyModels({
549
+ db: smartdataDb,
550
+ collectionNames: { sessions: 'SessionDoc', loginAttempts: 'LoginAttemptDoc' },
551
+ });
552
+ const sessions = new RelyingPartySessions({
553
+ models,
554
+ cookieNames: { session: 'nm_session', intent: '__Host-nm_oidc_intent' },
555
+ csrfLabel: 'nevermind.finance:csrf:v1\u0000',
556
+ // …
557
+ });
558
+ ```
559
+
560
+ A deployment without a provider (`provider` omitted) keeps the same session
561
+ store for sessions it opens itself with `openSession({ principalId })`, for
562
+ example after a local password check.
563
+
564
+ **Building before the database connects.** `db` may be a function that answers
565
+ the database once it exists. It is called on every use of a model, the way
566
+ `@Collection(() => db)` resolves it, and never while the models,
567
+ `RelyingPartySessions`, `RelyingPartyRoutes` or `RelyingPartySocketBinding` are
568
+ constructed — none of them does I/O in its constructor. A use before the
569
+ function answers a `SmartdataDb` (or while it throws) rejects with
570
+ `RelyingPartyDatabaseUnavailableError`, whose `cause` is the function's error:
571
+
572
+ ```ts
573
+ const models = createRelyingPartyModels({ db: () => appDatabase.db });
574
+ const sessions = new RelyingPartySessions({ models, /* … */ });
575
+ // later, at startup
576
+ await appDatabase.init();
577
+ await models.Session.ensureInitialized();
578
+ await models.LoginAttempt.ensureInitialized();
579
+ ```
580
+
581
+ **Reading the principal once.** `principals.isActive` answers a yes or no, so a
582
+ request that needs the user reads it a second time. Principals may give
583
+ `resolve` instead: it answers the principal when it may act right now and
584
+ `null` otherwise, and every live session carries that answer as `principal` —
585
+ on `resolveCookieSession`, `revalidateLiveSession`, `RelyingPartySocketBinding.revalidate`
586
+ and the routes' `sessionAnswer`. The principal type is inferred from `resolve`;
587
+ with `isActive` it is `undefined`. Give exactly one of the two; the constructor
588
+ refuses both or neither.
589
+
590
+ ```ts
591
+ const sessions = new RelyingPartySessions({
592
+ // …
593
+ principals: {
594
+ admit: async (completion) => ({ principalId: await users.upsertFromIdp(completion) }),
595
+ resolve: async (principalId) => {
596
+ const user = await users.get(principalId);
597
+ return user?.status === 'active' ? user : null;
598
+ },
599
+ },
600
+ });
601
+ const resolved = await sessions.resolveCookieSession(cookieHeader);
602
+ resolved?.principal.displayName; // typed as the user
603
+ ```
604
+
605
+ **Recording a logout.** `RelyingPartyRoutes` takes
606
+ `onLogout({ session, endedLiveSession })`. It runs only for a `POST
607
+ <prefix>logout` that passed the origin and CSRF checks, after the session was
608
+ ended, and the answer waits for it, so an audit record is written before the
609
+ browser learns the logout is done. `endedLiveSession` is `true` only when this
610
+ call ended a live session: a logout that lost a race to another end gets
611
+ `false`, and a repeated logout finds no session and does not call it. A hook
612
+ that throws or rejects never keeps the session alive and never changes the
613
+ answer — the session is already over — and is reported as a `hook_failed`
614
+ route event (`hook: 'onLogout'`) through `onRouteEvent`. The hook runs without
615
+ a deadline of its own; bound a slow audit store inside it.
616
+
617
+ **Socket peers.** `RelyingPartySocketBinding.tagConnection` and `upgradeOf` take
618
+ the connection wrapper or the bare SmartServe peer (`TRelyingPartySocketPeer`),
619
+ the two forms `TypedSocket.setServerTag` accepts.
620
+
621
+ ### Relying-Party API Surface
622
+
623
+ | API | Description |
624
+ | --- | --- |
625
+ | `IdpOidcProvider` | Discovery, PKCE authorization URL, code exchange with UserInfo, refresh, revocation and organization introspection for idp.global. Implements `IOidcProvider`. |
626
+ | `createRelyingPartyModels()` | Binds the session and login-attempt models to one `SmartdataDb`, or to a function asked for it on every use, optionally under existing collection names. |
627
+ | `RelyingPartySessions<TPrincipal>` | Login start/complete/abandon/finalize, `openSession`, `resolveCookieSession`, `revalidateSession`, `revalidateLiveSession`, `isSessionLive`, `validateCsrf`, `logout`/`endSession`, `endSessionsOfPrincipal`, `liveProviderToken`. Principals give `isActive` or `resolve` (`IRelyingPartyPrincipals`, `IRelyingPartyPrincipalResolver<TPrincipal>`); with `resolve`, live sessions carry `principal`. |
628
+ | `AuthorityObserver` | Live organization authority with opt-in positive and negative reuse. |
629
+ | `RelyingPartyRoutes` | `handle(ctx)` for `/auth/login`, `/auth/callback`, `/auth/finalize`, `/auth/session` and `/auth/logout`; `null` for anything else. Takes a typedserver route context. `onLogout` is awaited after a CSRF-checked logout ended the session. |
630
+ | `createActorScope()` | AsyncLocalStorage actor context with a TypedRouter request scope. |
631
+ | `RelyingPartySocketBinding` | TypedSocket browser registration, per-request re-validation and session tags; takes a connection wrapper or its peer. |
632
+ | `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()`, `sanitizeReturnPath()` | The primitives underneath. |
633
+ | `RelyingPartyError`, `RelyingPartyRefusal`, `LoginCallbackError`, `AuthorityTokenError`, `AuthorityRefusedError`, `RelyingPartyDatabaseUnavailableError` | Errors with stable codes; none carries token material. |
634
+
424
635
  ## Runtime Notes
425
636
 
426
637
  Use the explicit subpath imports. They keep browser bundles free of Node-only code and keep server processes free of browser storage dependencies.
@@ -438,7 +649,7 @@ The SDK uses typed sockets and typed requests from `@api.global/*`, shared idp.g
438
649
  pnpm test
439
650
  ```
440
651
 
441
- The test suite covers the server account store and auth service, browser transfer-token handling, typed administrative request names, and the exact backend JWT keyset and blocklist contracts.
652
+ The test suite covers the server account store and auth service, browser transfer-token handling, typed administrative request names, the exact backend JWT keyset and blocklist contracts, and the relying-party layer: its primitives, the OIDC provider against a fetch-level issuer double, sessions and two-replica token rotation on an in-memory NoSQL engine with an injected clock, the persisted shape, the authority observer, the HTTP routes, the actor scope and the socket binding.
442
653
 
443
654
  ## License and Legal Information
444
655
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@idp.global/sdk',
6
- version: '18.0.1',
6
+ version: '18.2.0',
7
7
  description: 'Reusable browser and server SDK for idp.global authentication.'
8
8
  }
@@ -0,0 +1,39 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ export interface IActorScope<TContext> {
4
+ /** Runs `operationArg` and everything it awaits as this actor. */
5
+ run<T>(contextArg: TContext, operationArg: () => T): T;
6
+ /** The actor of the operation in flight, or `undefined` outside one. */
7
+ current(): TContext | undefined;
8
+ /** Runs `operationArg` without any actor, for background work entered from a request. */
9
+ exit<T>(operationArg: () => T): T;
10
+ /**
11
+ * The request scope of a `TypedRouter` (`addRequestScope`): middleware and
12
+ * handler of every dispatch run as the actor the transport's trusted local
13
+ * data names. A dispatch without one runs without an actor.
14
+ */
15
+ readonly requestScope: plugins.typedrequest.TTypedRouterRequestScope;
16
+ }
17
+
18
+ /**
19
+ * An AsyncLocalStorage-backed actor context. The application decides what an
20
+ * actor is and how it is read from the trusted local data its transports set;
21
+ * nothing a handler can reach decides it.
22
+ */
23
+ export const createActorScope = <TContext>(optionsArg: {
24
+ fromLocalData(localDataArg: Readonly<Record<string, unknown>>): TContext | undefined;
25
+ }): IActorScope<TContext> => {
26
+ const storage = new plugins.asyncHooks.AsyncLocalStorage<TContext>();
27
+ return {
28
+ run: (contextArg, operationArg) => storage.run(contextArg, operationArg),
29
+ current: () => storage.getStore(),
30
+ exit: (operationArg) => storage.exit(operationArg),
31
+ requestScope: (_typedRequestArg, nextArg, contextArg) => {
32
+ const actor = optionsArg.fromLocalData(contextArg.localData);
33
+ if (actor === undefined) {
34
+ return nextArg();
35
+ }
36
+ return storage.run(actor, nextArg);
37
+ },
38
+ };
39
+ };
@@ -0,0 +1,253 @@
1
+ import { AuthorityTokenError } from './classes.errors.js';
2
+ import { OidcAuthorityUnavailableError, type IOidcProvider } from './classes.idpoidcprovider.js';
3
+ import type { IAuthorityTokenSource } from './interfaces.js';
4
+
5
+ /** Why an observation refused: no membership at all, or roles the application maps to nothing. */
6
+ export type TAuthorityRefusalReason = 'idp_membership_absent' | 'idp_roles_unmapped';
7
+
8
+ /**
9
+ * idp.global no longer reports a usable membership of the organization.
10
+ * `firstRefusal` is `true` exactly once per transition from access to refusal
11
+ * for one login session and organization, so an audit trail records the
12
+ * transition and not every refused request.
13
+ */
14
+ export class AuthorityRefusedError extends Error {
15
+ public readonly code = 'organization_access_revoked';
16
+ public readonly status = 403;
17
+
18
+ constructor(
19
+ public readonly reason: TAuthorityRefusalReason,
20
+ public readonly firstRefusal: boolean,
21
+ ) {
22
+ super('organization_access_revoked');
23
+ this.name = 'AuthorityRefusedError';
24
+ }
25
+ }
26
+
27
+ /** One live observation, as the caller reads it. */
28
+ export interface IAuthorityObservation<TGrant> {
29
+ /** what the application's projection made of the roles */
30
+ grant: TGrant;
31
+ /** the idp role keys the provider reported, sorted */
32
+ roles: readonly string[];
33
+ /** the observation's own `exp` in epoch milliseconds */
34
+ tokenExpiresAt: number;
35
+ observedAt: number;
36
+ }
37
+
38
+ export interface IAuthorityObserverOptions<TGrant> {
39
+ provider: IOidcProvider;
40
+ tokens: IAuthorityTokenSource;
41
+ /** the application's role table: what the provider's role keys grant here, `undefined` for nothing */
42
+ project(rolesArg: readonly string[]): TGrant | undefined;
43
+ /**
44
+ * How long a positive observation answers later requests of the same login
45
+ * session and organization. `0` (the default) observes freshly for every
46
+ * operation, which is the provider's contract; a positive value is an
47
+ * explicit opt-in under which a revocation at idp.global takes effect here
48
+ * only once the cached answer expires.
49
+ */
50
+ positiveTtlMs?: number;
51
+ /** How long a refusal is reused; `0` (the default) never reuses one. */
52
+ negativeTtlMs?: number;
53
+ /** the bound of remembered observations */
54
+ maxEntries?: number;
55
+ now?: () => number;
56
+ }
57
+
58
+ const DEFAULT_MAX_ENTRIES = 2_000;
59
+ /** how much room an eviction makes, so it does not run on every insert */
60
+ const EVICTION_HEADROOM = 0.1;
61
+
62
+ interface ICachedObservation<TGrant> {
63
+ outcome: 'active' | 'inactive';
64
+ grant?: TGrant;
65
+ reason?: TAuthorityRefusalReason;
66
+ roles: readonly string[];
67
+ tokenExpiresAt: number;
68
+ observedAt: number;
69
+ /** an entry at or past it never answers a request */
70
+ expiresAt: number;
71
+ /** the refusal whose transition was already reported */
72
+ reported: boolean;
73
+ }
74
+
75
+ const assertTtl = (valueArg: number, labelArg: string): number => {
76
+ if (!Number.isSafeInteger(valueArg) || valueArg < 0) {
77
+ throw new Error(labelArg + ' must be a non-negative integer of milliseconds');
78
+ }
79
+ return valueArg;
80
+ };
81
+
82
+ /**
83
+ * Live organization authority through idp.global: one `POST /oauth/introspect`
84
+ * observation of a login session's subject in one organization, projected by
85
+ * the application's role table.
86
+ *
87
+ * Observations of one key run once however many callers ask at the same time.
88
+ * A failure to observe is never remembered, so the caller fails closed on
89
+ * every attempt while the provider is unreachable. A session that is forgotten
90
+ * while its observation is in flight never stores a positive answer. Nothing
91
+ * runs on a timer: the cache is pruned when it is written to.
92
+ */
93
+ export class AuthorityObserver<TGrant> {
94
+ readonly #cache = new Map<string, ICachedObservation<TGrant>>();
95
+ readonly #inFlight = new Map<string, Promise<ICachedObservation<TGrant>>>();
96
+ readonly #forgotten = new Set<string>();
97
+ readonly #provider: IOidcProvider;
98
+ readonly #tokens: IAuthorityTokenSource;
99
+ readonly #project: (rolesArg: readonly string[]) => TGrant | undefined;
100
+ readonly #positiveTtlMs: number;
101
+ readonly #negativeTtlMs: number;
102
+ readonly #maxEntries: number;
103
+ readonly #now: () => number;
104
+
105
+ constructor(optionsArg: IAuthorityObserverOptions<TGrant>) {
106
+ this.#provider = optionsArg.provider;
107
+ this.#tokens = optionsArg.tokens;
108
+ this.#project = optionsArg.project;
109
+ this.#positiveTtlMs = assertTtl(optionsArg.positiveTtlMs ?? 0, 'positiveTtlMs');
110
+ this.#negativeTtlMs = assertTtl(optionsArg.negativeTtlMs ?? 0, 'negativeTtlMs');
111
+ this.#maxEntries = optionsArg.maxEntries ?? DEFAULT_MAX_ENTRIES;
112
+ if (!Number.isSafeInteger(this.#maxEntries) || this.#maxEntries < 1) {
113
+ throw new Error('maxEntries must be a positive integer');
114
+ }
115
+ this.#now = optionsArg.now ?? (() => Date.now());
116
+ }
117
+
118
+ /** How many observations are remembered right now. */
119
+ public get size(): number {
120
+ return this.#cache.size;
121
+ }
122
+
123
+ /**
124
+ * The grant of `principalId`'s login session in one organization at
125
+ * idp.global, or a throw: `AuthorityRefusedError` for a refusal,
126
+ * `AuthorityTokenError` when there is no live login to observe with or the
127
+ * provider could not be observed.
128
+ */
129
+ public async observe(optionsArg: {
130
+ loginSessionId: string;
131
+ principalId: string;
132
+ idpOrganizationId: string;
133
+ }): Promise<IAuthorityObservation<TGrant>> {
134
+ const key = `${optionsArg.loginSessionId} ${optionsArg.idpOrganizationId}`;
135
+ const cached = this.liveEntry(key);
136
+ const observation = cached ?? (await this.observeOnce(key, optionsArg));
137
+ if (observation.outcome !== 'active' || observation.grant === undefined) {
138
+ const firstRefusal = cached === undefined && !observation.reported;
139
+ observation.reported = true;
140
+ throw new AuthorityRefusedError(observation.reason ?? 'idp_membership_absent', firstRefusal);
141
+ }
142
+ return {
143
+ grant: observation.grant,
144
+ roles: observation.roles,
145
+ tokenExpiresAt: observation.tokenExpiresAt,
146
+ observedAt: observation.observedAt,
147
+ };
148
+ }
149
+
150
+ /** Drops every observation of a session; wire it to the session layer's `onSessionEnded`. */
151
+ public forgetSession(sessionIdArg: string): void {
152
+ const prefix = `${sessionIdArg} `;
153
+ for (const key of this.#cache.keys()) {
154
+ if (key.startsWith(prefix)) {
155
+ this.#cache.delete(key);
156
+ }
157
+ }
158
+ for (const key of this.#inFlight.keys()) {
159
+ if (key.startsWith(prefix)) {
160
+ this.#forgotten.add(key);
161
+ }
162
+ }
163
+ }
164
+
165
+ private liveEntry(keyArg: string): ICachedObservation<TGrant> | undefined {
166
+ const entry = this.#cache.get(keyArg);
167
+ return entry && entry.expiresAt > this.#now() ? entry : undefined;
168
+ }
169
+
170
+ private async observeOnce(
171
+ keyArg: string,
172
+ optionsArg: { loginSessionId: string; principalId: string; idpOrganizationId: string },
173
+ ): Promise<ICachedObservation<TGrant>> {
174
+ const running = this.#inFlight.get(keyArg);
175
+ if (running) {
176
+ return running;
177
+ }
178
+ const pending = this.makeObservation(keyArg, optionsArg).finally(() => {
179
+ this.#inFlight.delete(keyArg);
180
+ this.#forgotten.delete(keyArg);
181
+ });
182
+ this.#inFlight.set(keyArg, pending);
183
+ return pending;
184
+ }
185
+
186
+ private async makeObservation(
187
+ keyArg: string,
188
+ optionsArg: { loginSessionId: string; principalId: string; idpOrganizationId: string },
189
+ ): Promise<ICachedObservation<TGrant>> {
190
+ 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);
201
+ }
202
+ throw error;
203
+ }
204
+ const grant = answer.active ? this.#project(answer.roles) : undefined;
205
+ const active = answer.active && grant !== undefined;
206
+ const previous = this.#cache.get(keyArg);
207
+ const entry: ICachedObservation<TGrant> = {
208
+ outcome: active ? 'active' : 'inactive',
209
+ ...(active ? { grant } : { reason: answer.active ? 'idp_roles_unmapped' : 'idp_membership_absent' }),
210
+ roles: answer.roles,
211
+ tokenExpiresAt: answer.expiresAt,
212
+ observedAt: this.#now(),
213
+ expiresAt: this.#now() + (active ? this.#positiveTtlMs : this.#negativeTtlMs),
214
+ // a refusal after a reported refusal is the same transition
215
+ reported: !active && previous?.outcome === 'inactive' && previous.reported,
216
+ };
217
+ if (active && this.#forgotten.has(keyArg)) {
218
+ // the request holding this answer finishes — it was authorized before
219
+ // the session ended — and every later one observes again
220
+ entry.expiresAt = 0;
221
+ return entry;
222
+ }
223
+ // a refusal is kept even when it answers nothing (`negativeTtlMs` 0): it is
224
+ // what reports one transition instead of one per refused request
225
+ this.#cache.delete(keyArg);
226
+ this.#cache.set(keyArg, entry);
227
+ this.prune();
228
+ return entry;
229
+ }
230
+
231
+ /** Expired entries go first; if that is not enough, the oldest do. */
232
+ private prune(): void {
233
+ if (this.#cache.size <= this.#maxEntries) {
234
+ return;
235
+ }
236
+ const now = this.#now();
237
+ for (const [key, entry] of this.#cache) {
238
+ if (entry.expiresAt <= now) {
239
+ this.#cache.delete(key);
240
+ }
241
+ }
242
+ if (this.#cache.size <= this.#maxEntries) {
243
+ return;
244
+ }
245
+ const target = Math.floor(this.#maxEntries * (1 - EVICTION_HEADROOM));
246
+ for (const key of this.#cache.keys()) {
247
+ if (this.#cache.size <= target) {
248
+ break;
249
+ }
250
+ this.#cache.delete(key);
251
+ }
252
+ }
253
+ }
@@ -0,0 +1,77 @@
1
+ import type { TLoginCallbackStage } from './interfaces.js';
2
+
3
+ export type TRelyingPartyErrorCode =
4
+ | 'login_method_unavailable'
5
+ | 'invalid_login_callback'
6
+ | 'missing_login_intent'
7
+ | 'invalid_or_replayed_login'
8
+ | 'invalid_login_finalize';
9
+
10
+ const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number>> = {
11
+ login_method_unavailable: 404,
12
+ invalid_login_callback: 400,
13
+ missing_login_intent: 400,
14
+ invalid_or_replayed_login: 400,
15
+ invalid_login_finalize: 400,
16
+ };
17
+
18
+ /** A request the login flow refuses; the code is safe to answer, the browser learns nothing more. */
19
+ export class RelyingPartyError extends Error {
20
+ public readonly status: number;
21
+
22
+ constructor(public readonly code: TRelyingPartyErrorCode) {
23
+ super(code);
24
+ this.name = 'RelyingPartyError';
25
+ this.status = RELYING_PARTY_ERROR_STATUS[code];
26
+ }
27
+ }
28
+
29
+ /**
30
+ * The application refuses a verified provider login (a disabled account, a
31
+ * subject of another issuer, no organization it serves). Thrown from
32
+ * `IRelyingPartyPrincipals.admit`; it reaches the caller unchanged.
33
+ */
34
+ export class RelyingPartyRefusal extends Error {
35
+ constructor(
36
+ public readonly code: string,
37
+ public readonly status: number,
38
+ ) {
39
+ super(code);
40
+ this.name = 'RelyingPartyRefusal';
41
+ }
42
+ }
43
+
44
+ /** A login callback that failed for a reason other than a refusal; the stage is for the log only. */
45
+ export class LoginCallbackError extends Error {
46
+ constructor(
47
+ public readonly stage: TLoginCallbackStage,
48
+ causeArg: unknown,
49
+ ) {
50
+ super('The login callback could not be completed', { cause: causeArg });
51
+ this.name = 'LoginCallbackError';
52
+ }
53
+ }
54
+
55
+ export type TAuthorityTokenErrorCode =
56
+ /** there is no live provider login this credential could be observed with */
57
+ | 'authority_login_required'
58
+ /** the provider could not be reached; the caller fails closed */
59
+ | 'authority_unavailable';
60
+
61
+ export class AuthorityTokenError extends Error {
62
+ public readonly status: number;
63
+
64
+ constructor(public readonly code: TAuthorityTokenErrorCode, causeArg?: unknown) {
65
+ super(code, { cause: causeArg });
66
+ this.name = 'AuthorityTokenError';
67
+ this.status = code === 'authority_login_required' ? 403 : 503;
68
+ }
69
+ }
70
+
71
+ /** A relying-party model was used before the database it is bound to exists. */
72
+ export class RelyingPartyDatabaseUnavailableError extends Error {
73
+ constructor(causeArg?: unknown) {
74
+ super('The relying-party models were used before their database exists', { cause: causeArg });
75
+ this.name = 'RelyingPartyDatabaseUnavailableError';
76
+ }
77
+ }