@idp.global/sdk 19.4.0 → 19.6.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 (33) hide show
  1. package/changelog.md +60 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +40 -3
  4. package/dist_ts_browser/classes.relyingpartybearerclient.js +67 -12
  5. package/dist_ts_relyingparty/classes.errors.d.ts +2 -0
  6. package/dist_ts_relyingparty/classes.errors.js +2 -1
  7. package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +69 -11
  8. package/dist_ts_relyingparty/classes.idpoidcprovider.js +127 -17
  9. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +128 -18
  10. package/dist_ts_relyingparty/classes.relyingpartysessions.js +334 -69
  11. package/dist_ts_relyingparty/classes.rpmodels.d.ts +21 -4
  12. package/dist_ts_relyingparty/classes.rpmodels.js +35 -8
  13. package/dist_ts_relyingparty/contract.bearer.d.ts +37 -0
  14. package/dist_ts_relyingparty/contract.bearer.js +5 -1
  15. package/dist_ts_relyingparty/helpers.bearer.d.ts +2 -3
  16. package/dist_ts_relyingparty/helpers.bearer.js +4 -6
  17. package/dist_ts_relyingparty/helpers.cookies.d.ts +6 -0
  18. package/dist_ts_relyingparty/helpers.cookies.js +8 -1
  19. package/dist_ts_relyingparty/interfaces.d.ts +73 -3
  20. package/dist_ts_relyingparty/testing.js +8 -4
  21. package/package.json +3 -3
  22. package/readme.md +280 -34
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts_browser/classes.relyingpartybearerclient.ts +98 -18
  25. package/ts_relyingparty/classes.errors.ts +3 -0
  26. package/ts_relyingparty/classes.idpoidcprovider.ts +183 -17
  27. package/ts_relyingparty/classes.relyingpartysessions.ts +393 -78
  28. package/ts_relyingparty/classes.rpmodels.ts +27 -4
  29. package/ts_relyingparty/contract.bearer.ts +46 -0
  30. package/ts_relyingparty/helpers.bearer.ts +3 -6
  31. package/ts_relyingparty/helpers.cookies.ts +13 -0
  32. package/ts_relyingparty/interfaces.ts +76 -7
  33. package/ts_relyingparty/testing.ts +7 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idp.global/sdk",
3
- "version": "19.4.0",
3
+ "version": "19.6.0",
4
4
  "private": false,
5
5
  "description": "Reusable browser and server SDK for idp.global authentication.",
6
6
  "type": "module",
@@ -36,10 +36,10 @@
36
36
  },
37
37
  "devDependencies": {
38
38
  "@api.global/typedserver": "^11.4.0",
39
- "@git.zone/tsrepo": "^1.1.1",
39
+ "@git.zone/tsrepo": "^2.1.0",
40
40
  "@lossless.org/client": "^2.0.0",
41
41
  "@lossless.org/nosqldb": "10.5.0",
42
- "@types/node": "26.6.5"
42
+ "@types/node": "26.6.6"
43
43
  },
44
44
  "files": [
45
45
  "ts/**/*",
package/readme.md CHANGED
@@ -152,7 +152,7 @@ await idp.logout();
152
152
  | `getTransferTokenAndSwitchToLocation()` | Request a transfer token and navigate to the same configured target with `transfertoken` attached. |
153
153
  | `processTransferToken()` | Consume a `transfertoken` from the current URL and refresh local auth state. |
154
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). |
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). |
156
156
 
157
157
  ### Typed Request Shortcuts
158
158
 
@@ -559,8 +559,10 @@ What it does:
559
559
  - **Login**: `GET /auth/login?return=/path` stores one login attempt (state and
560
560
  browser binding stored as SHA-256, PKCE verifier sealed) and redirects to the
561
561
  provider. `GET /auth/callback` consumes the attempt exactly once, exchanges
562
- the code, reads UserInfo with a subject check, calls `principals.admit` and
563
- opens a session; a provider `error` abandons the attempt. `GET /auth/finalize`
562
+ the code, verifies the ID token (its RS256 signature against idp.global's
563
+ JWKS, its issuer, audience and nonce), reads UserInfo with a subject check,
564
+ calls `principals.admit` and opens a session; a provider `error` abandons the
565
+ attempt. `GET /auth/finalize`
564
566
  releases the return path only to the browser holding the session that attempt
565
567
  created. Return paths stay on the public origin and outside the login routes.
566
568
  Expired attempts are swept when the next login starts.
@@ -583,7 +585,8 @@ What it does:
583
585
  the routes follow it only when it is a plain path on the public origin
584
586
  outside the login routes, exactly as given, and otherwise answer as if it
585
587
  named none.
586
- - **Sessions**: an HttpOnly, `SameSite=Lax` cookie whose token is stored as a
588
+ - **Sessions**: an HttpOnly, `SameSite=Lax` (by default, see "Framed
589
+ applications" below) cookie whose token is stored as a
587
590
  hash, touched at most once a minute, with a CSRF token derived per request as
588
591
  an HMAC of the cookie token. `GET /auth/session` answers the session and
589
592
  `POST /auth/logout` needs the public origin and the CSRF token; its body is
@@ -596,7 +599,8 @@ What it does:
596
599
  - **Provider tokens**: sealed with AES-256-GCM (`SecretBox`) and rotated shortly
597
600
  before they expire, once per process and under a lock stored on the session,
598
601
  so two replicas never spend one refresh token. A refused refresh ends the
599
- session; an unreachable provider fails closed.
602
+ session; an unreachable provider fails closed, and so does a refresh whose ID
603
+ token does not verify against the JWKS.
600
604
  - **Live authority**: `AuthorityObserver.observe({ loginSessionId, principalId,
601
605
  idpOrganizationId })` introspects the organization with the session's live
602
606
  token and projects the role keys through the application's table. It answers
@@ -614,6 +618,13 @@ What it does:
614
618
  Nothing runs on a timer, and no token, cookie or sealed value ever reaches a
615
619
  log line or an error message.
616
620
 
621
+ `onSessionEnded` names the session, the reason and whether this call revoked
622
+ it (`revokedHere`); when it did, it also names the session's principal
623
+ (`principalId`), and when the session was over already it does not.
624
+ `onProviderSessionOpened` receives the session and, for a bearer login, its
625
+ `delivery`: the client kind, the return target and the client's `state`. A
626
+ cookie login has no `delivery`.
627
+
617
628
  Application hooks never stand in the way of that cleanup. A throwing (or
618
629
  rejecting) `onSessionEnded` or `onEvent` is reported as a `hook_failed` event —
619
630
  `onEvent`'s own failure on `console.error` — and the provider tokens go back
@@ -623,9 +634,46 @@ login's cause. Custom `cookieNames` with a `__Host-` or `__Secure-` prefix need
623
634
  an https `publicOrigin`; on http the constructor refuses them, because browsers
624
635
  would silently drop those cookies.
625
636
 
637
+ **Framed applications.** The session cookie and the login's intent cookie are
638
+ `SameSite=Lax`, so a browser never sends them into a page of the application
639
+ that another site frames. `cookiePolicy` changes that for both cookies, on
640
+ every `Set-Cookie` that sets or clears them:
641
+
642
+ ```ts
643
+ const sessions = new RelyingPartySessions({
644
+ // …
645
+ cookiePolicy: { sameSite: 'None', partitioned: true },
646
+ });
647
+ ```
648
+
649
+ - `sameSite: 'None'` sends the cookies with the framed page's requests too.
650
+ It needs an https `publicOrigin`, since a `SameSite=None` cookie must be
651
+ `Secure`.
652
+ - `partitioned: true` adds `Partitioned` (CHIPS): the browser keeps the
653
+ cookies per top-level site, so the application has one jar under each site
654
+ that frames it, where a browser that blocks third-party cookies would
655
+ otherwise drop them. It is valid only with `sameSite: 'None'`.
656
+ - The constructor refuses `partitioned` without `sameSite: 'None'`,
657
+ `sameSite: 'None'` on http, any other `sameSite` value and a `partitioned`
658
+ that is not a boolean. Bearer-only mode refuses `cookiePolicy` altogether.
659
+ `sessions.cookiePolicy` answers the resolved policy; the default is
660
+ `{ sameSite: 'Lax', partitioned: false }`.
661
+ - With `SameSite=None` the browser also sends the session cookie with other
662
+ sites' requests to the application, so the CSRF token (`validateCsrf`) and
663
+ the origin check are its only CSRF defence: check them on every request that
664
+ changes something.
665
+ - A partitioned cookie belongs to the top-level site of the window that
666
+ received it. A login that leaves the frame, in a popup or a new tab, sets its
667
+ cookies in the application's own top-level jar, where the frame never reads
668
+ them, so the whole login, `/auth/login` through `/auth/finalize`, has to run
669
+ inside the frame. Where the provider's login cannot run framed, the framed
670
+ application signs in with a bearer `popup` login instead (see "Bearer
671
+ Hand-off").
672
+
626
673
  **Adopting existing session data.** The persisted fields and index declarations
627
674
  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
675
+ model adds an `expiresAt` index and the bearer hand-off and token models a
676
+ `sessionId` index, each built on first use. An application that
629
677
  already holds such collections binds the models to them and passes its own
630
678
  cookie names and CSRF label, so live sessions and sealed tokens keep working:
631
679
 
@@ -648,9 +696,81 @@ example after a local password check.
648
696
 
649
697
  Nothing runs on a timer. A login start first removes up to 100 login attempts
650
698
  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.
699
+ start also its bearer hand-offs and token generations; `openBearerHandoff`
700
+ removes the bearer state and the sessions, `openSession` the sessions only.
701
+ Removing an ended session fires no end hook and calls no provider. In bearer
702
+ mode a session's hand-offs and token generations go before its row, so none
703
+ outlives the session, and a removal that fails half way is finished by the
704
+ next one. `endSessionsOfPrincipal` ends the principal's live sessions; one that
705
+ expired is over already and is neither ended nor counted.
706
+
707
+ **Deleting an account.** The sweeps above run only when a session is created,
708
+ so an ended session can stay stored for long on a quiet service.
709
+ `eraseSessionsOfPrincipal(principalId)` removes everything of one principal at
710
+ once: an application calls it when it deletes the account, after its own
711
+ erasers and before its user record, so a failure can be retried.
712
+
713
+ ```ts
714
+ await accountData.eraseAll(userId); // the application's own records
715
+ const erasure = await sessions.eraseSessionsOfPrincipal(userId);
716
+ await users.delete(userId);
717
+ ```
718
+
719
+ - It ends every session of the principal that is not revoked yet, an expired
720
+ one too, as `endSession` does: its tokens stop working at once,
721
+ `onSessionEnded` fires (reason `logout`, with `principalId`), and its
722
+ provider tokens go back to idp.global, which a deleted row could no longer
723
+ do.
724
+ - Then it deletes every session row of the principal, live or ended, and with
725
+ each the login attempt a provider session came out of and, in bearer mode,
726
+ its hand-offs (claimed or not) and its token generations. The session row
727
+ goes last: it is the only link from the principal to the others, so an
728
+ erase that fails half way leaves a retry everything it needs.
729
+ - It answers what it removed (`IRelyingPartySessionsErasure`: `endedSessions`,
730
+ `deletedSessions`, `deletedLoginAttempts`, `deletedBearerHandoffs`,
731
+ `deletedBearerTokens`); a second call finds nothing.
732
+ - A session opened by a login already past `admit` when the erase runs is not
733
+ covered. It keeps the principal id until it expires and a later sweep
734
+ removes it. A second `eraseSessionsOfPrincipal(principalId)`, run after the
735
+ user record is deleted, removes it. A token generation issued by a refresh
736
+ already under way is not covered either; it holds only hashes, the session
737
+ id and times, and the bearer sweep removes it an hour after the session
738
+ would have expired.
739
+
740
+ **Telling provider failures apart.** Every failure of the provider half is an
741
+ `OidcProviderError`, so an application classifies it without importing
742
+ openid-client. `code` (`TOidcProviderErrorCode`) says which case it is, and
743
+ `oauthError` the provider's own OAuth error code when it sent one, such as
744
+ `invalid_grant` (a short token; the provider's description is never kept):
745
+
746
+ | `code` | When |
747
+ | --- | --- |
748
+ | `token_exchange_refused` | The provider refused the login: an OAuth error in the callback, for the code at the token endpoint (`invalid_grant` for a used or expired code), or for its token at UserInfo. |
749
+ | `login_mismatch` | The callback or its ID token is not this login's: another `state`, or an ID token claim that does not match it (the nonce, and likewise the issuer, the audience or the authorized party). |
750
+ | `invalid_response` | The provider answered outside its contract: an ID token whose signature does not verify against the JWKS or that has expired, no subject, another subject at UserInfo, a body that is no OAuth answer. |
751
+ | `provider_unreachable` | The provider could not be asked: no answer in time, a transport failure, a 5xx, or no discovery yet. |
752
+ | `refresh_refused` | The refresh grant answered `invalid_grant`: the refresh family is revoked, expired or was reused. |
753
+
754
+ `OidcCompletionError` (with its `stage`), `OidcRefreshRefusedError` and
755
+ `OidcAuthorityUnavailableError` (with its `reason`) are `OidcProviderError`s. A
756
+ failed login callback throws `LoginCallbackError`, whose `cause` is the
757
+ provider's failure when the provider half failed:
758
+
759
+ ```ts
760
+ import { LoginCallbackError, OidcProviderError } from '@idp.global/sdk/relyingparty';
761
+
762
+ try {
763
+ await sessions.completeLogin(callbackUrl, cookieHeader);
764
+ } catch (error) {
765
+ const failure = error instanceof LoginCallbackError ? error.cause : error;
766
+ if (failure instanceof OidcProviderError) {
767
+ // `failure.code` and `failure.oauthError` are tokens a log line may carry
768
+ log({ event: 'login_failed', code: failure.code, oauthError: failure.oauthError });
769
+ const retryLater = failure.code === 'provider_unreachable' || failure.code === 'invalid_response';
770
+ // …
771
+ }
772
+ }
773
+ ```
654
774
 
655
775
  **Building before the database connects.** `db` may be a function that answers
656
776
  the database once it exists. It is called on every use of a model, the way
@@ -707,10 +827,11 @@ a deadline of its own; bound a slow audit store inside it.
707
827
 
708
828
  **Issuers under a private CA.** `IdpOidcProvider` takes `trustedCaPem` — one
709
829
  PEM string or several, e.g. `@idp.global/devidp`'s `caCertificatePem` or a LAN
710
- deployment's root. Discovery, token, UserInfo, refresh, revocation and
711
- introspection then go through one `node:https` agent of this provider that
712
- trusts those CAs and nothing else; the global `fetch`, its dispatcher and
713
- `NODE_EXTRA_CA_CERTS` are not involved, and the issuer must still be `https:`.
830
+ deployment's root. Discovery, token, UserInfo, refresh, revocation,
831
+ introspection and the JWKS the ID tokens are verified with then go through one
832
+ `node:https` agent of this provider that trusts those CAs and nothing else; the
833
+ global `fetch`, its dispatcher and `NODE_EXTRA_CA_CERTS` are not involved, and
834
+ the issuer must still be `https:`.
714
835
  An unreadable PEM is refused by the constructor.
715
836
 
716
837
  ```ts
@@ -775,6 +896,7 @@ const sessions = new RelyingPartySessions({
775
896
  popup: ['https://workspace.example'],
776
897
  },
777
898
  accessTokenMaxAgeSeconds: 900, // the default
899
+ refreshReuseGraceSeconds: 60, // the default; 0 turns it off, at most 300
778
900
  },
779
901
  });
780
902
  ```
@@ -833,9 +955,26 @@ Tokens and their ends:
833
955
  extends it.
834
956
  - A refresh spends its refresh token and answers a new pair; the old access
835
957
  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`.
958
+ whole session (`refresh_token_reused`), so a client serializes its refreshes.
959
+ A refused refresh answers 400 `invalid_bearer_grant`.
960
+ - A refresh answer that never reached the client (a reload, a closed tab, a
961
+ dropped connection) is the one exception. Within `refreshReuseGraceSeconds`
962
+ of the rotation (60 by default, 0 turns it off, at most 300; the
963
+ constructor refuses anything else), a spent refresh token whose successor
964
+ was never presented revokes that successor and answers a new pair, reported
965
+ as a `bearer_refresh_reissued` event. Presented means its access token
966
+ authenticated a request (`bearer/session`, `resolveBearerSession`,
967
+ `resolveBearerAccessToken`) or its refresh token was sent to `bearer/refresh`.
968
+ Only hashes are stored, so the lost pair is replaced, never repeated, and
969
+ that pair coming back later is reuse. Once the successor was presented, after
970
+ the window (counted from the rotation, however often the retry's answer is
971
+ lost too), and for the second of two retries at once, the spent token is
972
+ reuse and ends the session as before. `bearer/logout` judges a spent refresh
973
+ token the same way: a sign-out after a lost answer, within the window, ends
974
+ the session as a `logout`, not as reuse. A stolen spent token works within the
975
+ same window while the client has not used its new pair yet: the client's
976
+ access token then stops working, and its next refresh ends the session.
977
+ Choose the window by that trade-off.
839
978
  - The first claim consumes the code, whatever its verifier. A wrong verifier
840
979
  ends the session (`handoff_verifier_mismatch`), a claimed code that comes
841
980
  back ends it too (`handoff_code_reused`, RFC 6749 §4.1.2), and a code that
@@ -843,8 +982,10 @@ Tokens and their ends:
843
982
  bearer login sweeps it (`handoff_unclaimed`). Each answers 400
844
983
  `invalid_bearer_handoff`; reuse is also reported as a
845
984
  `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
985
+ - A bearer login is a provider login like a cookie session: `onSessionEnded`,
986
+ `endSessionsOfPrincipal` and `AuthorityObserver` apply as they are. (A
987
+ session opened by pairing a device has no provider login of its own; see
988
+ "Pairing a device" below.) A refresh
848
989
  rotates the provider token when it is due and ends the session when
849
990
  idp.global has ended the login (`invalid_bearer_grant`); an unreachable
850
991
  provider answers 503 `authority_unavailable` and spends nothing. Until then
@@ -870,15 +1011,21 @@ The client side:
870
1011
  - Every client claims a code once and never retries a claim or a refresh. Its
871
1012
  refreshes run one at a time across everything that shares the pair, and a
872
1013
  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.
1014
+ used. A pair that is lost after the relying party rotated it is recovered
1015
+ only by the next refresh with the stored pair within the grace window; after
1016
+ it, the spent refresh token coming back ends the session. Native clients
1017
+ implement this side themselves.
876
1018
 
877
1019
  The HTTP answers are exported as types (`IRelyingPartyBearerTokenAnswer`,
878
1020
  `IRelyingPartyBearerSessionAnswer`, `IRelyingPartyBearerLogoutAnswer`,
879
1021
  `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.
1022
+ popup's `IRelyingPartyBearerMessage`), and so are the requests:
1023
+ `IRelyingPartyBearerLoginQuery` (the `bearer/login` query by its parameter
1024
+ names), `IRelyingPartyBearerClaimRequest` (`{ code, codeVerifier }`),
1025
+ `IRelyingPartyBearerRefreshRequest` (`{ refreshToken }`) and
1026
+ `IRelyingPartyBearerLogoutRequest` (`{ refreshToken? }`). They come from a
1027
+ module that imports nothing, so `@idp.global/sdk/browser` and
1028
+ `@idp.global/sdk/browser/bearer` share them with the server.
882
1029
 
883
1030
  **The browser client.** `RelyingPartyBearerClient` from `@idp.global/sdk/browser`
884
1031
  is that client side for `web` and `popup` logins. The application owns where the
@@ -929,6 +1076,16 @@ await client.logout();
929
1076
  `previous_session_not_revoked` with `previousSessionError`. In the last case
930
1077
  that session lives on at the relying party until it expires. A failed claim
931
1078
  leaves the stored pair and its session as they were.
1079
+ - `createPkce()` makes a verifier and its S256 challenge from 32 random bytes,
1080
+ and `claimCode(pkce, code)` claims a code bound to that challenge which
1081
+ reached the client another way than its own login, such as a device pairing
1082
+ ("Pairing a device" below). It is received like a login: one
1083
+ claim, never retried, under `requestTimeoutMs`, and the pair replaces the
1084
+ stored one under the lock (`IRelyingPartyBearerLoginResult`). A pair is
1085
+ claimed with once: a second call with it, after a failed claim as well, is
1086
+ `already_received` and sends nothing. A pair that is not one (a malformed
1087
+ verifier, a challenge that is not its S256) or a code that is not text
1088
+ throws before anything is sent. Neither needs a window.
932
1089
  - `openPopupLogin(login, { timeoutMs?, signal?, windowFeatures? })` calls
933
1090
  `window.open` before it returns, so a browser lets it through within the
934
1091
  click. It takes only a message from the relying party's exact origin, from
@@ -945,8 +1102,9 @@ await client.logout();
945
1102
  time. Inside the lock the pair is read from the storage again. A pair another
946
1103
  tab rotated is used as it is, so concurrent refreshes make one request. A
947
1104
  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.
1105
+ token is answered; when `storage.set` fails, the new pair is never used, and
1106
+ the session goes on only if the next refresh comes within the relying
1107
+ party's grace window.
950
1108
  - `requestTimeoutMs` (30 seconds by default, more than 0 and at most two
951
1109
  minutes; any other value throws) is the deadline of each operation: the
952
1110
  claim of a login's code, a refresh with the write of its pair, the store of a
@@ -956,9 +1114,10 @@ await client.logout();
956
1114
  tabs and the call fails with `request_failed`; a login whose pair is stored
957
1115
  by then stands, with `previous_session_not_revoked`. A claim or refresh the
958
1116
  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.
1117
+ timed-out claim fails the login, and the user signs in again. After a lost
1118
+ refresh, the next refresh with the stored pair gets a new one when it runs
1119
+ within the relying party's `refreshReuseGraceSeconds`; later it ends the
1120
+ session (`refresh_token_reused`) and the user signs in again.
962
1121
  - `storage.set(tokens, { signal })` and `storage.clear({ signal })` either
963
1122
  honour the signal, so a write it aborted is not made and the call rejects,
964
1123
  or complete synchronously, as an adapter over `localStorage` does. The lock
@@ -981,12 +1140,94 @@ await client.logout();
981
1140
  - The client needs the Web Locks API and Web Crypto, which a browser offers
982
1141
  only in a secure context, and has no fallback for either. Every request is
983
1142
  sent without credentials, cache or redirects.
1143
+ - It runs in a Manifest V3 extension's service worker. That was checked in
1144
+ Chromium 149: a module service worker imported `@idp.global/sdk/browser/bearer`
1145
+ and ran `createPkce`, `claimCode` against the bearer routes and a rotation
1146
+ through `accessToken()`, with the lock held at each write. Other browsers'
1147
+ extension workers were not checked. The extension needs a host permission
1148
+ for the relying party's origin: the routes answer CORS for popup origins
1149
+ only, so without one the browser's preflight is refused and the claim fails
1150
+ with `request_failed` before it is sent.
1151
+
1152
+ **Pairing a device.** A person who is signed in can sign a second device of
1153
+ theirs in without a login on it, such as a TV or a browser extension. The
1154
+ device makes a PKCE pair and shows the application its challenge, for example
1155
+ together with a short code that the person confirms in a session where they
1156
+ are signed in. The application's server then opens the device's session from
1157
+ the session that confirmed:
1158
+
1159
+ ```ts
1160
+ // the device polls; the person has confirmed the pairing in one of their sessions
1161
+ const { code, expiresAt } = await sessions.openBearerHandoff({
1162
+ openerSessionId: pairing.confirmedInSessionId, // written by the confirm handler, never taken from the device
1163
+ codeChallenge: pairing.codeChallenge, // the device's S256 challenge
1164
+ maxAgeSeconds: 8 * 60 * 60, // optional: 1 to sessionMaxAgeSeconds, which is the default
1165
+ });
1166
+ // answer the poll with `code`; the device claims it at POST /auth/bearer/claim with its verifier
1167
+ ```
1168
+
1169
+ ```ts
1170
+ // the device, with the browser client (a native client claims at bearer/claim itself)
1171
+ const pkce = await client.createPkce();
1172
+ await showPairingRequest(pkce.codeChallenge);
1173
+ const result = await client.claimCode(pkce, await pollForCode());
1174
+ ```
1175
+
1176
+ - **The opener.** `openerSessionId` must name a live provider login: not ended
1177
+ and not expired, with an active principal and a provider login of its own
1178
+ (`idpSubject`). A session from `openSession` or from `openBearerHandoff`
1179
+ does not qualify, so a paired device cannot pair another one. Any other
1180
+ opener throws `RelyingPartyError('invalid_handoff_opener')` (400). A
1181
+ challenge that is not one throws `invalid_bearer_request`, and a
1182
+ `maxAgeSeconds` outside 1 to `sessionMaxAgeSeconds` throws a `RangeError`.
1183
+ Nothing is stored for any of these. Without `bearer` the call throws
1184
+ `login_method_unavailable`; in bearer-only mode it works, with a bearer login
1185
+ as the opener. The check reads this relying party's state of the opener;
1186
+ observe authority with the opener first to ask idp.global itself.
1187
+ - **The pairing record.** It keeps the id of the session that confirmed,
1188
+ written by the confirm handler and never taken from the device. That opener
1189
+ must still be live when the poll delivers the code. Open one hand-off per
1190
+ confirmed pairing, in the poll that delivers it; then the code is never
1191
+ stored, and only the public challenge sits in the record. Only server code
1192
+ opens a hand-off, and no route does. Guard the confirmation against a code
1193
+ the person did not start themselves (RFC 8628 §5.4).
1194
+ - **The code** is a bearer login's code: one claim within
1195
+ `BEARER_HANDOFF_MAX_AGE_SECONDS` (60) and never past the session, bound to
1196
+ the challenge, stored only as a hash. A claimed code that comes back, a wrong
1197
+ verifier or a code nobody claims ends the session.
1198
+ - **The session** acts as the opener's principal and has no `idpSubject` or
1199
+ `loginAttemptId`. It lives `maxAgeSeconds` from the opening, the same rule as
1200
+ every session, and no refresh extends it. It refreshes without asking the
1201
+ provider. Its expiry, `endSession`, `endSessionsOfPrincipal`, the device's
1202
+ `bearer/logout` and the reuse of its code or refresh token end it; an
1203
+ inactive principal resolves it to nothing.
1204
+ - **What does not reach it.** It holds no provider token, so an idp.global
1205
+ logout or a change of the person's memberships never reaches it, and
1206
+ `AuthorityObserver.observe` refuses it (`authority_login_required`) without
1207
+ ending it. So the application:
1208
+ - grants a paired session (`session.idpSubject` absent) no idp.global
1209
+ organization authority and does not observe it. An application that ends
1210
+ sessions on `authority_login_required` must not observe paired sessions at
1211
+ all;
1212
+ - sees idp.global end a login only as `onSessionEnded` with the reason
1213
+ `refresh_token_refused` for one of the person's provider sessions. It may
1214
+ answer that by ending the person's paired sessions: `endSession` for those
1215
+ `sessionsOfPrincipal` lists without `idpSubject`, or
1216
+ `endSessionsOfPrincipal` to end every session of the person. The hook
1217
+ names the principal of a session it revoked (`principalId`), so the
1218
+ application needs no table of sessions and principals of its own;
1219
+ - ends paired sessions itself when the person signs out everywhere, and
1220
+ erases them with every other session when the account goes
1221
+ (`eraseSessionsOfPrincipal`, see "Deleting an account");
1222
+ - chooses `sessionMaxAgeSeconds`, or `maxAgeSeconds` per pairing, knowing
1223
+ that a paired device lives that long without a check at idp.global.
984
1224
 
985
1225
  **Bearer-only mode.** An application whose every client is a bearer client turns
986
1226
  the cookie login off with `cookieSessions: false`. No route then reads or sets a
987
1227
  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.
1228
+ takes no `csrfKey`, and it refuses `csrfKey`, `csrfLabel`, `cookieNames` and
1229
+ `cookiePolicy`, because nothing would use them; `bearer` and `provider` are
1230
+ required.
990
1231
 
991
1232
  ```ts
992
1233
  const sessions = new RelyingPartySessions({
@@ -1004,7 +1245,8 @@ const sessions = new RelyingPartySessions({
1004
1245
  `null`, like every path the routes do not serve. `startLogin`,
1005
1246
  `completeLogin`, `abandonLogin`, `finalizeLogin`, `openSession`,
1006
1247
  `clearSessionCookie` and `clearIntentCookie` throw `login_method_unavailable`,
1007
- and `resolveCookieSession` answers `null`. A callback that belongs to no bearer
1248
+ and `resolveCookieSession` answers `null`. `openBearerHandoff` works, with a
1249
+ bearer login as the opener. A callback that belongs to no bearer
1008
1250
  login redirects to `failureLocation` when the provider sent an `error`, and
1009
1251
  otherwise answers 400 `authentication_failed`. `RelyingPartySocketBinding`
1010
1252
  registers a browser by its cookie session, so it binds no connection in this
@@ -1086,17 +1328,21 @@ const delivery = await readBearerDelivery(await fetch(callback, { redirect: 'man
1086
1328
 
1087
1329
  | API | Description |
1088
1330
  | --- | --- |
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. |
1331
+ | `IdpOidcProvider` | Discovery, PKCE authorization URL, code exchange with UserInfo, refresh, revocation and organization introspection for idp.global. Implements `IOidcProvider`. Verifies the signature of every ID token against the issuer's JWKS. `trustedCaPem` or `fetch` for an issuer under a private CA. |
1090
1332
  | `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. |
1333
+ | `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. |
1092
1334
  | `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
1335
  | `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`. |
1094
1336
  | `createActorScope()` | AsyncLocalStorage actor context with a TypedRouter request scope. |
1095
1337
  | `RelyingPartySocketBinding` | TypedSocket browser registration, per-request re-validation and session tags; takes a connection wrapper or its peer. |
1096
- | `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()`, `sanitizeReturnPath()` | The primitives underneath. |
1338
+ | `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()` (with `partitioned`, which needs `secure`), `sanitizeReturnPath()` | The primitives underneath. |
1097
1339
  | `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
1340
  | `IRelyingPartyBearerTokenAnswer`, `IRelyingPartyBearerSessionAnswer`, `IRelyingPartyBearerLogoutAnswer`, `IRelyingPartyBearerErrorAnswer`, `TRelyingPartyBearerErrorCode`, `IRelyingPartyBearerMessage` | The HTTP answers of the bearer routes and the popup message, shared with `@idp.global/sdk/browser`. |
1341
+ | `IRelyingPartyBearerLoginQuery`, `IRelyingPartyBearerClaimRequest`, `IRelyingPartyBearerRefreshRequest`, `IRelyingPartyBearerLogoutRequest` | The requests of the bearer routes: the `bearer/login` query and the JSON bodies, shared with `@idp.global/sdk/browser`. |
1342
+ | `IRelyingPartyBearerHandoffOpening` | What `openBearerHandoff` answers: the paired session, its code and when the code expires. |
1343
+ | `IRelyingPartySessionsErasure` | What `eraseSessionsOfPrincipal` answers: how many sessions it ended, and how many sessions, login attempts, bearer hand-offs and token generations it deleted. |
1099
1344
  | `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. |
1345
+ | `OidcProviderError`, `TOidcProviderErrorCode`, `OIDC_PROVIDER_ERROR_CODES`; `OidcCompletionError`, `OidcRefreshRefusedError`, `OidcAuthorityUnavailableError` | The provider's failures with the sdk's own `code` and the provider's `oauthError`, so no application classifies openid-client errors itself. |
1100
1346
  | `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. |
1101
1347
 
1102
1348
  ## Runtime Notes
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@idp.global/sdk',
6
- version: '19.4.0',
6
+ version: '19.6.0',
7
7
  description: 'Reusable browser and server SDK for idp.global authentication.'
8
8
  }