@idp.global/sdk 19.3.0 → 19.5.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 +66 -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 +313 -0
  6. package/dist_ts_browser/classes.relyingpartybearerclient.js +646 -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 +5 -1
  14. package/dist_ts_relyingparty/classes.errors.js +3 -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 +161 -11
  18. package/dist_ts_relyingparty/classes.relyingpartysessions.js +381 -55
  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 +119 -0
  22. package/dist_ts_relyingparty/contract.bearer.js +13 -0
  23. package/dist_ts_relyingparty/helpers.bearer.d.ts +3 -4
  24. package/dist_ts_relyingparty/helpers.bearer.js +4 -6
  25. package/dist_ts_relyingparty/interfaces.d.ts +75 -26
  26. package/package.json +2 -1
  27. package/readme.md +363 -21
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts_browser/classes.relyingpartybearerclient.ts +901 -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 +7 -1
  33. package/ts_relyingparty/classes.relyingpartyroutes.ts +38 -7
  34. package/ts_relyingparty/classes.relyingpartysessions.ts +463 -65
  35. package/ts_relyingparty/classes.rpmodels.ts +7 -4
  36. package/ts_relyingparty/contract.bearer.ts +145 -0
  37. package/ts_relyingparty/helpers.bearer.ts +10 -14
  38. package/ts_relyingparty/interfaces.ts +93 -33
package/readme.md CHANGED
@@ -13,6 +13,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
13
13
  | Runtime | Import | What it does |
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
+ | Browser, relying-party sign-in only | `@idp.global/sdk/browser/bearer` | `RelyingPartyBearerClient` and the bearer contract types, without the rest of the browser client; for applications whose server runs `@idp.global/sdk/relyingparty` in bearer mode. |
16
17
  | 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
18
  | 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. |
18
19
 
@@ -151,6 +152,7 @@ await idp.logout();
151
152
  | `getTransferTokenAndSwitchToLocation()` | Request a transfer token and navigate to the same configured target with `transfertoken` attached. |
152
153
  | `processTransferToken()` | Consume a `transfertoken` from the current URL and refresh local auth state. |
153
154
  | `logout()` | Revoke and clear the current login flow. |
155
+ | `RelyingPartyBearerClient` | The bearer hand-off client of a relying party's own `web` and `popup` logins: PKCE start URL, the redirect and popup receivers, claim, refresh single-flight across tabs through Web Locks, and logout, with the token pair in an application storage adapter (`IRelyingPartyBearerTokenStorage`). `createPkce()` and `claimCode(pkce, code)` claim a code delivered another way, such as a device pairing, without a window. See [Bearer Hand-off](#bearer-hand-off). |
154
156
 
155
157
  ### Typed Request Shortcuts
156
158
 
@@ -381,6 +383,43 @@ in that application only once the reused answer expires — up to the configured
381
383
  TTL — while ending the login session in the application drops its reused answers
382
384
  at once. A failure to observe is never reused.
383
385
 
386
+ idp.global answers an introspection inactive for a login that ended as well as
387
+ for a membership that is gone. Only a refresh tells them apart: idp.global
388
+ refuses the refresh of an ended login (`invalid_grant`), and also when the user
389
+ can no longer sign in or an organization of the login is no longer active, but
390
+ rotates the token of a member who was removed from one organization. So an
391
+ inactive answer is not yet a refusal. `AuthorityObserver` first rotates the
392
+ session's provider token (`liveProviderToken(sessionId, principalId,
393
+ { rejectedAccessToken })`) and introspects again with the new token:
394
+
395
+ - A refused rotation is the ended login. It ends the session
396
+ (`refresh_token_refused`, through `onSessionEnded`) and throws
397
+ `AuthorityTokenError` `authority_login_required`. No membership is refused.
398
+ - Only an inactive answer on a token from a rotation that started after the
399
+ observation's own inactive answer throws `AuthorityRefusedError`
400
+ `idp_membership_absent`. The source marks such a token
401
+ `rotatedAfterRejection`, and the observer orders inactive answers and
402
+ rotation starts by one sequence; it compares no tokens.
403
+ - Every other inactive answer throws `authority_unavailable` and keeps nothing:
404
+ when the rotation started before the answer, when another replica holds the
405
+ session's rotation, when a rotation was already running, and when the session
406
+ has no refresh token.
407
+ - An observer rotates a session's token for an inactive answer at most once per
408
+ `AUTHORITY_FORCED_REFRESH_INTERVAL_MS` (60 s). An inactive answer within that
409
+ time of the rotation is `authority_unavailable`, as is every one while a
410
+ rotation that failed stands. After it the next inactive answer rotates and
411
+ decides.
412
+ - A deactivated application is not a refusal either. idp.global answers its
413
+ refresh with `invalid_client`, which reads as `authority_unavailable`.
414
+ - idp.global counts refused token requests per client: after 10 in 10 minutes
415
+ without a successful one in between, it blocks the client's token requests
416
+ for 15 minutes. A rotation only refuses earlier what the session's next
417
+ refresh would refuse, once per session.
418
+ - A token source of an application's own (`IAuthorityTokenSource`) must rotate
419
+ when it is given `rejectedAccessToken` and mark the token. One that never
420
+ rotates or marks, as a 19.3.0 source does, can never refuse: its inactive
421
+ answers are `authority_unavailable`.
422
+
384
423
  ### Server-held OIDC UserInfo
385
424
 
386
425
  Use `OidcUserInfoClient` to obtain current profile and organization picker data
@@ -562,7 +601,9 @@ What it does:
562
601
  idpOrganizationId })` introspects the organization with the session's live
563
602
  token and projects the role keys through the application's table. It answers
564
603
  a grant or throws `AuthorityRefusedError` (with `firstRefusal` once per
565
- transition, for an audit trail) or `AuthorityTokenError`.
604
+ transition, for an audit trail) or `AuthorityTokenError`. An inactive answer
605
+ rotates the token before it refuses, and a login that ended at idp.global ends
606
+ the session (`authority_login_required`); see the authority contract above.
566
607
  - **Transports**: `createActorScope()` gives a TypedRouter request scope
567
608
  (`addRequestScope`) that runs every dispatch as the actor its trusted local
568
609
  data names. `RelyingPartySocketBinding` registers a TypedSocket connection
@@ -573,6 +614,13 @@ What it does:
573
614
  Nothing runs on a timer, and no token, cookie or sealed value ever reaches a
574
615
  log line or an error message.
575
616
 
617
+ `onSessionEnded` names the session, the reason and whether this call revoked
618
+ it (`revokedHere`); when it did, it also names the session's principal
619
+ (`principalId`), and when the session was over already it does not.
620
+ `onProviderSessionOpened` receives the session and, for a bearer login, its
621
+ `delivery`: the client kind, the return target and the client's `state`. A
622
+ cookie login has no `delivery`.
623
+
576
624
  Application hooks never stand in the way of that cleanup. A throwing (or
577
625
  rejecting) `onSessionEnded` or `onEvent` is reported as a `hook_failed` event —
578
626
  `onEvent`'s own failure on `console.error` — and the provider tokens go back
@@ -583,8 +631,9 @@ an https `publicOrigin`; on http the constructor refuses them, because browsers
583
631
  would silently drop those cookies.
584
632
 
585
633
  **Adopting existing session data.** The persisted fields and index declarations
586
- are those nevermind.finance has stored since mainapp 0b693cb. An application
587
- that already holds such collections binds the models to them and passes its own
634
+ are those nevermind.finance has stored since mainapp 0b693cb, and the session
635
+ model adds an `expiresAt` index, built on first use. An application that
636
+ already holds such collections binds the models to them and passes its own
588
637
  cookie names and CSRF label, so live sessions and sealed tokens keep working:
589
638
 
590
639
  ```ts
@@ -604,6 +653,49 @@ A deployment without a provider (`provider` omitted) keeps the same session
604
653
  store for sessions it opens itself with `openSession({ principalId })`, for
605
654
  example after a local password check.
606
655
 
656
+ Nothing runs on a timer. A login start first removes up to 100 login attempts
657
+ and up to 100 sessions that ended more than an hour ago, and a bearer login
658
+ start also its bearer hand-offs and token generations; `openBearerHandoff`
659
+ removes the bearer state and the sessions, `openSession` the sessions only.
660
+ Removing an ended session fires no end hook and calls no provider.
661
+
662
+ **Deleting an account.** The sweeps above run only when a session is created,
663
+ so an ended session can stay stored for long on a quiet service.
664
+ `eraseSessionsOfPrincipal(principalId)` removes everything of one principal at
665
+ once: an application calls it when it deletes the account, after its own
666
+ erasers and before its user record, so a failure can be retried.
667
+
668
+ ```ts
669
+ await accountData.eraseAll(userId); // the application's own records
670
+ const erasure = await sessions.eraseSessionsOfPrincipal(userId);
671
+ await users.delete(userId);
672
+ ```
673
+
674
+ - It ends every session of the principal that is not revoked yet, an expired
675
+ one too, as `endSession` does: its tokens stop working at once,
676
+ `onSessionEnded` fires (reason `logout`, with `principalId`), and its
677
+ provider tokens go back to idp.global, which a deleted row could no longer
678
+ do.
679
+ - Then it deletes every session row of the principal, live or ended, and with
680
+ each the login attempt a provider session came out of and, in bearer mode,
681
+ its hand-offs (claimed or not) and its token generations. The session row
682
+ goes last: it is the only link from the principal to the others, so an
683
+ erase that fails half way leaves a retry everything it needs.
684
+ - It answers what it removed (`IRelyingPartySessionsErasure`: `endedSessions`,
685
+ `deletedSessions`, `deletedLoginAttempts`, `deletedBearerHandoffs`,
686
+ `deletedBearerTokens`); a second call finds nothing.
687
+ - A session opened by a login already past `admit` when the erase runs is not
688
+ covered. It keeps the principal id until it expires and a later sweep
689
+ removes it. A second `eraseSessionsOfPrincipal(principalId)`, run after the
690
+ user record is deleted, removes it. A token generation issued by a refresh
691
+ already under way is not covered either; it outlives its session row as
692
+ described below.
693
+ - A token generation, and for at most a minute a hand-off, outlives the row
694
+ of its session when that session was revoked and swept before its expiry.
695
+ It holds only hashes, the session id and times, the bearer sweep removes it
696
+ an hour after the session would have expired, and the erase cannot
697
+ attribute it to the principal any more.
698
+
607
699
  **Building before the database connects.** `db` may be a function that answers
608
700
  the database once it exists. It is called on every use of a model, the way
609
701
  `@Collection(() => db)` resolves it, and never while the models,
@@ -795,14 +887,17 @@ Tokens and their ends:
795
887
  bearer login sweeps it (`handoff_unclaimed`). Each answers 400
796
888
  `invalid_bearer_handoff`; reuse is also reported as a
797
889
  `bearer_credential_reused` event.
798
- - A bearer session is a provider login like a cookie session: `onSessionEnded`,
799
- `endSessionsOfPrincipal` and `AuthorityObserver` apply as they are. A refresh
890
+ - A bearer login is a provider login like a cookie session: `onSessionEnded`,
891
+ `endSessionsOfPrincipal` and `AuthorityObserver` apply as they are. (A
892
+ session opened by pairing a device has no provider login of its own; see
893
+ "Pairing a device" below.) A refresh
800
894
  rotates the provider token when it is due and ends the session when
801
895
  idp.global has ended the login (`invalid_bearer_grant`); an unreachable
802
896
  provider answers 503 `authority_unavailable` and spends nothing. Until then
803
897
  an access token works for its remaining lifetime. An application that must
804
898
  see an idp.global logout sooner observes authority per request
805
- (`authority.observe({ loginSessionId: live.session.id, … })`).
899
+ (`authority.observe({ loginSessionId: live.session.id, … })`), which ends the
900
+ session as soon as idp.global has ended the login.
806
901
 
807
902
  The four API routes answer CORS, including the preflight, for the popup
808
903
  origins exactly and never allow credentials; every other origin gets no CORS
@@ -815,23 +910,266 @@ The client side:
815
910
  `Referrer-Policy: no-referrer`.
816
911
  - A popup client opens the login with `window.open` (without `noopener`) and
817
912
  accepts only the message of the popup it opened, from the relying party's
818
- public origin, with its own `state`:
913
+ public origin, with its own `state`. The opener page must keep its opener
914
+ link to the popup: `Cross-Origin-Opener-Policy: same-origin` severs it, while
915
+ `same-origin-allow-popups` keeps it.
916
+ - Every client claims a code once and never retries a claim or a refresh. Its
917
+ refreshes run one at a time across everything that shares the pair, and a
918
+ rotated pair is stored, all of it at once, before its access token is first
919
+ used: a pair that is lost after the relying party rotated it cannot be
920
+ recovered, and its spent refresh token coming back ends the session. Native
921
+ clients implement this side themselves.
922
+
923
+ The HTTP answers are exported as types (`IRelyingPartyBearerTokenAnswer`,
924
+ `IRelyingPartyBearerSessionAnswer`, `IRelyingPartyBearerLogoutAnswer`,
925
+ `IRelyingPartyBearerErrorAnswer` with `TRelyingPartyBearerErrorCode`, and the
926
+ popup's `IRelyingPartyBearerMessage`), and so are the requests:
927
+ `IRelyingPartyBearerLoginQuery` (the `bearer/login` query by its parameter
928
+ names), `IRelyingPartyBearerClaimRequest` (`{ code, codeVerifier }`),
929
+ `IRelyingPartyBearerRefreshRequest` (`{ refreshToken }`) and
930
+ `IRelyingPartyBearerLogoutRequest` (`{ refreshToken? }`). They come from a
931
+ module that imports nothing, so `@idp.global/sdk/browser` and
932
+ `@idp.global/sdk/browser/bearer` share them with the server.
933
+
934
+ **The browser client.** `RelyingPartyBearerClient` from `@idp.global/sdk/browser`
935
+ is that client side for `web` and `popup` logins. The application owns where the
936
+ token pair lives: it passes a storage adapter, typically over IndexedDB, and the
937
+ client keeps nothing of its own across reloads.
938
+
939
+ ```ts
940
+ import { RelyingPartyBearerClient, type IRelyingPartyBearerTokenStorage } from '@idp.global/sdk/browser';
941
+
942
+ const storage: IRelyingPartyBearerTokenStorage = {
943
+ get: () => tokenStore.get('bearer'), // the stored pair, or null
944
+ set: (tokens, { signal }) => tokenStore.put('bearer', tokens, { signal }), // resolves once durable, writes nothing once aborted
945
+ clear: ({ signal }) => tokenStore.delete('bearer', { signal }), // resolves once durable, removes nothing once aborted
946
+ };
947
+ const client = new RelyingPartyBearerClient({ origin: 'https://app.example', storage });
948
+
949
+ // web: start, and receive on the return target page
950
+ const login = await client.createLogin({ clientKind: 'web', returnTarget: '/signed-in' });
951
+ sessionStorage.setItem('bearer-login', JSON.stringify(login));
952
+ location.assign(login.url);
953
+ // … on /signed-in
954
+ const result = await client.completeRedirectLogin(JSON.parse(sessionStorage.getItem('bearer-login') ?? 'null'));
955
+ // result.tokens is the stored pair; result.previousSession says what became of the one it replaced
956
+
957
+ // popup: make the login first, open it in the click handler itself
958
+ const popupLogin = await client.createLogin({ clientKind: 'popup', returnTarget: 'https://workspace.example' });
959
+ button.onclick = () => client.openPopupLogin(popupLogin).then(showWorkspace, showSignInError);
960
+
961
+ // every call
962
+ const accessToken = await client.accessToken(); // null without a session
963
+ // after the relying party refused one
964
+ await client.refresh(rejectedAccessToken);
965
+ await client.logout();
966
+ ```
967
+
968
+ - `createLogin` makes the verifier, its S256 challenge and the state from 32
969
+ random bytes each and answers them with the start URL. The pending login is
970
+ the application's to keep until it comes back; it holds the verifier.
971
+ - `completeRedirectLogin(login)` answers `null` on a page without a delivery.
972
+ It removes `code`, `error` and `state` from the address first, then refuses a
973
+ state that is not the login's (`state_mismatch`, nothing claimed) and claims
974
+ the code once (`already_received` after that).
975
+ - A received login (`IRelyingPartyBearerLoginResult`) replaces the stored pair.
976
+ After the claim, under the lock, the client stores the new pair and then
977
+ ends the session of the pair it replaced through `bearer/logout`, with that
978
+ pair held in memory. `previousSession` is `no_previous_session`,
979
+ `same_session` (nothing to end), `previous_session_ended`, or
980
+ `previous_session_not_revoked` with `previousSessionError`. In the last case
981
+ that session lives on at the relying party until it expires. A failed claim
982
+ leaves the stored pair and its session as they were.
983
+ - `createPkce()` makes a verifier and its S256 challenge from 32 random bytes,
984
+ and `claimCode(pkce, code)` claims a code bound to that challenge which
985
+ reached the client another way than its own login, such as a device pairing
986
+ ("Pairing a device" below). It is received like a login: one
987
+ claim, never retried, under `requestTimeoutMs`, and the pair replaces the
988
+ stored one under the lock (`IRelyingPartyBearerLoginResult`). A pair is
989
+ claimed with once: a second call with it, after a failed claim as well, is
990
+ `already_received` and sends nothing. A pair that is not one (a malformed
991
+ verifier, a challenge that is not its S256) or a code that is not text
992
+ throws before anything is sent. Neither needs a window.
993
+ - `openPopupLogin(login, { timeoutMs?, signal?, windowFeatures? })` calls
994
+ `window.open` before it returns, so a browser lets it through within the
995
+ click. It takes only a message from the relying party's exact origin, from
996
+ this popup, of `RELYING_PARTY_BEARER_MESSAGE_TYPE` and with the login's
997
+ state; it ignores every other. It ends with `popup_blocked`, `popup_closed`,
998
+ `popup_timeout` (10 minutes by default) or the signal's reason.
999
+ - `accessToken()` answers the stored access token while it is fresh, and
1000
+ otherwise rotates it first. `refreshSkewMs` (one minute by default, at most
1001
+ half the lifetime) is the margin. `refresh(rejectedAccessToken?)` rotates now.
1002
+ Both answer `null`, and clear the storage, once the session is over
1003
+ (`invalid_bearer_grant`).
1004
+ - Every refresh, logout and store of a claimed pair holds one Web Lock
1005
+ (`lockName`; by default per origin), so the tabs of an origin rotate one at a
1006
+ time. Inside the lock the pair is read from the storage again. A pair another
1007
+ tab rotated is used as it is, so concurrent refreshes make one request. A
1008
+ rotated pair is stored before the lock is released and before its access
1009
+ token is answered; when `storage.set` fails, the new pair is never used and
1010
+ the session is lost.
1011
+ - `requestTimeoutMs` (30 seconds by default, more than 0 and at most two
1012
+ minutes; any other value throws) is the deadline of each operation: the
1013
+ claim of a login's code, a refresh with the write of its pair, the store of a
1014
+ claimed pair with the revoke of the one it replaced, and a logout. When it
1015
+ fires, the request is aborted, the signal a storage write got aborts, an
1016
+ answer that still arrives is dropped unstored, the lock is released for the other
1017
+ tabs and the call fails with `request_failed`; a login whose pair is stored
1018
+ by then stands, with `previous_session_not_revoked`. A claim or refresh the
1019
+ relying party carried out before the deadline is lost with its answer. A
1020
+ timed-out claim fails the login, and the user signs in again; after a lost
1021
+ refresh, the next refresh ends the session (`refresh_token_reused`) and the
1022
+ user signs in again. Strict rotation costs that.
1023
+ - `storage.set(tokens, { signal })` and `storage.clear({ signal })` either
1024
+ honour the signal, so a write it aborted is not made and the call rejects,
1025
+ or complete synchronously, as an adapter over `localStorage` does. The lock
1026
+ is never held past the deadline, so an adapter that ignores the signal can
1027
+ finish a write after another tab took the lock. A late `set` overwrites what
1028
+ that tab stored or cleared, and the session can then end
1029
+ (`refresh_token_reused`); a late `clear` removes the pair that tab stored.
1030
+ Either way the user signs in again.
1031
+ - `logout()` clears the storage and then ends the session at the relying party
1032
+ with the pair it held. When that request fails, is refused or reaches the
1033
+ deadline, it throws `session_not_revoked` with the request's error as its
1034
+ `cause`: the device is signed out, the session may live on at the relying
1035
+ party until it expires, and the application can tell the user they are
1036
+ signed out on this device only. A deadline that fires before the storage is
1037
+ cleared throws `request_failed` and leaves the pair.
1038
+ - Failures are `RelyingPartyBearerClientError`s with a `code`. A refusal of the
1039
+ relying party is `request_refused` with its `status` and `answerError`; an
1040
+ unreachable one is `request_failed`, which leaves the stored pair as it was,
1041
+ except in `logout()` (`session_not_revoked`).
1042
+ - The client needs the Web Locks API and Web Crypto, which a browser offers
1043
+ only in a secure context, and has no fallback for either. Every request is
1044
+ sent without credentials, cache or redirects.
1045
+ - It runs in a Manifest V3 extension's service worker. That was checked in
1046
+ Chromium 149: a module service worker imported `@idp.global/sdk/browser/bearer`
1047
+ and ran `createPkce`, `claimCode` against the bearer routes and a rotation
1048
+ through `accessToken()`, with the lock held at each write. Other browsers'
1049
+ extension workers were not checked. The extension needs a host permission
1050
+ for the relying party's origin: the routes answer CORS for popup origins
1051
+ only, so without one the browser's preflight is refused and the claim fails
1052
+ with `request_failed` before it is sent.
1053
+
1054
+ **Pairing a device.** A person who is signed in can sign a second device of
1055
+ theirs in without a login on it, such as a TV or a browser extension. The
1056
+ device makes a PKCE pair and shows the application its challenge, for example
1057
+ together with a short code that the person confirms in a session where they
1058
+ are signed in. The application's server then opens the device's session from
1059
+ the session that confirmed:
1060
+
1061
+ ```ts
1062
+ // the device polls; the person has confirmed the pairing in one of their sessions
1063
+ const { code, expiresAt } = await sessions.openBearerHandoff({
1064
+ openerSessionId: pairing.confirmedInSessionId, // written by the confirm handler, never taken from the device
1065
+ codeChallenge: pairing.codeChallenge, // the device's S256 challenge
1066
+ maxAgeSeconds: 8 * 60 * 60, // optional: 1 to sessionMaxAgeSeconds, which is the default
1067
+ });
1068
+ // answer the poll with `code`; the device claims it at POST /auth/bearer/claim with its verifier
1069
+ ```
1070
+
1071
+ ```ts
1072
+ // the device, with the browser client (a native client claims at bearer/claim itself)
1073
+ const pkce = await client.createPkce();
1074
+ await showPairingRequest(pkce.codeChallenge);
1075
+ const result = await client.claimCode(pkce, await pollForCode());
1076
+ ```
1077
+
1078
+ - **The opener.** `openerSessionId` must name a live provider login: not ended
1079
+ and not expired, with an active principal and a provider login of its own
1080
+ (`idpSubject`). A session from `openSession` or from `openBearerHandoff`
1081
+ does not qualify, so a paired device cannot pair another one. Any other
1082
+ opener throws `RelyingPartyError('invalid_handoff_opener')` (400). A
1083
+ challenge that is not one throws `invalid_bearer_request`, and a
1084
+ `maxAgeSeconds` outside 1 to `sessionMaxAgeSeconds` throws a `RangeError`.
1085
+ Nothing is stored for any of these. Without `bearer` the call throws
1086
+ `login_method_unavailable`; in bearer-only mode it works, with a bearer login
1087
+ as the opener. The check reads this relying party's state of the opener;
1088
+ observe authority with the opener first to ask idp.global itself.
1089
+ - **The pairing record.** It keeps the id of the session that confirmed,
1090
+ written by the confirm handler and never taken from the device. That opener
1091
+ must still be live when the poll delivers the code. Open one hand-off per
1092
+ confirmed pairing, in the poll that delivers it; then the code is never
1093
+ stored, and only the public challenge sits in the record. Only server code
1094
+ opens a hand-off, and no route does. Guard the confirmation against a code
1095
+ the person did not start themselves (RFC 8628 §5.4).
1096
+ - **The code** is a bearer login's code: one claim within
1097
+ `BEARER_HANDOFF_MAX_AGE_SECONDS` (60) and never past the session, bound to
1098
+ the challenge, stored only as a hash. A claimed code that comes back, a wrong
1099
+ verifier or a code nobody claims ends the session.
1100
+ - **The session** acts as the opener's principal and has no `idpSubject` or
1101
+ `loginAttemptId`. It lives `maxAgeSeconds` from the opening, the same rule as
1102
+ every session, and no refresh extends it. It refreshes without asking the
1103
+ provider. Its expiry, `endSession`, `endSessionsOfPrincipal`, the device's
1104
+ `bearer/logout` and the reuse of its code or refresh token end it; an
1105
+ inactive principal resolves it to nothing.
1106
+ - **What does not reach it.** It holds no provider token, so an idp.global
1107
+ logout or a change of the person's memberships never reaches it, and
1108
+ `AuthorityObserver.observe` refuses it (`authority_login_required`) without
1109
+ ending it. So the application:
1110
+ - grants a paired session (`session.idpSubject` absent) no idp.global
1111
+ organization authority and does not observe it. An application that ends
1112
+ sessions on `authority_login_required` must not observe paired sessions at
1113
+ all;
1114
+ - sees idp.global end a login only as `onSessionEnded` with the reason
1115
+ `refresh_token_refused` for one of the person's provider sessions. It may
1116
+ answer that by ending the person's paired sessions: `endSession` for those
1117
+ `sessionsOfPrincipal` lists without `idpSubject`, or
1118
+ `endSessionsOfPrincipal` to end every session of the person. The hook
1119
+ names the principal of a session it revoked (`principalId`), so the
1120
+ application needs no table of sessions and principals of its own;
1121
+ - ends paired sessions itself when the person signs out everywhere, and
1122
+ erases them with every other session when the account goes
1123
+ (`eraseSessionsOfPrincipal`, see "Deleting an account");
1124
+ - chooses `sessionMaxAgeSeconds`, or `maxAgeSeconds` per pairing, knowing
1125
+ that a paired device lives that long without a check at idp.global.
1126
+
1127
+ **Bearer-only mode.** An application whose every client is a bearer client turns
1128
+ the cookie login off with `cookieSessions: false`. No route then reads or sets a
1129
+ cookie, and that includes the intent cookie a cookie-mode callback clears. It
1130
+ takes no `csrfKey`, and it refuses `csrfKey`, `csrfLabel` and `cookieNames`,
1131
+ because nothing would use them; `bearer` and `provider` are required.
819
1132
 
820
1133
  ```ts
821
- const popup = window.open(loginUrl, 'idp-login', 'popup,width=480,height=640');
822
- window.addEventListener('message', async (event) => {
823
- if (event.origin !== 'https://app.example' || event.source !== popup) return;
824
- const message = event.data;
825
- if (message?.type !== 'idp.global/relyingparty:bearer-handoff' || message.state !== state) return;
826
- if (message.error) return showSignInError(message.error);
827
- const tokens = await fetch('https://app.example/auth/bearer/claim', {
828
- method: 'POST',
829
- headers: { 'content-type': 'application/json' },
830
- body: JSON.stringify({ code: message.code, codeVerifier: verifier }),
831
- }).then((response) => response.json());
1134
+ const sessions = new RelyingPartySessions({
1135
+ cookieSessions: false,
1136
+ models,
1137
+ provider,
1138
+ publicOrigin: 'https://app.example',
1139
+ sessionEncryptionKey: decodeSecretKey(process.env.SESSION_ENCRYPTION_KEY!, 'SESSION_ENCRYPTION_KEY'),
1140
+ principals,
1141
+ bearer: { returnTargets: { web: ['/signed-in'], popup: ['https://workspace.example'] } },
832
1142
  });
833
1143
  ```
834
1144
 
1145
+ `/auth/login`, `/auth/finalize`, `/auth/session` and `/auth/logout` answer
1146
+ `null`, like every path the routes do not serve. `startLogin`,
1147
+ `completeLogin`, `abandonLogin`, `finalizeLogin`, `openSession`,
1148
+ `clearSessionCookie` and `clearIntentCookie` throw `login_method_unavailable`,
1149
+ and `resolveCookieSession` answers `null`. `openBearerHandoff` works, with a
1150
+ bearer login as the opener. A callback that belongs to no bearer
1151
+ login redirects to `failureLocation` when the provider sent an `error`, and
1152
+ otherwise answers 400 `authentication_failed`. `RelyingPartySocketBinding`
1153
+ registers a browser by its cookie session, so it binds no connection in this
1154
+ mode; an application with sockets authenticates them itself, for example with
1155
+ `resolveBearerSession`.
1156
+
1157
+ **Listing a principal's sessions.** `sessionsOfPrincipal(principalId, { limit?,
1158
+ cursor? })` answers one page of the principal's live sessions, newest first,
1159
+ cookie and bearer alike: `{ sessions, nextCursor? }`. `limit` is 1 to 1,000
1160
+ (100 by default). `nextCursor` is there while more may follow. A session live
1161
+ throughout the walk appears on exactly one page; one opened during the walk is
1162
+ newer than every cursor and appears in the next walk. The cursor is opaque; a malformed one
1163
+ throws `invalid_sessions_cursor` (400). An inactive principal has no live
1164
+ session.
1165
+
1166
+ ```ts
1167
+ let page = await sessions.sessionsOfPrincipal(principalId, { limit: 50 });
1168
+ while (page.nextCursor) {
1169
+ page = await sessions.sessionsOfPrincipal(principalId, { limit: 50, cursor: page.nextCursor });
1170
+ }
1171
+ ```
1172
+
835
1173
  ### Testing an Application
836
1174
 
837
1175
  `@idp.global/sdk/relyingparty/testing` exports `RelyingPartyProviderDouble`, an
@@ -893,13 +1231,17 @@ const delivery = await readBearerDelivery(await fetch(callback, { redirect: 'man
893
1231
  | --- | --- |
894
1232
  | `IdpOidcProvider` | Discovery, PKCE authorization URL, code exchange with UserInfo, refresh, revocation and organization introspection for idp.global. Implements `IOidcProvider`. `trustedCaPem` or `fetch` for an issuer under a private CA. |
895
1233
  | `createRelyingPartyModels()` | Binds the session, login-attempt, bearer hand-off and bearer token models to one `SmartdataDb`, or to a function asked for it on every use, optionally under existing collection names. |
896
- | `RelyingPartySessions<TPrincipal>` | Login start (with an application intent and a prompt)/complete/abandon/finalize, `openSession` (with a shorter lifetime), `resolveCookieSession`, `revalidateSession`, `revalidateLiveSession`, `isSessionLive`, `validateCsrf`, `logout`/`endSession`, `endSessionsOfPrincipal`, `liveProviderToken`. Principals give `isActive` or `resolve` (`IRelyingPartyPrincipals`, `IRelyingPartyPrincipalResolver<TPrincipal>`); with `resolve`, live sessions carry `principal`. With `bearer`: `startBearerLogin`, `bearerDeliveryOf`, `completeBearerLogin`, `abandonBearerLogin`, `claimBearerHandoff`, `refreshBearerTokens`, `resolveBearerAccessToken`, `resolveBearerSession`, `endBearerSession`. |
897
- | `AuthorityObserver` | Live organization authority with opt-in positive and negative reuse. |
898
- | `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. In bearer mode also `/auth/bearer/login`, `/auth/bearer/claim`, `/auth/bearer/refresh`, `/auth/bearer/session` and `/auth/bearer/logout`, with CORS for the popup origins only. |
1234
+ | `RelyingPartySessions<TPrincipal>` | Login start (with an application intent and a prompt)/complete/abandon/finalize, `openSession` (with a shorter lifetime), `resolveCookieSession`, `revalidateSession`, `revalidateLiveSession`, `isSessionLive`, `validateCsrf`, `logout`/`endSession`, `endSessionsOfPrincipal`, `eraseSessionsOfPrincipal` (deleting an account), `sessionsOfPrincipal` (paged), `liveProviderToken` (with `rejectedAccessToken`). Principals give `isActive` or `resolve` (`IRelyingPartyPrincipals`, `IRelyingPartyPrincipalResolver<TPrincipal>`); with `resolve`, live sessions carry `principal`. With `bearer`: `startBearerLogin`, `bearerDeliveryOf`, `completeBearerLogin`, `abandonBearerLogin`, `openBearerHandoff` (pairing a device from a live provider login), `claimBearerHandoff`, `refreshBearerTokens`, `resolveBearerAccessToken`, `resolveBearerSession`, `endBearerSession`; `cookieSessions: false` (`IRelyingPartyBearerOnlySessionsOptions`) runs bearer hand-off alone, without cookies or a CSRF key. |
1235
+ | `AuthorityObserver`, `AUTHORITY_FORCED_REFRESH_INTERVAL_MS` | Live organization authority with opt-in positive and negative reuse. An inactive answer rotates the session's token, at most once per interval, before it refuses. |
1236
+ | `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. In bearer mode also `/auth/bearer/login`, `/auth/bearer/claim`, `/auth/bearer/refresh`, `/auth/bearer/session` and `/auth/bearer/logout`, with CORS for the popup origins only; in bearer-only mode `/auth/login`, `/auth/finalize`, `/auth/session` and `/auth/logout` answer `null`. |
899
1237
  | `createActorScope()` | AsyncLocalStorage actor context with a TypedRouter request scope. |
900
1238
  | `RelyingPartySocketBinding` | TypedSocket browser registration, per-request re-validation and session tags; takes a connection wrapper or its peer. |
901
1239
  | `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()`, `sanitizeReturnPath()` | The primitives underneath. |
902
1240
  | `pkceChallengeOf()`, `verifyPkceChallenge()`, `parseBearerAuthorization()`, `isBearerReturnTargetAllowed()`, `RELYING_PARTY_BEARER_MESSAGE_TYPE`, `BEARER_HANDOFF_MAX_AGE_SECONDS` | The bearer primitives: S256, the `Authorization` header, the return-target match and the popup message type. |
1241
+ | `IRelyingPartyBearerTokenAnswer`, `IRelyingPartyBearerSessionAnswer`, `IRelyingPartyBearerLogoutAnswer`, `IRelyingPartyBearerErrorAnswer`, `TRelyingPartyBearerErrorCode`, `IRelyingPartyBearerMessage` | The HTTP answers of the bearer routes and the popup message, shared with `@idp.global/sdk/browser`. |
1242
+ | `IRelyingPartyBearerLoginQuery`, `IRelyingPartyBearerClaimRequest`, `IRelyingPartyBearerRefreshRequest`, `IRelyingPartyBearerLogoutRequest` | The requests of the bearer routes: the `bearer/login` query and the JSON bodies, shared with `@idp.global/sdk/browser`. |
1243
+ | `IRelyingPartyBearerHandoffOpening` | What `openBearerHandoff` answers: the paired session, its code and when the code expires. |
1244
+ | `IRelyingPartySessionsErasure` | What `eraseSessionsOfPrincipal` answers: how many sessions it ended, and how many sessions, login attempts, bearer hand-offs and token generations it deleted. |
903
1245
  | `RelyingPartyError`, `RelyingPartyRefusal`, `LoginCallbackError`, `AuthorityTokenError`, `AuthorityRefusedError`, `RelyingPartyDatabaseUnavailableError` | Errors with stable codes; none carries token material. A `RelyingPartyRefusal` may name the application's `location` for the browser. |
904
1246
  | `RelyingPartyProviderDouble` (`/relyingparty/testing`) | An in-process idp.global for an application's tests: personas, live organization roles, single-use codes, rotating and reuse-detecting refresh tokens, recorded calls and injectable failures. `createBearerPkcePair()` and `readBearerDelivery()` for bearer clients. |
905
1247
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@idp.global/sdk',
6
- version: '19.3.0',
6
+ version: '19.5.0',
7
7
  description: 'Reusable browser and server SDK for idp.global authentication.'
8
8
  }