@idp.global/sdk 19.5.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 +34 -0
- package/dist_ts/00_commitinfo_data.js +1 -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 +67 -12
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +179 -41
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +21 -4
- package/dist_ts_relyingparty/classes.rpmodels.js +35 -8
- 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 +31 -0
- package/dist_ts_relyingparty/testing.js +8 -4
- package/package.json +3 -3
- package/readme.md +132 -32
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.idpoidcprovider.ts +183 -17
- package/ts_relyingparty/classes.relyingpartysessions.ts +225 -44
- package/ts_relyingparty/classes.rpmodels.ts +27 -4
- package/ts_relyingparty/helpers.cookies.ts +13 -0
- package/ts_relyingparty/interfaces.ts +32 -0
- package/ts_relyingparty/testing.ts +7 -3
package/readme.md
CHANGED
|
@@ -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
|
|
@@ -630,9 +634,46 @@ login's cause. Custom `cookieNames` with a `__Host-` or `__Secure-` prefix need
|
|
|
630
634
|
an https `publicOrigin`; on http the constructor refuses them, because browsers
|
|
631
635
|
would silently drop those cookies.
|
|
632
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
|
+
|
|
633
673
|
**Adopting existing session data.** The persisted fields and index declarations
|
|
634
674
|
are those nevermind.finance has stored since mainapp 0b693cb, and the session
|
|
635
|
-
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
|
|
636
677
|
already holds such collections binds the models to them and passes its own
|
|
637
678
|
cookie names and CSRF label, so live sessions and sealed tokens keep working:
|
|
638
679
|
|
|
@@ -657,7 +698,11 @@ Nothing runs on a timer. A login start first removes up to 100 login attempts
|
|
|
657
698
|
and up to 100 sessions that ended more than an hour ago, and a bearer login
|
|
658
699
|
start also its bearer hand-offs and token generations; `openBearerHandoff`
|
|
659
700
|
removes the bearer state and the sessions, `openSession` the sessions only.
|
|
660
|
-
Removing an ended session fires no end hook and calls no provider.
|
|
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.
|
|
661
706
|
|
|
662
707
|
**Deleting an account.** The sweeps above run only when a session is created,
|
|
663
708
|
so an ended session can stay stored for long on a quiet service.
|
|
@@ -688,13 +733,44 @@ await users.delete(userId);
|
|
|
688
733
|
covered. It keeps the principal id until it expires and a later sweep
|
|
689
734
|
removes it. A second `eraseSessionsOfPrincipal(principalId)`, run after the
|
|
690
735
|
user record is deleted, removes it. A token generation issued by a refresh
|
|
691
|
-
already under way is not covered either; it
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
+
```
|
|
698
774
|
|
|
699
775
|
**Building before the database connects.** `db` may be a function that answers
|
|
700
776
|
the database once it exists. It is called on every use of a model, the way
|
|
@@ -751,10 +827,11 @@ a deadline of its own; bound a slow audit store inside it.
|
|
|
751
827
|
|
|
752
828
|
**Issuers under a private CA.** `IdpOidcProvider` takes `trustedCaPem` — one
|
|
753
829
|
PEM string or several, e.g. `@idp.global/devidp`'s `caCertificatePem` or a LAN
|
|
754
|
-
deployment's root. Discovery, token, UserInfo, refresh, revocation
|
|
755
|
-
introspection
|
|
756
|
-
trusts those CAs and nothing else; the
|
|
757
|
-
`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:`.
|
|
758
835
|
An unreadable PEM is refused by the constructor.
|
|
759
836
|
|
|
760
837
|
```ts
|
|
@@ -819,6 +896,7 @@ const sessions = new RelyingPartySessions({
|
|
|
819
896
|
popup: ['https://workspace.example'],
|
|
820
897
|
},
|
|
821
898
|
accessTokenMaxAgeSeconds: 900, // the default
|
|
899
|
+
refreshReuseGraceSeconds: 60, // the default; 0 turns it off, at most 300
|
|
822
900
|
},
|
|
823
901
|
});
|
|
824
902
|
```
|
|
@@ -877,9 +955,26 @@ Tokens and their ends:
|
|
|
877
955
|
extends it.
|
|
878
956
|
- A refresh spends its refresh token and answers a new pair; the old access
|
|
879
957
|
token stops working at once. A spent refresh token that comes back ends the
|
|
880
|
-
whole session (`refresh_token_reused`), so a client serializes its refreshes
|
|
881
|
-
|
|
882
|
-
|
|
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.
|
|
883
978
|
- The first claim consumes the code, whatever its verifier. A wrong verifier
|
|
884
979
|
ends the session (`handoff_verifier_mismatch`), a claimed code that comes
|
|
885
980
|
back ends it too (`handoff_code_reused`, RFC 6749 §4.1.2), and a code that
|
|
@@ -916,9 +1011,10 @@ The client side:
|
|
|
916
1011
|
- Every client claims a code once and never retries a claim or a refresh. Its
|
|
917
1012
|
refreshes run one at a time across everything that shares the pair, and a
|
|
918
1013
|
rotated pair is stored, all of it at once, before its access token is first
|
|
919
|
-
used
|
|
920
|
-
|
|
921
|
-
|
|
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.
|
|
922
1018
|
|
|
923
1019
|
The HTTP answers are exported as types (`IRelyingPartyBearerTokenAnswer`,
|
|
924
1020
|
`IRelyingPartyBearerSessionAnswer`, `IRelyingPartyBearerLogoutAnswer`,
|
|
@@ -1006,8 +1102,9 @@ await client.logout();
|
|
|
1006
1102
|
time. Inside the lock the pair is read from the storage again. A pair another
|
|
1007
1103
|
tab rotated is used as it is, so concurrent refreshes make one request. A
|
|
1008
1104
|
rotated pair is stored before the lock is released and before its access
|
|
1009
|
-
token is answered; when `storage.set` fails, the new pair is never used and
|
|
1010
|
-
the session
|
|
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.
|
|
1011
1108
|
- `requestTimeoutMs` (30 seconds by default, more than 0 and at most two
|
|
1012
1109
|
minutes; any other value throws) is the deadline of each operation: the
|
|
1013
1110
|
claim of a login's code, a refresh with the write of its pair, the store of a
|
|
@@ -1017,9 +1114,10 @@ await client.logout();
|
|
|
1017
1114
|
tabs and the call fails with `request_failed`; a login whose pair is stored
|
|
1018
1115
|
by then stands, with `previous_session_not_revoked`. A claim or refresh the
|
|
1019
1116
|
relying party carried out before the deadline is lost with its answer. A
|
|
1020
|
-
timed-out claim fails the login, and the user signs in again
|
|
1021
|
-
refresh, the next refresh
|
|
1022
|
-
|
|
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.
|
|
1023
1121
|
- `storage.set(tokens, { signal })` and `storage.clear({ signal })` either
|
|
1024
1122
|
honour the signal, so a write it aborted is not made and the call rejects,
|
|
1025
1123
|
or complete synchronously, as an adapter over `localStorage` does. The lock
|
|
@@ -1127,8 +1225,9 @@ const result = await client.claimCode(pkce, await pollForCode());
|
|
|
1127
1225
|
**Bearer-only mode.** An application whose every client is a bearer client turns
|
|
1128
1226
|
the cookie login off with `cookieSessions: false`. No route then reads or sets a
|
|
1129
1227
|
cookie, and that includes the intent cookie a cookie-mode callback clears. It
|
|
1130
|
-
takes no `csrfKey`, and it refuses `csrfKey`, `csrfLabel` and
|
|
1131
|
-
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.
|
|
1132
1231
|
|
|
1133
1232
|
```ts
|
|
1134
1233
|
const sessions = new RelyingPartySessions({
|
|
@@ -1229,20 +1328,21 @@ const delivery = await readBearerDelivery(await fetch(callback, { redirect: 'man
|
|
|
1229
1328
|
|
|
1230
1329
|
| API | Description |
|
|
1231
1330
|
| --- | --- |
|
|
1232
|
-
| `IdpOidcProvider` | Discovery, PKCE authorization URL, code exchange with UserInfo, refresh, revocation and organization introspection for idp.global. Implements `IOidcProvider`. `trustedCaPem` or `fetch` for an issuer under a private CA. |
|
|
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. |
|
|
1233
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. |
|
|
1234
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. |
|
|
1235
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. |
|
|
1236
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`. |
|
|
1237
1336
|
| `createActorScope()` | AsyncLocalStorage actor context with a TypedRouter request scope. |
|
|
1238
1337
|
| `RelyingPartySocketBinding` | TypedSocket browser registration, per-request re-validation and session tags; takes a connection wrapper or its peer. |
|
|
1239
|
-
| `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()`, `sanitizeReturnPath()` | The primitives underneath. |
|
|
1338
|
+
| `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()` (with `partitioned`, which needs `secure`), `sanitizeReturnPath()` | The primitives underneath. |
|
|
1240
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. |
|
|
1241
1340
|
| `IRelyingPartyBearerTokenAnswer`, `IRelyingPartyBearerSessionAnswer`, `IRelyingPartyBearerLogoutAnswer`, `IRelyingPartyBearerErrorAnswer`, `TRelyingPartyBearerErrorCode`, `IRelyingPartyBearerMessage` | The HTTP answers of the bearer routes and the popup message, shared with `@idp.global/sdk/browser`. |
|
|
1242
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`. |
|
|
1243
1342
|
| `IRelyingPartyBearerHandoffOpening` | What `openBearerHandoff` answers: the paired session, its code and when the code expires. |
|
|
1244
1343
|
| `IRelyingPartySessionsErasure` | What `eraseSessionsOfPrincipal` answers: how many sessions it ended, and how many sessions, login attempts, bearer hand-offs and token generations it deleted. |
|
|
1245
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. |
|
|
1246
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. |
|
|
1247
1347
|
|
|
1248
1348
|
## Runtime Notes
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -70,24 +70,163 @@ export interface IOidcRefreshedTokens {
|
|
|
70
70
|
accessTokenExpiresAt: number;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* What failed when the provider was asked, as an application tells the cases
|
|
75
|
+
* apart; never why access was refused, which only an authority answer says.
|
|
76
|
+
*/
|
|
77
|
+
export type TOidcProviderErrorCode =
|
|
78
|
+
/**
|
|
79
|
+
* The provider refused the login: an OAuth error in the callback, for the
|
|
80
|
+
* code at the token endpoint, or for its token at UserInfo. `oauthError`
|
|
81
|
+
* names it, such as `invalid_grant` for a code that was used or expired.
|
|
82
|
+
*/
|
|
83
|
+
| 'token_exchange_refused'
|
|
84
|
+
/**
|
|
85
|
+
* The callback or its ID token is not this login's: a `state` that is not
|
|
86
|
+
* the login's, or an ID token claim that does not match it (the nonce, and
|
|
87
|
+
* likewise the issuer, the audience or the authorized party).
|
|
88
|
+
*/
|
|
89
|
+
| 'login_mismatch'
|
|
90
|
+
/**
|
|
91
|
+
* The provider answered outside its contract: an ID token whose signature
|
|
92
|
+
* does not verify against the JWKS or that has expired, no subject, another
|
|
93
|
+
* subject at UserInfo, or a body that is no OAuth answer.
|
|
94
|
+
*/
|
|
95
|
+
| 'invalid_response'
|
|
96
|
+
/** The provider could not be asked: no answer in time, a transport failure, a 5xx, or no discovery yet. */
|
|
97
|
+
| 'provider_unreachable'
|
|
98
|
+
/** The refresh grant answered `invalid_grant`: the refresh family is revoked, expired or was reused. */
|
|
99
|
+
| 'refresh_refused';
|
|
100
|
+
|
|
101
|
+
export const OIDC_PROVIDER_ERROR_CODES: readonly TOidcProviderErrorCode[] = [
|
|
102
|
+
'token_exchange_refused',
|
|
103
|
+
'login_mismatch',
|
|
104
|
+
'invalid_response',
|
|
105
|
+
'provider_unreachable',
|
|
106
|
+
'refresh_refused',
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
/** An OAuth error code is a short token; anything else a provider sends there is dropped, never kept. */
|
|
110
|
+
const OAUTH_ERROR_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
111
|
+
|
|
112
|
+
const oauthErrorOf = (valueArg: unknown): string | undefined =>
|
|
113
|
+
typeof valueArg === 'string' && OAUTH_ERROR_PATTERN.test(valueArg) ? valueArg : undefined;
|
|
114
|
+
|
|
115
|
+
/** The OAuth error and HTTP status of an openid-client failure that is the provider's own answer; `null` for any other failure. */
|
|
116
|
+
const providerAnswerOf = (errorArg: unknown): { oauthError?: string; status?: number } | null => {
|
|
117
|
+
const openidClient = plugins.openidClient;
|
|
118
|
+
if (errorArg instanceof openidClient.ResponseBodyError) {
|
|
119
|
+
return { oauthError: oauthErrorOf(errorArg.error), status: errorArg.status };
|
|
120
|
+
}
|
|
121
|
+
if (errorArg instanceof openidClient.AuthorizationResponseError) {
|
|
122
|
+
return { oauthError: oauthErrorOf(errorArg.error) };
|
|
123
|
+
}
|
|
124
|
+
if (errorArg instanceof openidClient.WWWAuthenticateChallengeError) {
|
|
125
|
+
return { oauthError: oauthErrorOf(errorArg.cause[0]?.parameters.error), status: errorArg.status };
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** openid-client's codes (`ClientError.code`) for a provider that did not answer in time or within HTTP's own contract. */
|
|
131
|
+
const UNREACHABLE_CLIENT_ERROR_CODES: readonly string[] = ['OAUTH_TIMEOUT', 'OAUTH_ABORT', 'OAUTH_RESPONSE_IS_NOT_CONFORM'];
|
|
132
|
+
/** openid-client's code for a JWT claim that is not the expected one: the nonce, the issuer, the audience or the authorized party. */
|
|
133
|
+
const JWT_CLAIM_COMPARISON_CODE = 'OAUTH_JWT_CLAIM_COMPARISON_FAILED';
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The class of an openid-client failure of the code exchange or of UserInfo.
|
|
137
|
+
* The provider's own OAuth error answer is a refusal, or unreachable on a 5xx;
|
|
138
|
+
* a claim that does not match is not this login's; every other failure of
|
|
139
|
+
* openid-client's checks is an invalid response; and anything else (a
|
|
140
|
+
* transport failure is fetch's `TypeError`) is an unreachable provider.
|
|
141
|
+
*/
|
|
142
|
+
const classifyCompletionFailure = (errorArg: unknown): { code: TOidcProviderErrorCode; oauthError?: string } => {
|
|
143
|
+
if (errorArg instanceof OidcProviderError) {
|
|
144
|
+
return { code: errorArg.code, oauthError: errorArg.oauthError };
|
|
145
|
+
}
|
|
146
|
+
const answer = providerAnswerOf(errorArg);
|
|
147
|
+
if (answer) {
|
|
148
|
+
const code: TOidcProviderErrorCode = answer.status !== undefined && answer.status >= 500
|
|
149
|
+
? 'provider_unreachable'
|
|
150
|
+
: 'token_exchange_refused';
|
|
151
|
+
return { code, oauthError: answer.oauthError };
|
|
152
|
+
}
|
|
153
|
+
if (errorArg instanceof plugins.openidClient.ClientError) {
|
|
154
|
+
if (errorArg.code === JWT_CLAIM_COMPARISON_CODE) {
|
|
155
|
+
return { code: 'login_mismatch' };
|
|
156
|
+
}
|
|
157
|
+
return { code: errorArg.code !== undefined && UNREACHABLE_CLIENT_ERROR_CODES.includes(errorArg.code) ? 'provider_unreachable' : 'invalid_response' };
|
|
158
|
+
}
|
|
159
|
+
return { code: 'provider_unreachable' };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Why a refresh could not be made: openid-client's checks failed on the answer, or the provider could not be asked. */
|
|
163
|
+
const refreshFailureReasonOf = (errorArg: unknown): TOidcAuthorityFailure =>
|
|
164
|
+
errorArg instanceof plugins.openidClient.ClientError
|
|
165
|
+
&& !(errorArg.code !== undefined && UNREACHABLE_CLIENT_ERROR_CODES.includes(errorArg.code))
|
|
166
|
+
? 'invalid_response'
|
|
167
|
+
: 'unavailable';
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Every failure of the provider this module throws: its `code` says which
|
|
171
|
+
* case it is, `oauthError` the provider's own OAuth error code where it sent
|
|
172
|
+
* one (a short token, never its description). An application classifies a
|
|
173
|
+
* failure by these and never by openid-client's classes; `cause` keeps the
|
|
174
|
+
* original for a debugger, not for a log line.
|
|
175
|
+
*/
|
|
176
|
+
export class OidcProviderError extends Error {
|
|
177
|
+
public readonly code: TOidcProviderErrorCode;
|
|
178
|
+
public readonly oauthError?: string;
|
|
179
|
+
|
|
180
|
+
constructor(
|
|
181
|
+
messageArg: string,
|
|
182
|
+
detailArg: { code: TOidcProviderErrorCode; oauthError?: string },
|
|
183
|
+
causeArg?: unknown,
|
|
184
|
+
) {
|
|
185
|
+
super(messageArg, { cause: causeArg });
|
|
186
|
+
this.name = 'OidcProviderError';
|
|
187
|
+
if (!OIDC_PROVIDER_ERROR_CODES.includes(detailArg?.code)) {
|
|
188
|
+
throw new TypeError('OidcProviderError needs one of OIDC_PROVIDER_ERROR_CODES');
|
|
189
|
+
}
|
|
190
|
+
this.code = detailArg.code;
|
|
191
|
+
const oauthError = oauthErrorOf(detailArg.oauthError);
|
|
192
|
+
if (oauthError !== undefined) {
|
|
193
|
+
this.oauthError = oauthError;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
73
198
|
/** Why an observation or a refresh could not be made; never why access was refused. */
|
|
74
199
|
export type TOidcAuthorityFailure = 'unavailable' | 'invalid_response';
|
|
75
200
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
201
|
+
/**
|
|
202
|
+
* The provider could not be asked, or did not answer in its own contract. The
|
|
203
|
+
* caller fails closed on it. `code` is `provider_unreachable` or
|
|
204
|
+
* `invalid_response`, after `reason`.
|
|
205
|
+
*/
|
|
206
|
+
export class OidcAuthorityUnavailableError extends OidcProviderError {
|
|
78
207
|
constructor(
|
|
79
208
|
public readonly reason: TOidcAuthorityFailure,
|
|
80
209
|
causeArg?: unknown,
|
|
81
210
|
) {
|
|
82
|
-
super(
|
|
211
|
+
super(
|
|
212
|
+
'The identity provider could not be observed',
|
|
213
|
+
{
|
|
214
|
+
code: reason === 'invalid_response' ? 'invalid_response' : 'provider_unreachable',
|
|
215
|
+
oauthError: providerAnswerOf(causeArg)?.oauthError,
|
|
216
|
+
},
|
|
217
|
+
causeArg,
|
|
218
|
+
);
|
|
83
219
|
this.name = 'OidcAuthorityUnavailableError';
|
|
84
220
|
}
|
|
85
221
|
}
|
|
86
222
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
223
|
+
/**
|
|
224
|
+
* The provider refused the refresh with `invalid_grant`: the refresh family is
|
|
225
|
+
* revoked, expired or was reused. `code` is `refresh_refused`.
|
|
226
|
+
*/
|
|
227
|
+
export class OidcRefreshRefusedError extends OidcProviderError {
|
|
89
228
|
constructor(causeArg?: unknown) {
|
|
90
|
-
super('The identity provider refused the refresh', {
|
|
229
|
+
super('The identity provider refused the refresh', { code: 'refresh_refused', oauthError: 'invalid_grant' }, causeArg);
|
|
91
230
|
this.name = 'OidcRefreshRefusedError';
|
|
92
231
|
}
|
|
93
232
|
}
|
|
@@ -96,12 +235,18 @@ export type TOidcTokenHint = 'access_token' | 'refresh_token';
|
|
|
96
235
|
|
|
97
236
|
export type TOidcCompletionFailureStage = 'token_exchange' | 'token_validation' | 'userinfo';
|
|
98
237
|
|
|
99
|
-
|
|
238
|
+
/**
|
|
239
|
+
* A login the provider half could not complete. `stage` says where it
|
|
240
|
+
* stopped, `code` what it was; without a `detailArg` the code is read from
|
|
241
|
+
* the cause, an openid-client failure.
|
|
242
|
+
*/
|
|
243
|
+
export class OidcCompletionError extends OidcProviderError {
|
|
100
244
|
constructor(
|
|
101
245
|
public readonly stage: TOidcCompletionFailureStage,
|
|
102
246
|
causeArg: unknown,
|
|
247
|
+
detailArg?: { code: TOidcProviderErrorCode; oauthError?: string },
|
|
103
248
|
) {
|
|
104
|
-
super('OIDC completion failed',
|
|
249
|
+
super('OIDC completion failed', detailArg ?? classifyCompletionFailure(causeArg), causeArg);
|
|
105
250
|
this.name = 'OidcCompletionError';
|
|
106
251
|
}
|
|
107
252
|
}
|
|
@@ -151,7 +296,7 @@ export interface IIdpOidcProviderOptions {
|
|
|
151
296
|
/** the registered callback, on the application's public origin */
|
|
152
297
|
redirectUri: URL;
|
|
153
298
|
scopes?: string;
|
|
154
|
-
/** deadline of discovery, token, UserInfo, refresh and revocation requests */
|
|
299
|
+
/** deadline of discovery, token, JWKS, UserInfo, refresh and revocation requests */
|
|
155
300
|
requestTimeoutSeconds?: number;
|
|
156
301
|
/** deadline of one introspection; the introspection client's default applies when absent */
|
|
157
302
|
introspectionTimeoutMs?: number;
|
|
@@ -165,7 +310,7 @@ export interface IIdpOidcProviderOptions {
|
|
|
165
310
|
trustedCaPem?: string | readonly string[];
|
|
166
311
|
/**
|
|
167
312
|
* Advanced: the fetch that carries every request of this provider —
|
|
168
|
-
* discovery, token, UserInfo, refresh, revocation and introspection.
|
|
313
|
+
* discovery, token, JWKS, UserInfo, refresh, revocation and introspection.
|
|
169
314
|
* It owns the transport's trust decisions; https-only still holds. Not
|
|
170
315
|
* combined with `trustedCaPem`.
|
|
171
316
|
*/
|
|
@@ -213,9 +358,9 @@ export const readOrganizationClaims = (valueArg: unknown): IOidcOrganizationClai
|
|
|
213
358
|
/**
|
|
214
359
|
* The relying-party side of the authorization code flow with S256 PKCE on
|
|
215
360
|
* `openid-client`, in the profile idp.global supports: code responses, S256
|
|
216
|
-
* challenges, RS256 ID tokens
|
|
217
|
-
*
|
|
218
|
-
* `OidcIntrospectionClient`.
|
|
361
|
+
* challenges, RS256 ID tokens whose signature is verified against the
|
|
362
|
+
* issuer's JWKS, and `client_secret_basic` for a confidential client. Live
|
|
363
|
+
* organization authority is observed with the sdk's `OidcIntrospectionClient`.
|
|
219
364
|
*/
|
|
220
365
|
export class IdpOidcProvider implements IOidcProvider {
|
|
221
366
|
public readonly requestTimeoutSeconds: number;
|
|
@@ -262,7 +407,11 @@ export class IdpOidcProvider implements IOidcProvider {
|
|
|
262
407
|
}
|
|
263
408
|
const options = this.#options;
|
|
264
409
|
// the timeout and the fetch given to discovery apply to discovery itself
|
|
265
|
-
// and stay on the resolved configuration for every later request
|
|
410
|
+
// and stay on the resolved configuration for every later request.
|
|
411
|
+
// `enableNonRepudiationChecks` verifies the signature of every ID token the
|
|
412
|
+
// code grant and the refresh grant answer against the issuer's JWKS
|
|
413
|
+
// (`jwks_uri`, fetched through the same fetch), so an ID token is never
|
|
414
|
+
// accepted on the trust of the TLS connection alone
|
|
266
415
|
const configuration = await plugins.openidClient.discovery(
|
|
267
416
|
options.issuer,
|
|
268
417
|
options.clientId,
|
|
@@ -274,6 +423,7 @@ export class IdpOidcProvider implements IOidcProvider {
|
|
|
274
423
|
plugins.openidClient.ClientSecretBasic(options.clientSecret),
|
|
275
424
|
{
|
|
276
425
|
timeout: this.requestTimeoutSeconds,
|
|
426
|
+
execute: [plugins.openidClient.enableNonRepudiationChecks],
|
|
277
427
|
...(this.#fetch ? { [plugins.openidClient.customFetch]: this.#fetch } : {}),
|
|
278
428
|
},
|
|
279
429
|
);
|
|
@@ -322,6 +472,15 @@ export class IdpOidcProvider implements IOidcProvider {
|
|
|
322
472
|
}
|
|
323
473
|
|
|
324
474
|
public async completeAuthorization(optionsArg: IOidcCompletionOptions): Promise<IOidcCompletion> {
|
|
475
|
+
// the one state of the callback is the login's, or nothing is exchanged
|
|
476
|
+
const states = optionsArg.callbackUrl.searchParams.getAll('state');
|
|
477
|
+
if (states.length !== 1 || states[0] !== optionsArg.state) {
|
|
478
|
+
throw new OidcCompletionError(
|
|
479
|
+
'token_exchange',
|
|
480
|
+
new Error('The callback state is not the state of this login'),
|
|
481
|
+
{ code: 'login_mismatch' },
|
|
482
|
+
);
|
|
483
|
+
}
|
|
325
484
|
let tokens: Awaited<ReturnType<typeof plugins.openidClient.authorizationCodeGrant>>;
|
|
326
485
|
try {
|
|
327
486
|
tokens = await plugins.openidClient.authorizationCodeGrant(this.configuration, optionsArg.callbackUrl, {
|
|
@@ -342,7 +501,7 @@ export class IdpOidcProvider implements IOidcProvider {
|
|
|
342
501
|
}
|
|
343
502
|
subject = claims.sub;
|
|
344
503
|
} catch (error) {
|
|
345
|
-
throw new OidcCompletionError('token_validation', error);
|
|
504
|
+
throw new OidcCompletionError('token_validation', error, { code: 'invalid_response' });
|
|
346
505
|
}
|
|
347
506
|
|
|
348
507
|
// UserInfo is the current view of profile and organization claims; the ID
|
|
@@ -354,7 +513,11 @@ export class IdpOidcProvider implements IOidcProvider {
|
|
|
354
513
|
throw new OidcCompletionError('userinfo', error);
|
|
355
514
|
}
|
|
356
515
|
if (userInfo.sub !== subject) {
|
|
357
|
-
throw new OidcCompletionError(
|
|
516
|
+
throw new OidcCompletionError(
|
|
517
|
+
'token_validation',
|
|
518
|
+
new Error('The UserInfo subject does not match the ID token'),
|
|
519
|
+
{ code: 'invalid_response' },
|
|
520
|
+
);
|
|
358
521
|
}
|
|
359
522
|
|
|
360
523
|
return {
|
|
@@ -426,7 +589,10 @@ export class IdpOidcProvider implements IOidcProvider {
|
|
|
426
589
|
if (error instanceof plugins.openidClient.ResponseBodyError && error.error === 'invalid_grant') {
|
|
427
590
|
throw new OidcRefreshRefusedError(error);
|
|
428
591
|
}
|
|
429
|
-
|
|
592
|
+
// everything else fails closed: another OAuth error (`invalid_client` for a
|
|
593
|
+
// deactivated app) or a transport failure is unreachable, an answer that
|
|
594
|
+
// fails openid-client's checks (an ID token that does not verify) is invalid
|
|
595
|
+
throw new OidcAuthorityUnavailableError(refreshFailureReasonOf(error), error);
|
|
430
596
|
}
|
|
431
597
|
return {
|
|
432
598
|
accessToken: tokens.access_token,
|