@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.
- package/changelog.md +60 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +40 -3
- package/dist_ts_browser/classes.relyingpartybearerclient.js +67 -12
- package/dist_ts_relyingparty/classes.errors.d.ts +2 -0
- package/dist_ts_relyingparty/classes.errors.js +2 -1
- package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +69 -11
- package/dist_ts_relyingparty/classes.idpoidcprovider.js +127 -17
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +128 -18
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +334 -69
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +21 -4
- package/dist_ts_relyingparty/classes.rpmodels.js +35 -8
- package/dist_ts_relyingparty/contract.bearer.d.ts +37 -0
- package/dist_ts_relyingparty/contract.bearer.js +5 -1
- package/dist_ts_relyingparty/helpers.bearer.d.ts +2 -3
- package/dist_ts_relyingparty/helpers.bearer.js +4 -6
- package/dist_ts_relyingparty/helpers.cookies.d.ts +6 -0
- package/dist_ts_relyingparty/helpers.cookies.js +8 -1
- package/dist_ts_relyingparty/interfaces.d.ts +73 -3
- package/dist_ts_relyingparty/testing.js +8 -4
- package/package.json +3 -3
- package/readme.md +280 -34
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_browser/classes.relyingpartybearerclient.ts +98 -18
- package/ts_relyingparty/classes.errors.ts +3 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +183 -17
- package/ts_relyingparty/classes.relyingpartysessions.ts +393 -78
- package/ts_relyingparty/classes.rpmodels.ts +27 -4
- package/ts_relyingparty/contract.bearer.ts +46 -0
- package/ts_relyingparty/helpers.bearer.ts +3 -6
- package/ts_relyingparty/helpers.cookies.ts +13 -0
- package/ts_relyingparty/interfaces.ts +76 -7
- 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.
|
|
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": "^
|
|
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.
|
|
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,
|
|
563
|
-
|
|
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`
|
|
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
|
|
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; `
|
|
652
|
-
the
|
|
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
|
|
711
|
-
introspection
|
|
712
|
-
trusts those CAs and nothing else; the
|
|
713
|
-
`NODE_EXTRA_CA_CERTS` are not involved, and
|
|
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
|
-
|
|
838
|
-
|
|
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
|
|
847
|
-
`endSessionsOfPrincipal` and `AuthorityObserver` apply as they are. A
|
|
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
|
|
874
|
-
|
|
875
|
-
|
|
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`)
|
|
881
|
-
|
|
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
|
|
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
|
|
960
|
-
refresh, the next refresh
|
|
961
|
-
|
|
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
|
|
989
|
-
because nothing would use them; `bearer` and `provider` are
|
|
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`.
|
|
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
|