@idp.global/sdk 19.4.0 → 19.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +60 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +40 -3
- package/dist_ts_browser/classes.relyingpartybearerclient.js +67 -12
- package/dist_ts_relyingparty/classes.errors.d.ts +2 -0
- package/dist_ts_relyingparty/classes.errors.js +2 -1
- package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +69 -11
- package/dist_ts_relyingparty/classes.idpoidcprovider.js +127 -17
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +128 -18
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +334 -69
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +21 -4
- package/dist_ts_relyingparty/classes.rpmodels.js +35 -8
- package/dist_ts_relyingparty/contract.bearer.d.ts +37 -0
- package/dist_ts_relyingparty/contract.bearer.js +5 -1
- package/dist_ts_relyingparty/helpers.bearer.d.ts +2 -3
- package/dist_ts_relyingparty/helpers.bearer.js +4 -6
- package/dist_ts_relyingparty/helpers.cookies.d.ts +6 -0
- package/dist_ts_relyingparty/helpers.cookies.js +8 -1
- package/dist_ts_relyingparty/interfaces.d.ts +73 -3
- package/dist_ts_relyingparty/testing.js +8 -4
- package/package.json +3 -3
- package/readme.md +280 -34
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_browser/classes.relyingpartybearerclient.ts +98 -18
- package/ts_relyingparty/classes.errors.ts +3 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +183 -17
- package/ts_relyingparty/classes.relyingpartysessions.ts +393 -78
- package/ts_relyingparty/classes.rpmodels.ts +27 -4
- package/ts_relyingparty/contract.bearer.ts +46 -0
- package/ts_relyingparty/helpers.bearer.ts +3 -6
- package/ts_relyingparty/helpers.cookies.ts +13 -0
- package/ts_relyingparty/interfaces.ts +76 -7
- package/ts_relyingparty/testing.ts +7 -3
|
@@ -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 =
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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,
|
|
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
|
|
722
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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,
|