@idp.global/sdk 19.4.0 → 19.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/changelog.md +60 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +40 -3
  4. package/dist_ts_browser/classes.relyingpartybearerclient.js +67 -12
  5. package/dist_ts_relyingparty/classes.errors.d.ts +2 -0
  6. package/dist_ts_relyingparty/classes.errors.js +2 -1
  7. package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +69 -11
  8. package/dist_ts_relyingparty/classes.idpoidcprovider.js +127 -17
  9. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +128 -18
  10. package/dist_ts_relyingparty/classes.relyingpartysessions.js +334 -69
  11. package/dist_ts_relyingparty/classes.rpmodels.d.ts +21 -4
  12. package/dist_ts_relyingparty/classes.rpmodels.js +35 -8
  13. package/dist_ts_relyingparty/contract.bearer.d.ts +37 -0
  14. package/dist_ts_relyingparty/contract.bearer.js +5 -1
  15. package/dist_ts_relyingparty/helpers.bearer.d.ts +2 -3
  16. package/dist_ts_relyingparty/helpers.bearer.js +4 -6
  17. package/dist_ts_relyingparty/helpers.cookies.d.ts +6 -0
  18. package/dist_ts_relyingparty/helpers.cookies.js +8 -1
  19. package/dist_ts_relyingparty/interfaces.d.ts +73 -3
  20. package/dist_ts_relyingparty/testing.js +8 -4
  21. package/package.json +3 -3
  22. package/readme.md +280 -34
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts_browser/classes.relyingpartybearerclient.ts +98 -18
  25. package/ts_relyingparty/classes.errors.ts +3 -0
  26. package/ts_relyingparty/classes.idpoidcprovider.ts +183 -17
  27. package/ts_relyingparty/classes.relyingpartysessions.ts +393 -78
  28. package/ts_relyingparty/classes.rpmodels.ts +27 -4
  29. package/ts_relyingparty/contract.bearer.ts +46 -0
  30. package/ts_relyingparty/helpers.bearer.ts +3 -6
  31. package/ts_relyingparty/helpers.cookies.ts +13 -0
  32. package/ts_relyingparty/interfaces.ts +76 -7
  33. package/ts_relyingparty/testing.ts +7 -3
@@ -1,13 +1,22 @@
1
1
  import {
2
+ isPkceVerifier,
2
3
  RELYING_PARTY_BEARER_MESSAGE_TYPE,
4
+ type IRelyingPartyBearerClaimRequest,
5
+ type IRelyingPartyBearerLoginQuery,
3
6
  type IRelyingPartyBearerLogoutAnswer,
7
+ type IRelyingPartyBearerLogoutRequest,
8
+ type IRelyingPartyBearerRefreshRequest,
4
9
  type IRelyingPartyBearerTokenAnswer,
5
10
  } from '../ts_relyingparty/contract.bearer.js';
6
11
 
7
12
  export type {
13
+ IRelyingPartyBearerClaimRequest,
8
14
  IRelyingPartyBearerErrorAnswer,
15
+ IRelyingPartyBearerLoginQuery,
9
16
  IRelyingPartyBearerLogoutAnswer,
17
+ IRelyingPartyBearerLogoutRequest,
10
18
  IRelyingPartyBearerMessage,
19
+ IRelyingPartyBearerRefreshRequest,
11
20
  IRelyingPartyBearerSessionAnswer,
12
21
  IRelyingPartyBearerSessionInfo,
13
22
  IRelyingPartyBearerTokenAnswer,
@@ -49,6 +58,19 @@ export interface IRelyingPartyBearerTokenStorage {
49
58
  clear(optionsArg: { signal: AbortSignal }): Promise<void>;
50
59
  }
51
60
 
61
+ /**
62
+ * A PKCE pair (RFC 7636): the verifier the client keeps and the S256
63
+ * challenge it hands out. `codeVerifier` is the secret that makes a code
64
+ * worth anything; keep it like a pending login, never in a URL, a log or a
65
+ * message.
66
+ */
67
+ export interface IRelyingPartyBearerPkce {
68
+ /** 43 characters from 32 random bytes */
69
+ codeVerifier: string;
70
+ /** BASE64URL(SHA-256(codeVerifier)) */
71
+ codeChallenge: string;
72
+ }
73
+
52
74
  /**
53
75
  * A login on its way: what `createLogin` made and the receiver needs back.
54
76
  * A `web` login leaves the page, so the application keeps it until the
@@ -149,7 +171,7 @@ export type TRelyingPartyBearerClientErrorCode =
149
171
  | 'state_mismatch'
150
172
  /** the delivery carries no code; `deliveryError` says why */
151
173
  | 'login_failed'
152
- /** this login was received once already; a code is claimed once, never again */
174
+ /** this login, or this PKCE pair, was received once already; a code is claimed once, never again */
153
175
  | 'already_received'
154
176
  /** the browser opened no popup */
155
177
  | 'popup_blocked'
@@ -219,6 +241,12 @@ const isText = (valueArg: unknown): valueArg is string => typeof valueArg === 's
219
241
 
220
242
  const isTime = (valueArg: unknown): valueArg is number => typeof valueArg === 'number' && Number.isFinite(valueArg);
221
243
 
244
+ /** What the client posts to the bearer routes. */
245
+ type TRelyingPartyBearerRequestBody =
246
+ | IRelyingPartyBearerClaimRequest
247
+ | IRelyingPartyBearerRefreshRequest
248
+ | IRelyingPartyBearerLogoutRequest;
249
+
222
250
  /** Throws `request_failed` once a section's deadline has fired: nothing is sent or written past it. */
223
251
  const inTime = (signalArg: AbortSignal): void => {
224
252
  if (signalArg.aborted) {
@@ -278,7 +306,9 @@ const tokenAnswerOf = (answerArg: unknown): IRelyingPartyBearerTokenAnswer | nul
278
306
  * request or write never holds the other tabs. A login replaces the stored
279
307
  * pair and ends that pair's session. Rotation is strict at the relying
280
308
  * party: a refresh token spent once ends the session when it comes back, so
281
- * nothing here retries a claim or a refresh.
309
+ * nothing here retries a claim or a refresh. A code that reaches the client
310
+ * another way, such as a device pairing, is claimed through `createPkce` and
311
+ * `claimCode` under the same rules, without a window.
282
312
  *
283
313
  * It needs the Web Locks API and Web Crypto, both of them only in a secure
284
314
  * context, and has no fallback for either.
@@ -296,6 +326,8 @@ export class RelyingPartyBearerClient {
296
326
  readonly #now: () => number;
297
327
  /** the states of the logins received here; a login is received once */
298
328
  readonly #receivedStates = new Set<string>();
329
+ /** the challenges of the pairs `claimCode` claimed with here; a pair is claimed with once */
330
+ readonly #claimedChallenges = new Set<string>();
299
331
 
300
332
  constructor(optionsArg: IRelyingPartyBearerClientOptions) {
301
333
  const origin = optionsArg?.origin;
@@ -375,17 +407,63 @@ export class RelyingPartyBearerClient {
375
407
  if (!isText(returnTarget)) {
376
408
  throw new Error('returnTarget must name the configured return target');
377
409
  }
378
- const codeVerifier = randomBase64Url();
410
+ const { codeVerifier, codeChallenge } = await this.createPkce();
379
411
  const state = randomBase64Url();
412
+ const query: Required<IRelyingPartyBearerLoginQuery> = {
413
+ client_kind: clientKind,
414
+ return_target: returnTarget,
415
+ code_challenge: codeChallenge,
416
+ code_challenge_method: 'S256',
417
+ state,
418
+ };
380
419
  const url = new URL(this.#paths.login);
381
- url.searchParams.set('client_kind', clientKind);
382
- url.searchParams.set('return_target', returnTarget);
383
- url.searchParams.set('code_challenge', await pkceChallengeOf(codeVerifier));
384
- url.searchParams.set('code_challenge_method', 'S256');
385
- url.searchParams.set('state', state);
420
+ for (const [name, value] of Object.entries(query)) {
421
+ url.searchParams.set(name, value);
422
+ }
386
423
  return { clientKind, returnTarget, state, codeVerifier, url: url.href };
387
424
  }
388
425
 
426
+ /**
427
+ * A fresh PKCE pair: a verifier from 32 random bytes and its S256
428
+ * challenge. For a code the client does not receive through its own login,
429
+ * such as one the application delivers to a device it pairs: the challenge
430
+ * goes to the application, and `claimCode` claims the code with the pair.
431
+ * Needs no window.
432
+ */
433
+ public async createPkce(): Promise<IRelyingPartyBearerPkce> {
434
+ const codeVerifier = randomBase64Url();
435
+ return { codeVerifier, codeChallenge: await pkceChallengeOf(codeVerifier) };
436
+ }
437
+
438
+ /**
439
+ * Claims a hand-off code that was bound to `pkceArg`'s challenge and
440
+ * reached this client some other way than its own login, such as a device
441
+ * pairing (`openBearerHandoff` at the relying party). It is received like
442
+ * a login: one claim, never retried, under `requestTimeoutMs`, and the
443
+ * claimed pair replaces the stored one under the lock, whose session is
444
+ * ended then (`IRelyingPartyBearerLoginResult`). A pair is claimed with
445
+ * once: a second call with it, after a failed claim too, is
446
+ * `already_received` and sends nothing. A pair that is not one (a
447
+ * malformed verifier, a challenge that is not its S256) or a code that is
448
+ * not text is refused before anything is sent. Needs no window.
449
+ */
450
+ public async claimCode(pkceArg: IRelyingPartyBearerPkce, codeArg: string): Promise<IRelyingPartyBearerLoginResult> {
451
+ const codeVerifier: unknown = pkceArg?.codeVerifier;
452
+ const codeChallenge: unknown = pkceArg?.codeChallenge;
453
+ if (!isPkceVerifier(codeVerifier) || typeof codeChallenge !== 'string' || codeChallenge !== await pkceChallengeOf(codeVerifier)) {
454
+ throw new Error('claimCode needs a PKCE pair: a verifier and its S256 challenge, as createPkce makes them');
455
+ }
456
+ if (!isText(codeArg)) {
457
+ throw new Error('claimCode needs the hand-off code as text');
458
+ }
459
+ if (this.#claimedChallenges.has(codeChallenge)) {
460
+ throw new RelyingPartyBearerClientError('already_received');
461
+ }
462
+ // taken before the claim is sent: a failed claim uses the pair up too
463
+ this.#claimedChallenges.add(codeChallenge);
464
+ return this.claim(codeArg, codeVerifier);
465
+ }
466
+
389
467
  /**
390
468
  * Receives a `web` login on its return target page. The delivery leaves
391
469
  * the address first, before anything is checked, so neither a reload nor
@@ -548,9 +626,14 @@ export class RelyingPartyBearerClient {
548
626
  if (!isText(codeArg)) {
549
627
  throw new RelyingPartyBearerClientError('unexpected_answer');
550
628
  }
629
+ return this.claim(codeArg, loginArg.codeVerifier);
630
+ }
631
+
632
+ /** Claims a code once with its verifier and stores the pair in place of the one before. */
633
+ private async claim(codeArg: string, codeVerifierArg: string): Promise<IRelyingPartyBearerLoginResult> {
634
+ const body: IRelyingPartyBearerClaimRequest = { code: codeArg, codeVerifier: codeVerifierArg };
551
635
  // the claim touches no stored pair, so it holds no lock, only its own deadline; a failed one leaves the stored pair as it was
552
- const answer = tokenAnswerOf(await this.withDeadline((signalArg) =>
553
- this.post(this.#paths.claim, { code: codeArg, codeVerifier: loginArg.codeVerifier }, {}, signalArg)));
636
+ const answer = tokenAnswerOf(await this.withDeadline((signalArg) => this.post(this.#paths.claim, body, {}, signalArg)));
554
637
  if (!answer) {
555
638
  throw new RelyingPartyBearerClientError('unexpected_answer', { status: 200 });
556
639
  }
@@ -695,8 +778,9 @@ export class RelyingPartyBearerClient {
695
778
  return current;
696
779
  }
697
780
  let answer: IRelyingPartyBearerTokenAnswer | null;
781
+ const body: IRelyingPartyBearerRefreshRequest = { refreshToken: current.refreshToken };
698
782
  try {
699
- answer = tokenAnswerOf(await this.post(this.#paths.refresh, { refreshToken: current.refreshToken }, {}, signalArg));
783
+ answer = tokenAnswerOf(await this.post(this.#paths.refresh, body, {}, signalArg));
700
784
  } catch (error) {
701
785
  if (error instanceof RelyingPartyBearerClientError && error.answerError === 'invalid_bearer_grant') {
702
786
  // the session is over: this pair opens nothing any more
@@ -718,12 +802,8 @@ export class RelyingPartyBearerClient {
718
802
 
719
803
  /** Ends the session of a pair through the logout route and answers whether that ended a live one. */
720
804
  private async endSessionOf(pairArg: IRelyingPartyBearerStoredTokens, signalArg: AbortSignal): Promise<boolean> {
721
- const answer = await this.post(
722
- this.#paths.logout,
723
- { refreshToken: pairArg.refreshToken },
724
- { authorization: `Bearer ${pairArg.accessToken}` },
725
- signalArg,
726
- );
805
+ const body: IRelyingPartyBearerLogoutRequest = { refreshToken: pairArg.refreshToken };
806
+ const answer = await this.post(this.#paths.logout, body, { authorization: `Bearer ${pairArg.accessToken}` }, signalArg);
727
807
  if (!isRecord(answer) || typeof answer.endedLiveSession !== 'boolean') {
728
808
  throw new RelyingPartyBearerClientError('unexpected_answer', { status: 200 });
729
809
  }
@@ -772,7 +852,7 @@ export class RelyingPartyBearerClient {
772
852
  */
773
853
  private async post(
774
854
  urlArg: string,
775
- bodyArg: Record<string, string>,
855
+ bodyArg: TRelyingPartyBearerRequestBody,
776
856
  headersArg: Record<string, string> = {},
777
857
  signalArg?: AbortSignal,
778
858
  ): Promise<unknown> {
@@ -14,6 +14,8 @@ export type TRelyingPartyErrorCode =
14
14
  | 'invalid_bearer_handoff'
15
15
  /** a refresh token that is unknown, rotated, expired, or of a session that is over */
16
16
  | 'invalid_bearer_grant'
17
+ /** `openBearerHandoff` named an opener that is no live provider login: unknown, ended, expired, of an inactive principal, or without a provider login of its own */
18
+ | 'invalid_handoff_opener'
17
19
  /** a `sessionsOfPrincipal` cursor that no page of it answered */
18
20
  | 'invalid_sessions_cursor';
19
21
 
@@ -27,6 +29,7 @@ const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number
27
29
  return_target_not_allowed: 400,
28
30
  invalid_bearer_handoff: 400,
29
31
  invalid_bearer_grant: 400,
32
+ invalid_handoff_opener: 400,
30
33
  invalid_sessions_cursor: 400,
31
34
  };
32
35
 
@@ -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,