@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/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, 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
@@ -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, 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
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 outlives its session row as
692
- described below.
693
- - A token generation, and for at most a minute a hand-off, outlives the row
694
- of its session when that session was revoked and swept before its expiry.
695
- It holds only hashes, the session id and times, the bearer sweep removes it
696
- an hour after the session would have expired, and the erase cannot
697
- attribute it to the principal any more.
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 and
755
- introspection then go through one `node:https` agent of this provider that
756
- trusts those CAs and nothing else; the global `fetch`, its dispatcher and
757
- `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:`.
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
- and a client that lost a refresh answer signs in again. A refused refresh
882
- 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.
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: a pair that is lost after the relying party rotated it cannot be
920
- recovered, and its spent refresh token coming back ends the session. Native
921
- clients implement this side themselves.
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 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.
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; after a lost
1021
- refresh, the next refresh ends the session (`refresh_token_reused`) and the
1022
- user signs in again. Strict rotation costs that.
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 `cookieNames`,
1131
- 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.
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
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@idp.global/sdk',
6
- version: '19.5.0',
6
+ version: '19.6.0',
7
7
  description: 'Reusable browser and server SDK for idp.global authentication.'
8
8
  }
@@ -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
- /** The provider could not be asked, or did not answer in its own contract. The caller fails closed on it. */
77
- export class OidcAuthorityUnavailableError extends Error {
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('The identity provider could not be observed', { cause: causeArg });
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
- /** The provider refused the refresh with `invalid_grant`: the refresh family is revoked, expired or was reused. */
88
- export class OidcRefreshRefusedError extends Error {
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', { cause: causeArg });
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
- export class OidcCompletionError extends Error {
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', { cause: causeArg });
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 and `client_secret_basic` for a confidential
217
- * client. Live organization authority is observed with the sdk's
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('token_validation', new Error('The UserInfo subject does not match the ID token'));
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
- throw new OidcAuthorityUnavailableError('unavailable', error);
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,