@idp.global/sdk 19.2.1 → 19.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/changelog.md +58 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_browser/classes.idpclient.js +1 -1
  4. package/dist_ts_browser/classes.idprequests.js +1 -1
  5. package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +276 -0
  6. package/dist_ts_browser/classes.relyingpartybearerclient.js +591 -0
  7. package/dist_ts_browser/index.d.ts +1 -0
  8. package/dist_ts_browser/index.js +2 -1
  9. package/dist_ts_browser/plugins.js +1 -1
  10. package/dist_ts_browser/secret-delivery.js +1 -1
  11. package/dist_ts_relyingparty/classes.authorityobserver.d.ts +23 -0
  12. package/dist_ts_relyingparty/classes.authorityobserver.js +68 -13
  13. package/dist_ts_relyingparty/classes.errors.d.ts +11 -1
  14. package/dist_ts_relyingparty/classes.errors.js +6 -1
  15. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +74 -2
  16. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +289 -14
  17. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +209 -7
  18. package/dist_ts_relyingparty/classes.relyingpartysessions.js +734 -87
  19. package/dist_ts_relyingparty/classes.rpmodels.d.ts +71 -8
  20. package/dist_ts_relyingparty/classes.rpmodels.js +224 -10
  21. package/dist_ts_relyingparty/contract.bearer.d.ts +82 -0
  22. package/dist_ts_relyingparty/contract.bearer.js +9 -0
  23. package/dist_ts_relyingparty/helpers.bearer.d.ts +48 -0
  24. package/dist_ts_relyingparty/helpers.bearer.js +199 -0
  25. package/dist_ts_relyingparty/index.d.ts +1 -0
  26. package/dist_ts_relyingparty/index.js +2 -1
  27. package/dist_ts_relyingparty/interfaces.d.ts +122 -2
  28. package/dist_ts_relyingparty/testing.d.ts +22 -0
  29. package/dist_ts_relyingparty/testing.js +42 -1
  30. package/package.json +9 -11
  31. package/readme.md +353 -8
  32. package/ts/00_commitinfo_data.ts +1 -1
  33. package/ts_browser/classes.relyingpartybearerclient.ts +821 -0
  34. package/ts_browser/index.ts +1 -0
  35. package/ts_relyingparty/classes.authorityobserver.ts +80 -12
  36. package/ts_relyingparty/classes.errors.ts +16 -1
  37. package/ts_relyingparty/classes.relyingpartyroutes.ts +340 -16
  38. package/ts_relyingparty/classes.relyingpartysessions.ts +907 -94
  39. package/ts_relyingparty/classes.rpmodels.ts +131 -9
  40. package/ts_relyingparty/contract.bearer.ts +99 -0
  41. package/ts_relyingparty/helpers.bearer.ts +259 -0
  42. package/ts_relyingparty/index.ts +1 -0
  43. package/ts_relyingparty/interfaces.ts +144 -2
  44. package/ts_relyingparty/testing.ts +60 -0
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`). 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
@@ -583,8 +624,9 @@ an https `publicOrigin`; on http the constructor refuses them, because browsers
583
624
  would silently drop those cookies.
584
625
 
585
626
  **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
627
+ are those nevermind.finance has stored since mainapp 0b693cb, and the session
628
+ model adds an `expiresAt` index, built on first use. An application that
629
+ already holds such collections binds the models to them and passes its own
588
630
  cookie names and CSRF label, so live sessions and sealed tokens keep working:
589
631
 
590
632
  ```ts
@@ -604,6 +646,12 @@ A deployment without a provider (`provider` omitted) keeps the same session
604
646
  store for sessions it opens itself with `openSession({ principalId })`, for
605
647
  example after a local password check.
606
648
 
649
+ Nothing runs on a timer. A login start first removes up to 100 login attempts
650
+ and up to 100 sessions that ended more than an hour ago, and a bearer login
651
+ start also its bearer hand-offs and token generations; `openSession` removes
652
+ the sessions only. Removing an ended session fires no end hook and calls no
653
+ provider.
654
+
607
655
  **Building before the database connects.** `db` may be a function that answers
608
656
  the database once it exists. It is called on every use of a model, the way
609
657
  `@Collection(() => db)` resolves it, and never while the models,
@@ -700,6 +748,285 @@ for (const connection of await binding.connectionsOfSession(sessionId)) {
700
748
  }
701
749
  ```
702
750
 
751
+ ### Bearer Hand-off
752
+
753
+ Cookies serve the application's own pages. Clients of its own that hold no
754
+ cookie of this origin — a desktop app, a native app, a page framed on another
755
+ origin — sign in through the same idp.global login and receive the relying
756
+ party's own access and refresh tokens instead. The `bearer` option turns this
757
+ on beside the cookie login; no bearer route reads or sets a cookie.
758
+
759
+ ```ts
760
+ const models = createRelyingPartyModels({ db: smartdataDb });
761
+ await models.Session.ensureInitialized();
762
+ await models.LoginAttempt.ensureInitialized();
763
+ await models.BearerHandoff.ensureInitialized();
764
+ await models.BearerToken.ensureInitialized();
765
+
766
+ const sessions = new RelyingPartySessions({
767
+ models,
768
+ provider,
769
+ // … publicOrigin, keys, principals and hooks as above
770
+ bearer: {
771
+ returnTargets: {
772
+ web: ['/signed-in'],
773
+ loopback: ['http://127.0.0.1/callback', 'http://[::1]/callback'],
774
+ custom_scheme: ['com.example.app:/oauth/callback'],
775
+ popup: ['https://workspace.example'],
776
+ },
777
+ accessTokenMaxAgeSeconds: 900, // the default
778
+ },
779
+ });
780
+ ```
781
+
782
+ The flow, with the routes under the default `/auth/` prefix:
783
+
784
+ 1. The client makes a PKCE verifier and its S256 challenge (RFC 7636) and
785
+ opens `GET /auth/bearer/login?client_kind=<kind>&return_target=<target>&code_challenge=<challenge>&code_challenge_method=S256&state=<state>`.
786
+ `state` is optional: the client's own value of 1 to 256 characters of
787
+ `A-Z a-z 0-9 . _ ~ -`, handed back with the outcome. A malformed request
788
+ answers 400 `invalid_bearer_request`, a target that is not configured for
789
+ the kind 400 `return_target_not_allowed`; neither stores anything.
790
+ 2. The relying party runs the idp.global login (authorization code with PKCE,
791
+ as for cookies). Its `GET /auth/callback` opens the session and delivers a
792
+ hand-off code to the return target. The code is no token: it is valid for
793
+ one claim within `BEARER_HANDOFF_MAX_AGE_SECONDS` (60), bound to the
794
+ challenge, and only its hash is stored. `web`, `loopback` and
795
+ `custom_scheme` get a redirect with `code` and `state` in the query.
796
+ `popup` gets a page whose one nonce script posts
797
+ `{ type: RELYING_PARTY_BEARER_MESSAGE_TYPE, code, state }` to
798
+ `window.opener` with the registered origin as the target origin — never
799
+ `'*'` — and closes the window. A login that fails delivers `error` instead
800
+ of `code`: `access_denied` when the provider came back without a code, the
801
+ code of a `RelyingPartyRefusal` (its `location` is not followed), or
802
+ `authentication_failed`.
803
+ 3. The client claims with `POST /auth/bearer/claim` `{ code, codeVerifier }`.
804
+ The answer, as for `POST /auth/bearer/refresh` `{ refreshToken }`, is
805
+ `{ tokenType: 'Bearer', accessToken, accessTokenExpiresAt, expiresIn, refreshToken, refreshTokenExpiresAt, session: { sessionId, principalId, expiresAt } }`
806
+ (`expiresIn` in seconds, counted on the relying party's clock).
807
+ 4. `Authorization: Bearer <accessToken>` authenticates the client:
808
+ `GET /auth/bearer/session` answers `{ session }` (`null` without a live
809
+ one), and the application's own routes call
810
+ `sessions.resolveBearerSession(authorizationHeader)` for the live session.
811
+ `POST /auth/bearer/logout` ends the session of that access token, or of the
812
+ body's `{ refreshToken }` once the access token has expired, and answers
813
+ `{ endedLiveSession }`.
814
+
815
+ Return targets are an exact allow-list per kind, compared by equality:
816
+
817
+ - `web`: paths on the public origin outside the login routes.
818
+ - `loopback`: `http://127.0.0.1/<path>` or `http://[::1]/<path>`, configured
819
+ without a port. The client names its ephemeral port, and any port matches
820
+ (RFC 8252 §7.3); `localhost` is not accepted.
821
+ - `custom_scheme`: whole URIs of a private-use scheme with a dot in it (RFC
822
+ 8252 §7.1).
823
+ - `popup`: bare origins.
824
+
825
+ The constructor refuses an entry that is not in its canonical form, carries a
826
+ query or a fragment, or contains a `*`: nothing is a prefix and nothing is a
827
+ wildcard.
828
+
829
+ Tokens and their ends:
830
+
831
+ - An access token lives `accessTokenMaxAgeSeconds` and never past its
832
+ session; the refresh token lives until the session ends, and no refresh
833
+ extends it.
834
+ - A refresh spends its refresh token and answers a new pair; the old access
835
+ token stops working at once. A spent refresh token that comes back ends the
836
+ whole session (`refresh_token_reused`), so a client serializes its refreshes,
837
+ and a client that lost a refresh answer signs in again. A refused refresh
838
+ answers 400 `invalid_bearer_grant`.
839
+ - The first claim consumes the code, whatever its verifier. A wrong verifier
840
+ ends the session (`handoff_verifier_mismatch`), a claimed code that comes
841
+ back ends it too (`handoff_code_reused`, RFC 6749 §4.1.2), and a code that
842
+ expired unclaimed ends its session when it comes back or when the next
843
+ bearer login sweeps it (`handoff_unclaimed`). Each answers 400
844
+ `invalid_bearer_handoff`; reuse is also reported as a
845
+ `bearer_credential_reused` event.
846
+ - A bearer session is a provider login like a cookie session: `onSessionEnded`,
847
+ `endSessionsOfPrincipal` and `AuthorityObserver` apply as they are. A refresh
848
+ rotates the provider token when it is due and ends the session when
849
+ idp.global has ended the login (`invalid_bearer_grant`); an unreachable
850
+ provider answers 503 `authority_unavailable` and spends nothing. Until then
851
+ an access token works for its remaining lifetime. An application that must
852
+ see an idp.global logout sooner observes authority per request
853
+ (`authority.observe({ loginSessionId: live.session.id, … })`), which ends the
854
+ session as soon as idp.global has ended the login.
855
+
856
+ The four API routes answer CORS, including the preflight, for the popup
857
+ origins exactly and never allow credentials; every other origin gets no CORS
858
+ header.
859
+
860
+ The client side:
861
+
862
+ - A web client reads `code` and `state` from its return path, removes them with
863
+ `history.replaceState` and claims at once. Every delivery is sent with
864
+ `Referrer-Policy: no-referrer`.
865
+ - A popup client opens the login with `window.open` (without `noopener`) and
866
+ accepts only the message of the popup it opened, from the relying party's
867
+ public origin, with its own `state`. The opener page must keep its opener
868
+ link to the popup: `Cross-Origin-Opener-Policy: same-origin` severs it, while
869
+ `same-origin-allow-popups` keeps it.
870
+ - Every client claims a code once and never retries a claim or a refresh. Its
871
+ refreshes run one at a time across everything that shares the pair, and a
872
+ rotated pair is stored, all of it at once, before its access token is first
873
+ used: a pair that is lost after the relying party rotated it cannot be
874
+ recovered, and its spent refresh token coming back ends the session. Native
875
+ clients implement this side themselves.
876
+
877
+ The HTTP answers are exported as types (`IRelyingPartyBearerTokenAnswer`,
878
+ `IRelyingPartyBearerSessionAnswer`, `IRelyingPartyBearerLogoutAnswer`,
879
+ `IRelyingPartyBearerErrorAnswer` with `TRelyingPartyBearerErrorCode`, and the
880
+ popup's `IRelyingPartyBearerMessage`). They come from a module that imports
881
+ nothing, so `@idp.global/sdk/browser` shares them with the server.
882
+
883
+ **The browser client.** `RelyingPartyBearerClient` from `@idp.global/sdk/browser`
884
+ is that client side for `web` and `popup` logins. The application owns where the
885
+ token pair lives: it passes a storage adapter, typically over IndexedDB, and the
886
+ client keeps nothing of its own across reloads.
887
+
888
+ ```ts
889
+ import { RelyingPartyBearerClient, type IRelyingPartyBearerTokenStorage } from '@idp.global/sdk/browser';
890
+
891
+ const storage: IRelyingPartyBearerTokenStorage = {
892
+ get: () => tokenStore.get('bearer'), // the stored pair, or null
893
+ set: (tokens, { signal }) => tokenStore.put('bearer', tokens, { signal }), // resolves once durable, writes nothing once aborted
894
+ clear: ({ signal }) => tokenStore.delete('bearer', { signal }), // resolves once durable, removes nothing once aborted
895
+ };
896
+ const client = new RelyingPartyBearerClient({ origin: 'https://app.example', storage });
897
+
898
+ // web: start, and receive on the return target page
899
+ const login = await client.createLogin({ clientKind: 'web', returnTarget: '/signed-in' });
900
+ sessionStorage.setItem('bearer-login', JSON.stringify(login));
901
+ location.assign(login.url);
902
+ // … on /signed-in
903
+ const result = await client.completeRedirectLogin(JSON.parse(sessionStorage.getItem('bearer-login') ?? 'null'));
904
+ // result.tokens is the stored pair; result.previousSession says what became of the one it replaced
905
+
906
+ // popup: make the login first, open it in the click handler itself
907
+ const popupLogin = await client.createLogin({ clientKind: 'popup', returnTarget: 'https://workspace.example' });
908
+ button.onclick = () => client.openPopupLogin(popupLogin).then(showWorkspace, showSignInError);
909
+
910
+ // every call
911
+ const accessToken = await client.accessToken(); // null without a session
912
+ // after the relying party refused one
913
+ await client.refresh(rejectedAccessToken);
914
+ await client.logout();
915
+ ```
916
+
917
+ - `createLogin` makes the verifier, its S256 challenge and the state from 32
918
+ random bytes each and answers them with the start URL. The pending login is
919
+ the application's to keep until it comes back; it holds the verifier.
920
+ - `completeRedirectLogin(login)` answers `null` on a page without a delivery.
921
+ It removes `code`, `error` and `state` from the address first, then refuses a
922
+ state that is not the login's (`state_mismatch`, nothing claimed) and claims
923
+ the code once (`already_received` after that).
924
+ - A received login (`IRelyingPartyBearerLoginResult`) replaces the stored pair.
925
+ After the claim, under the lock, the client stores the new pair and then
926
+ ends the session of the pair it replaced through `bearer/logout`, with that
927
+ pair held in memory. `previousSession` is `no_previous_session`,
928
+ `same_session` (nothing to end), `previous_session_ended`, or
929
+ `previous_session_not_revoked` with `previousSessionError`. In the last case
930
+ that session lives on at the relying party until it expires. A failed claim
931
+ leaves the stored pair and its session as they were.
932
+ - `openPopupLogin(login, { timeoutMs?, signal?, windowFeatures? })` calls
933
+ `window.open` before it returns, so a browser lets it through within the
934
+ click. It takes only a message from the relying party's exact origin, from
935
+ this popup, of `RELYING_PARTY_BEARER_MESSAGE_TYPE` and with the login's
936
+ state; it ignores every other. It ends with `popup_blocked`, `popup_closed`,
937
+ `popup_timeout` (10 minutes by default) or the signal's reason.
938
+ - `accessToken()` answers the stored access token while it is fresh, and
939
+ otherwise rotates it first. `refreshSkewMs` (one minute by default, at most
940
+ half the lifetime) is the margin. `refresh(rejectedAccessToken?)` rotates now.
941
+ Both answer `null`, and clear the storage, once the session is over
942
+ (`invalid_bearer_grant`).
943
+ - Every refresh, logout and store of a claimed pair holds one Web Lock
944
+ (`lockName`; by default per origin), so the tabs of an origin rotate one at a
945
+ time. Inside the lock the pair is read from the storage again. A pair another
946
+ tab rotated is used as it is, so concurrent refreshes make one request. A
947
+ rotated pair is stored before the lock is released and before its access
948
+ token is answered; when `storage.set` fails, the new pair is never used and
949
+ the session is lost.
950
+ - `requestTimeoutMs` (30 seconds by default, more than 0 and at most two
951
+ minutes; any other value throws) is the deadline of each operation: the
952
+ claim of a login's code, a refresh with the write of its pair, the store of a
953
+ claimed pair with the revoke of the one it replaced, and a logout. When it
954
+ fires, the request is aborted, the signal a storage write got aborts, an
955
+ answer that still arrives is dropped unstored, the lock is released for the other
956
+ tabs and the call fails with `request_failed`; a login whose pair is stored
957
+ by then stands, with `previous_session_not_revoked`. A claim or refresh the
958
+ relying party carried out before the deadline is lost with its answer. A
959
+ timed-out claim fails the login, and the user signs in again; after a lost
960
+ refresh, the next refresh ends the session (`refresh_token_reused`) and the
961
+ user signs in again. Strict rotation costs that.
962
+ - `storage.set(tokens, { signal })` and `storage.clear({ signal })` either
963
+ honour the signal, so a write it aborted is not made and the call rejects,
964
+ or complete synchronously, as an adapter over `localStorage` does. The lock
965
+ is never held past the deadline, so an adapter that ignores the signal can
966
+ finish a write after another tab took the lock. A late `set` overwrites what
967
+ that tab stored or cleared, and the session can then end
968
+ (`refresh_token_reused`); a late `clear` removes the pair that tab stored.
969
+ Either way the user signs in again.
970
+ - `logout()` clears the storage and then ends the session at the relying party
971
+ with the pair it held. When that request fails, is refused or reaches the
972
+ deadline, it throws `session_not_revoked` with the request's error as its
973
+ `cause`: the device is signed out, the session may live on at the relying
974
+ party until it expires, and the application can tell the user they are
975
+ signed out on this device only. A deadline that fires before the storage is
976
+ cleared throws `request_failed` and leaves the pair.
977
+ - Failures are `RelyingPartyBearerClientError`s with a `code`. A refusal of the
978
+ relying party is `request_refused` with its `status` and `answerError`; an
979
+ unreachable one is `request_failed`, which leaves the stored pair as it was,
980
+ except in `logout()` (`session_not_revoked`).
981
+ - The client needs the Web Locks API and Web Crypto, which a browser offers
982
+ only in a secure context, and has no fallback for either. Every request is
983
+ sent without credentials, cache or redirects.
984
+
985
+ **Bearer-only mode.** An application whose every client is a bearer client turns
986
+ the cookie login off with `cookieSessions: false`. No route then reads or sets a
987
+ cookie, and that includes the intent cookie a cookie-mode callback clears. It
988
+ takes no `csrfKey`, and it refuses `csrfKey`, `csrfLabel` and `cookieNames`,
989
+ because nothing would use them; `bearer` and `provider` are required.
990
+
991
+ ```ts
992
+ const sessions = new RelyingPartySessions({
993
+ cookieSessions: false,
994
+ models,
995
+ provider,
996
+ publicOrigin: 'https://app.example',
997
+ sessionEncryptionKey: decodeSecretKey(process.env.SESSION_ENCRYPTION_KEY!, 'SESSION_ENCRYPTION_KEY'),
998
+ principals,
999
+ bearer: { returnTargets: { web: ['/signed-in'], popup: ['https://workspace.example'] } },
1000
+ });
1001
+ ```
1002
+
1003
+ `/auth/login`, `/auth/finalize`, `/auth/session` and `/auth/logout` answer
1004
+ `null`, like every path the routes do not serve. `startLogin`,
1005
+ `completeLogin`, `abandonLogin`, `finalizeLogin`, `openSession`,
1006
+ `clearSessionCookie` and `clearIntentCookie` throw `login_method_unavailable`,
1007
+ and `resolveCookieSession` answers `null`. A callback that belongs to no bearer
1008
+ login redirects to `failureLocation` when the provider sent an `error`, and
1009
+ otherwise answers 400 `authentication_failed`. `RelyingPartySocketBinding`
1010
+ registers a browser by its cookie session, so it binds no connection in this
1011
+ mode; an application with sockets authenticates them itself, for example with
1012
+ `resolveBearerSession`.
1013
+
1014
+ **Listing a principal's sessions.** `sessionsOfPrincipal(principalId, { limit?,
1015
+ cursor? })` answers one page of the principal's live sessions, newest first,
1016
+ cookie and bearer alike: `{ sessions, nextCursor? }`. `limit` is 1 to 1,000
1017
+ (100 by default). `nextCursor` is there while more may follow. A session live
1018
+ throughout the walk appears on exactly one page; one opened during the walk is
1019
+ newer than every cursor and appears in the next walk. The cursor is opaque; a malformed one
1020
+ throws `invalid_sessions_cursor` (400). An inactive principal has no live
1021
+ session.
1022
+
1023
+ ```ts
1024
+ let page = await sessions.sessionsOfPrincipal(principalId, { limit: 50 });
1025
+ while (page.nextCursor) {
1026
+ page = await sessions.sessionsOfPrincipal(principalId, { limit: 50, cursor: page.nextCursor });
1027
+ }
1028
+ ```
1029
+
703
1030
  ### Testing an Application
704
1031
 
705
1032
  `@idp.global/sdk/relyingparty/testing` exports `RelyingPartyProviderDouble`, an
@@ -739,20 +1066,38 @@ discovery. Once one has succeeded, discovery stays done, as with
739
1066
  `IdpOidcProvider`; `discoveries` counts the calls. The claims of a login are
740
1067
  those of its sign-in; introspection reads the persona as it is now.
741
1068
 
1069
+ For bearer clients, `createBearerPkcePair()` makes a verifier and its S256
1070
+ challenge, and `readBearerDelivery(response)` reads a callback answer the way
1071
+ its client would: the `code`, `error` and `state` of a redirect's location
1072
+ (`target` is the location without its query), or a popup page's posted
1073
+ `message` and its target origin.
1074
+
1075
+ ```ts
1076
+ import { createBearerPkcePair, readBearerDelivery } from '@idp.global/sdk/relyingparty/testing';
1077
+
1078
+ const pkce = createBearerPkcePair();
1079
+ const start = await fetch(`${appOrigin}/auth/bearer/login?client_kind=web&return_target=/signed-in&code_challenge=${pkce.challenge}&code_challenge_method=S256`, { redirect: 'manual' });
1080
+ const callback = idp.callbackUrlFor(new URL(start.headers.get('location')!), new URL('/auth/callback', appOrigin));
1081
+ const delivery = await readBearerDelivery(await fetch(callback, { redirect: 'manual' }));
1082
+ // delivery.code is claimed with pkce.verifier at /auth/bearer/claim
1083
+ ```
1084
+
742
1085
  ### Relying-Party API Surface
743
1086
 
744
1087
  | API | Description |
745
1088
  | --- | --- |
746
1089
  | `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. |
747
- | `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. |
748
- | `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`. |
749
- | `AuthorityObserver` | Live organization authority with opt-in positive and negative reuse. |
750
- | `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. |
1090
+ | `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. |
1091
+ | `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`, `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`, `claimBearerHandoff`, `refreshBearerTokens`, `resolveBearerAccessToken`, `resolveBearerSession`, `endBearerSession`; `cookieSessions: false` (`IRelyingPartyBearerOnlySessionsOptions`) runs bearer hand-off alone, without cookies or a CSRF key. |
1092
+ | `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. |
1093
+ | `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`. |
751
1094
  | `createActorScope()` | AsyncLocalStorage actor context with a TypedRouter request scope. |
752
1095
  | `RelyingPartySocketBinding` | TypedSocket browser registration, per-request re-validation and session tags; takes a connection wrapper or its peer. |
753
1096
  | `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()`, `sanitizeReturnPath()` | The primitives underneath. |
1097
+ | `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. |
1098
+ | `IRelyingPartyBearerTokenAnswer`, `IRelyingPartyBearerSessionAnswer`, `IRelyingPartyBearerLogoutAnswer`, `IRelyingPartyBearerErrorAnswer`, `TRelyingPartyBearerErrorCode`, `IRelyingPartyBearerMessage` | The HTTP answers of the bearer routes and the popup message, shared with `@idp.global/sdk/browser`. |
754
1099
  | `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. |
755
- | `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. |
1100
+ | `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. |
756
1101
 
757
1102
  ## Runtime Notes
758
1103
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@idp.global/sdk',
6
- version: '19.2.1',
6
+ version: '19.4.0',
7
7
  description: 'Reusable browser and server SDK for idp.global authentication.'
8
8
  }