@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.
- package/changelog.md +28 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_relyingparty/classes.actorscope.d.ts +23 -0
- package/dist_ts_relyingparty/classes.actorscope.js +22 -0
- package/dist_ts_relyingparty/classes.authorityobserver.d.ts +81 -0
- package/dist_ts_relyingparty/classes.authorityobserver.js +185 -0
- package/dist_ts_relyingparty/classes.errors.d.ts +37 -0
- package/dist_ts_relyingparty/classes.errors.js +60 -0
- package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +142 -0
- package/dist_ts_relyingparty/classes.idpoidcprovider.js +255 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +102 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +248 -0
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +223 -0
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +825 -0
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +87 -0
- package/dist_ts_relyingparty/classes.rpmodels.js +302 -0
- package/dist_ts_relyingparty/classes.secretbox.d.ts +14 -0
- package/dist_ts_relyingparty/classes.secretbox.js +45 -0
- package/dist_ts_relyingparty/classes.socketbinding.d.ts +84 -0
- package/dist_ts_relyingparty/classes.socketbinding.js +100 -0
- package/dist_ts_relyingparty/helpers.cookies.d.ts +11 -0
- package/dist_ts_relyingparty/helpers.cookies.js +62 -0
- package/dist_ts_relyingparty/helpers.crypto.d.ts +12 -0
- package/dist_ts_relyingparty/helpers.crypto.js +40 -0
- package/dist_ts_relyingparty/helpers.returnpath.d.ts +9 -0
- package/dist_ts_relyingparty/helpers.returnpath.js +39 -0
- package/dist_ts_relyingparty/index.d.ts +13 -0
- package/dist_ts_relyingparty/index.js +14 -0
- package/dist_ts_relyingparty/interfaces.d.ts +142 -0
- package/dist_ts_relyingparty/interfaces.js +2 -0
- package/dist_ts_relyingparty/plugins.d.ts +10 -0
- package/dist_ts_relyingparty/plugins.js +11 -0
- package/package.json +13 -10
- package/readme.md +215 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.actorscope.ts +39 -0
- package/ts_relyingparty/classes.authorityobserver.ts +253 -0
- package/ts_relyingparty/classes.errors.ts +77 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +386 -0
- package/ts_relyingparty/classes.relyingpartyroutes.ts +319 -0
- package/ts_relyingparty/classes.relyingpartysessions.ts +1017 -0
- package/ts_relyingparty/classes.rpmodels.ts +170 -0
- package/ts_relyingparty/classes.secretbox.ts +49 -0
- package/ts_relyingparty/classes.socketbinding.ts +156 -0
- package/ts_relyingparty/helpers.cookies.ts +79 -0
- package/ts_relyingparty/helpers.crypto.ts +49 -0
- package/ts_relyingparty/helpers.returnpath.ts +44 -0
- package/ts_relyingparty/index.ts +13 -0
- package/ts_relyingparty/interfaces.ts +165 -0
- 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
|
|
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
|
|
25
|
-
which provides the `SmartdataDb` the account store
|
|
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,
|
|
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
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -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
|
+
}
|