@idp.global/sdk 19.3.0 → 19.5.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 +66 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_browser/classes.idpclient.js +1 -1
- package/dist_ts_browser/classes.idprequests.js +1 -1
- package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +313 -0
- package/dist_ts_browser/classes.relyingpartybearerclient.js +646 -0
- package/dist_ts_browser/index.d.ts +1 -0
- package/dist_ts_browser/index.js +2 -1
- package/dist_ts_browser/plugins.js +1 -1
- package/dist_ts_browser/secret-delivery.js +1 -1
- package/dist_ts_relyingparty/classes.authorityobserver.d.ts +23 -0
- package/dist_ts_relyingparty/classes.authorityobserver.js +68 -13
- package/dist_ts_relyingparty/classes.errors.d.ts +5 -1
- package/dist_ts_relyingparty/classes.errors.js +3 -1
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +12 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +33 -6
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +161 -11
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +381 -55
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +6 -4
- package/dist_ts_relyingparty/classes.rpmodels.js +8 -6
- package/dist_ts_relyingparty/contract.bearer.d.ts +119 -0
- package/dist_ts_relyingparty/contract.bearer.js +13 -0
- package/dist_ts_relyingparty/helpers.bearer.d.ts +3 -4
- package/dist_ts_relyingparty/helpers.bearer.js +4 -6
- package/dist_ts_relyingparty/interfaces.d.ts +75 -26
- package/package.json +2 -1
- package/readme.md +363 -21
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_browser/classes.relyingpartybearerclient.ts +901 -0
- package/ts_browser/index.ts +1 -0
- package/ts_relyingparty/classes.authorityobserver.ts +80 -12
- package/ts_relyingparty/classes.errors.ts +7 -1
- package/ts_relyingparty/classes.relyingpartyroutes.ts +38 -7
- package/ts_relyingparty/classes.relyingpartysessions.ts +463 -65
- package/ts_relyingparty/classes.rpmodels.ts +7 -4
- package/ts_relyingparty/contract.bearer.ts +145 -0
- package/ts_relyingparty/helpers.bearer.ts +10 -14
- package/ts_relyingparty/interfaces.ts +93 -33
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPkceVerifier,
|
|
3
|
+
RELYING_PARTY_BEARER_MESSAGE_TYPE,
|
|
4
|
+
type IRelyingPartyBearerClaimRequest,
|
|
5
|
+
type IRelyingPartyBearerLoginQuery,
|
|
6
|
+
type IRelyingPartyBearerLogoutAnswer,
|
|
7
|
+
type IRelyingPartyBearerLogoutRequest,
|
|
8
|
+
type IRelyingPartyBearerRefreshRequest,
|
|
9
|
+
type IRelyingPartyBearerTokenAnswer,
|
|
10
|
+
} from '../ts_relyingparty/contract.bearer.js';
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
IRelyingPartyBearerClaimRequest,
|
|
14
|
+
IRelyingPartyBearerErrorAnswer,
|
|
15
|
+
IRelyingPartyBearerLoginQuery,
|
|
16
|
+
IRelyingPartyBearerLogoutAnswer,
|
|
17
|
+
IRelyingPartyBearerLogoutRequest,
|
|
18
|
+
IRelyingPartyBearerMessage,
|
|
19
|
+
IRelyingPartyBearerRefreshRequest,
|
|
20
|
+
IRelyingPartyBearerSessionAnswer,
|
|
21
|
+
IRelyingPartyBearerSessionInfo,
|
|
22
|
+
IRelyingPartyBearerTokenAnswer,
|
|
23
|
+
TRelyingPartyBearerDeliveryError,
|
|
24
|
+
TRelyingPartyBearerErrorCode,
|
|
25
|
+
} from '../ts_relyingparty/contract.bearer.js';
|
|
26
|
+
export { RELYING_PARTY_BEARER_MESSAGE_TYPE };
|
|
27
|
+
|
|
28
|
+
/** The client kinds a browser signs in as; native clients use `loopback` and `custom_scheme` themselves. */
|
|
29
|
+
export type TRelyingPartyBearerBrowserClientKind = 'web' | 'popup';
|
|
30
|
+
|
|
31
|
+
/** The pair a bearer client keeps: the relying party's answer and when it arrived. */
|
|
32
|
+
export interface IRelyingPartyBearerStoredTokens extends IRelyingPartyBearerTokenAnswer {
|
|
33
|
+
/** epoch milliseconds on this device's clock when the answer arrived; with `expiresIn` it dates the access token's end here */
|
|
34
|
+
receivedAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where the application keeps the token pair — IndexedDB, say — for every
|
|
39
|
+
* tab of its origin. The client keeps no pair in memory: it reads this store
|
|
40
|
+
* on every use and again inside the refresh lock, so a pair another tab
|
|
41
|
+
* rotated is seen at once. `set` and `clear` resolve once the change is
|
|
42
|
+
* durable; the client hands out a rotated pair only after `set` resolved.
|
|
43
|
+
*
|
|
44
|
+
* Each `set` and `clear` is given the signal of its operation's deadline
|
|
45
|
+
* (`requestTimeoutMs`). An adapter either honours it, so a write the signal
|
|
46
|
+
* aborted is not made and the call rejects, or completes synchronously, as
|
|
47
|
+
* one over `localStorage` does, so it is never late. The lock is never held
|
|
48
|
+
* past the deadline, whatever a write still does: an adapter that ignores the
|
|
49
|
+
* signal can finish one after another tab took the lock. A late `set`
|
|
50
|
+
* overwrites what that tab stored or cleared, and the pair it leaves can be
|
|
51
|
+
* spent or of an ended session; its refresh then ends the session
|
|
52
|
+
* (`refresh_token_reused`) or is refused. A late `clear` removes the pair that
|
|
53
|
+
* tab stored. Either way the user signs in again.
|
|
54
|
+
*/
|
|
55
|
+
export interface IRelyingPartyBearerTokenStorage {
|
|
56
|
+
get(): Promise<IRelyingPartyBearerStoredTokens | null>;
|
|
57
|
+
set(tokensArg: IRelyingPartyBearerStoredTokens, optionsArg: { signal: AbortSignal }): Promise<void>;
|
|
58
|
+
clear(optionsArg: { signal: AbortSignal }): Promise<void>;
|
|
59
|
+
}
|
|
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
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A login on its way: what `createLogin` made and the receiver needs back.
|
|
76
|
+
* A `web` login leaves the page, so the application keeps it until the
|
|
77
|
+
* return target page hands it to `completeRedirectLogin` — in
|
|
78
|
+
* `sessionStorage` or IndexedDB, never in a URL or a log: `codeVerifier` is
|
|
79
|
+
* the secret that makes the code worth anything.
|
|
80
|
+
*/
|
|
81
|
+
export interface IRelyingPartyBearerClientLogin {
|
|
82
|
+
clientKind: TRelyingPartyBearerBrowserClientKind;
|
|
83
|
+
returnTarget: string;
|
|
84
|
+
state: string;
|
|
85
|
+
codeVerifier: string;
|
|
86
|
+
/** the bearer login start: navigate to it (`web`) or open it (`popup`) */
|
|
87
|
+
url: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** What the client reads of a `message` event. */
|
|
91
|
+
export interface IRelyingPartyBearerMessageEvent {
|
|
92
|
+
readonly origin: string;
|
|
93
|
+
readonly source: unknown;
|
|
94
|
+
readonly data: unknown;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What the client reads of a popup it opened. */
|
|
98
|
+
export interface IRelyingPartyBearerPopup {
|
|
99
|
+
readonly closed: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The parts of `window` the login receivers use; `window` itself is one. */
|
|
103
|
+
export interface IRelyingPartyBearerClientWindow {
|
|
104
|
+
readonly location: { readonly href: string };
|
|
105
|
+
readonly history: {
|
|
106
|
+
readonly state: unknown;
|
|
107
|
+
replaceState(dataArg: unknown, unusedArg: string, urlArg?: string): void;
|
|
108
|
+
};
|
|
109
|
+
open(urlArg: string, targetArg?: string, featuresArg?: string): IRelyingPartyBearerPopup | null;
|
|
110
|
+
addEventListener(typeArg: 'message', listenerArg: (eventArg: IRelyingPartyBearerMessageEvent) => void): void;
|
|
111
|
+
removeEventListener(typeArg: 'message', listenerArg: (eventArg: IRelyingPartyBearerMessageEvent) => void): void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface IRelyingPartyBearerClientOptions {
|
|
115
|
+
/** the relying party's public origin, exactly: where its bearer routes and its popup page are served */
|
|
116
|
+
origin: string;
|
|
117
|
+
/** the prefix of the relying party's login routes; `/auth/` unless it mounts them elsewhere */
|
|
118
|
+
loginPathPrefix?: string;
|
|
119
|
+
storage: IRelyingPartyBearerTokenStorage;
|
|
120
|
+
/** the Web Lock every refresh, logout and store of a claimed pair holds; one name per pair in `storage` */
|
|
121
|
+
lockName?: string;
|
|
122
|
+
/** how long before its end on this device's clock an access token is rotated; at most half its lifetime */
|
|
123
|
+
refreshSkewMs?: number;
|
|
124
|
+
/**
|
|
125
|
+
* The deadline of each operation of the client: the claim of a login's
|
|
126
|
+
* code, a refresh with the write of its pair, the store of a claimed pair
|
|
127
|
+
* with the end of the session it replaced, and a logout. 30 seconds unless
|
|
128
|
+
* given, at most two minutes, since every tab of the origin waits on the
|
|
129
|
+
* lock meanwhile. When it fires the request and the storage write are
|
|
130
|
+
* aborted, a late answer is never stored and the lock is released.
|
|
131
|
+
*/
|
|
132
|
+
requestTimeoutMs?: number;
|
|
133
|
+
/** `navigator.locks` unless given */
|
|
134
|
+
locks?: LockManager;
|
|
135
|
+
/** `globalThis.fetch` unless given */
|
|
136
|
+
fetch?: typeof globalThis.fetch;
|
|
137
|
+
/** `window` unless given; only the login receivers need one */
|
|
138
|
+
window?: IRelyingPartyBearerClientWindow;
|
|
139
|
+
now?: () => number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface IRelyingPartyBearerPopupOptions {
|
|
143
|
+
/** how long the popup may take; ten minutes, the life of a login attempt, unless given */
|
|
144
|
+
timeoutMs?: number;
|
|
145
|
+
signal?: AbortSignal;
|
|
146
|
+
/** the `features` of `window.open`; never `noopener`, the popup posts to its opener */
|
|
147
|
+
windowFeatures?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What a login did to the pair stored before it. */
|
|
151
|
+
export type TRelyingPartyBearerPreviousSessionOutcome =
|
|
152
|
+
/** no pair was stored */
|
|
153
|
+
| 'no_previous_session'
|
|
154
|
+
/** the stored pair was of the session this login opened; there was nothing to end */
|
|
155
|
+
| 'same_session'
|
|
156
|
+
/** the logout route ended the previous session, or answered that it was over already */
|
|
157
|
+
| 'previous_session_ended'
|
|
158
|
+
/** the previous session could not be ended and lives on at the relying party until it expires; `previousSessionError` says why */
|
|
159
|
+
| 'previous_session_not_revoked';
|
|
160
|
+
|
|
161
|
+
/** A received login: the pair now stored, and what became of the session of the pair it replaced. */
|
|
162
|
+
export interface IRelyingPartyBearerLoginResult {
|
|
163
|
+
tokens: IRelyingPartyBearerStoredTokens;
|
|
164
|
+
previousSession: TRelyingPartyBearerPreviousSessionOutcome;
|
|
165
|
+
/** with `previous_session_not_revoked`: why the logout route did not end it */
|
|
166
|
+
previousSessionError?: RelyingPartyBearerClientError;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type TRelyingPartyBearerClientErrorCode =
|
|
170
|
+
/** the delivery's state is not the one of the login it came back to; nothing was claimed */
|
|
171
|
+
| 'state_mismatch'
|
|
172
|
+
/** the delivery carries no code; `deliveryError` says why */
|
|
173
|
+
| 'login_failed'
|
|
174
|
+
/** this login, or this PKCE pair, was received once already; a code is claimed once, never again */
|
|
175
|
+
| 'already_received'
|
|
176
|
+
/** the browser opened no popup */
|
|
177
|
+
| 'popup_blocked'
|
|
178
|
+
/** the popup was closed before it delivered */
|
|
179
|
+
| 'popup_closed'
|
|
180
|
+
/** the popup delivered nothing in time */
|
|
181
|
+
| 'popup_timeout'
|
|
182
|
+
/** the request got no answer (network, CORS, a redirect) or none before `requestTimeoutMs`; a claim or refresh may have been spent anyway */
|
|
183
|
+
| 'request_failed'
|
|
184
|
+
/** the relying party refused the request; `status` and `answerError` say how */
|
|
185
|
+
| 'request_refused'
|
|
186
|
+
/** an answer outside the bearer contract */
|
|
187
|
+
| 'unexpected_answer'
|
|
188
|
+
/** `logout()` cleared the pair, so this device is signed out, but the relying party did not confirm the session's end; `cause` says why */
|
|
189
|
+
| 'session_not_revoked';
|
|
190
|
+
|
|
191
|
+
export class RelyingPartyBearerClientError extends Error {
|
|
192
|
+
public readonly status?: number;
|
|
193
|
+
/** the `error` the relying party answered (`TRelyingPartyBearerErrorCode`) */
|
|
194
|
+
public readonly answerError?: string;
|
|
195
|
+
/** the `error` a delivery carried (`TRelyingPartyBearerDeliveryError`) */
|
|
196
|
+
public readonly deliveryError?: string;
|
|
197
|
+
|
|
198
|
+
constructor(
|
|
199
|
+
public readonly code: TRelyingPartyBearerClientErrorCode,
|
|
200
|
+
detailsArg: { status?: number; answerError?: string; deliveryError?: string; cause?: unknown } = {},
|
|
201
|
+
) {
|
|
202
|
+
super(code, detailsArg.cause !== undefined ? { cause: detailsArg.cause } : undefined);
|
|
203
|
+
this.name = 'RelyingPartyBearerClientError';
|
|
204
|
+
if (detailsArg.status !== undefined) this.status = detailsArg.status;
|
|
205
|
+
if (detailsArg.answerError !== undefined) this.answerError = detailsArg.answerError;
|
|
206
|
+
if (detailsArg.deliveryError !== undefined) this.deliveryError = detailsArg.deliveryError;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const DEFAULT_LOGIN_PATH_PREFIX = '/auth/';
|
|
211
|
+
const DEFAULT_REFRESH_SKEW_MS = 60_000;
|
|
212
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
213
|
+
/** Every tab of the origin waits on the lock while a section runs: two minutes at most. */
|
|
214
|
+
const MAX_REQUEST_TIMEOUT_MS = 120_000;
|
|
215
|
+
/** The life of a login attempt at the relying party (`LOGIN_ATTEMPT_MAX_AGE_SECONDS`). */
|
|
216
|
+
const DEFAULT_POPUP_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
217
|
+
const DEFAULT_POPUP_FEATURES = 'popup,width=480,height=720';
|
|
218
|
+
const POPUP_CLOSED_POLL_MS = 500;
|
|
219
|
+
/** A popup posts and closes at once; its message can arrive after the opener sees it closed. */
|
|
220
|
+
const POPUP_CLOSED_GRACE_MS = 1_000;
|
|
221
|
+
/** 32 random bytes: a 43-character verifier and state (RFC 7636 §4.1). */
|
|
222
|
+
const RANDOM_BYTES = 32;
|
|
223
|
+
const DELIVERY_PARAMS = ['code', 'error', 'state'] as const;
|
|
224
|
+
|
|
225
|
+
const base64Url = (bytesArg: Uint8Array): string => {
|
|
226
|
+
let binary = '';
|
|
227
|
+
for (const byte of bytesArg) binary += String.fromCharCode(byte);
|
|
228
|
+
return globalThis.btoa(binary).replace(/\+/gu, '-').replace(/\//gu, '_').replace(/=+$/u, '');
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const randomBase64Url = (): string => base64Url(globalThis.crypto.getRandomValues(new Uint8Array(RANDOM_BYTES)));
|
|
232
|
+
|
|
233
|
+
/** The S256 challenge of a verifier: BASE64URL(SHA-256(ASCII(verifier))). */
|
|
234
|
+
const pkceChallengeOf = async (verifierArg: string): Promise<string> =>
|
|
235
|
+
base64Url(new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifierArg))));
|
|
236
|
+
|
|
237
|
+
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> =>
|
|
238
|
+
typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
|
239
|
+
|
|
240
|
+
const isText = (valueArg: unknown): valueArg is string => typeof valueArg === 'string' && valueArg.length > 0;
|
|
241
|
+
|
|
242
|
+
const isTime = (valueArg: unknown): valueArg is number => typeof valueArg === 'number' && Number.isFinite(valueArg);
|
|
243
|
+
|
|
244
|
+
/** What the client posts to the bearer routes. */
|
|
245
|
+
type TRelyingPartyBearerRequestBody =
|
|
246
|
+
| IRelyingPartyBearerClaimRequest
|
|
247
|
+
| IRelyingPartyBearerRefreshRequest
|
|
248
|
+
| IRelyingPartyBearerLogoutRequest;
|
|
249
|
+
|
|
250
|
+
/** Throws `request_failed` once a section's deadline has fired: nothing is sent or written past it. */
|
|
251
|
+
const inTime = (signalArg: AbortSignal): void => {
|
|
252
|
+
if (signalArg.aborted) {
|
|
253
|
+
throw new RelyingPartyBearerClientError('request_failed');
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/** The one value of a parameter, or `undefined` when it is absent or repeated. */
|
|
258
|
+
const singleParam = (paramsArg: URLSearchParams, nameArg: string): string | undefined => {
|
|
259
|
+
const values = paramsArg.getAll(nameArg);
|
|
260
|
+
return values.length === 1 ? values[0] : undefined;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/** A token answer exactly as the bearer contract says, or `null`. */
|
|
264
|
+
const tokenAnswerOf = (answerArg: unknown): IRelyingPartyBearerTokenAnswer | null => {
|
|
265
|
+
if (
|
|
266
|
+
!isRecord(answerArg)
|
|
267
|
+
|| answerArg.tokenType !== 'Bearer'
|
|
268
|
+
|| !isText(answerArg.accessToken)
|
|
269
|
+
|| !isText(answerArg.refreshToken)
|
|
270
|
+
|| !isTime(answerArg.accessTokenExpiresAt)
|
|
271
|
+
|| !isTime(answerArg.expiresIn)
|
|
272
|
+
|| answerArg.expiresIn <= 0
|
|
273
|
+
|| !isTime(answerArg.refreshTokenExpiresAt)
|
|
274
|
+
|| !isRecord(answerArg.session)
|
|
275
|
+
|| !isText(answerArg.session.sessionId)
|
|
276
|
+
|| !isText(answerArg.session.principalId)
|
|
277
|
+
|| !isTime(answerArg.session.expiresAt)
|
|
278
|
+
) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
tokenType: 'Bearer',
|
|
283
|
+
accessToken: answerArg.accessToken,
|
|
284
|
+
accessTokenExpiresAt: answerArg.accessTokenExpiresAt,
|
|
285
|
+
expiresIn: answerArg.expiresIn,
|
|
286
|
+
refreshToken: answerArg.refreshToken,
|
|
287
|
+
refreshTokenExpiresAt: answerArg.refreshTokenExpiresAt,
|
|
288
|
+
session: {
|
|
289
|
+
sessionId: answerArg.session.sessionId,
|
|
290
|
+
principalId: answerArg.session.principalId,
|
|
291
|
+
expiresAt: answerArg.session.expiresAt,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The browser side of bearer hand-off (`@idp.global/sdk/relyingparty`
|
|
298
|
+
* `bearer`): it starts a `web` or `popup` login with PKCE and a state,
|
|
299
|
+
* receives the delivery, claims the code once and keeps the token pair in
|
|
300
|
+
* the application's storage. Every refresh, logout and store of a claimed
|
|
301
|
+
* pair holds one Web Lock, so the tabs of an origin rotate the pair one at a
|
|
302
|
+
* time: inside the lock the pair is read from storage again, a pair another
|
|
303
|
+
* tab rotated is used as it is, and a rotated pair is stored before the lock
|
|
304
|
+
* is released and before its access token is handed out. Each claim and
|
|
305
|
+
* each section that holds the lock ends by `requestTimeoutMs`, so a hanging
|
|
306
|
+
* request or write never holds the other tabs. A login replaces the stored
|
|
307
|
+
* pair and ends that pair's session. Rotation is strict at the relying
|
|
308
|
+
* party: a refresh token spent once ends the session when it comes back, so
|
|
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.
|
|
312
|
+
*
|
|
313
|
+
* It needs the Web Locks API and Web Crypto, both of them only in a secure
|
|
314
|
+
* context, and has no fallback for either.
|
|
315
|
+
*/
|
|
316
|
+
export class RelyingPartyBearerClient {
|
|
317
|
+
public readonly origin: string;
|
|
318
|
+
readonly #paths: { claim: string; refresh: string; logout: string; login: string };
|
|
319
|
+
readonly #storage: IRelyingPartyBearerTokenStorage;
|
|
320
|
+
readonly #lockName: string;
|
|
321
|
+
readonly #refreshSkewMs: number;
|
|
322
|
+
readonly #requestTimeoutMs: number;
|
|
323
|
+
readonly #locks: LockManager;
|
|
324
|
+
readonly #fetch: typeof globalThis.fetch;
|
|
325
|
+
readonly #window: IRelyingPartyBearerClientWindow | undefined;
|
|
326
|
+
readonly #now: () => number;
|
|
327
|
+
/** the states of the logins received here; a login is received once */
|
|
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>();
|
|
331
|
+
|
|
332
|
+
constructor(optionsArg: IRelyingPartyBearerClientOptions) {
|
|
333
|
+
const origin = optionsArg?.origin;
|
|
334
|
+
let parsedOrigin: URL | null = null;
|
|
335
|
+
try {
|
|
336
|
+
parsedOrigin = typeof origin === 'string' ? new URL(origin) : null;
|
|
337
|
+
} catch {
|
|
338
|
+
parsedOrigin = null;
|
|
339
|
+
}
|
|
340
|
+
if (!parsedOrigin || parsedOrigin.origin !== origin || !['https:', 'http:'].includes(parsedOrigin.protocol)) {
|
|
341
|
+
throw new Error('origin must be the relying party\'s exact origin, such as https://app.example');
|
|
342
|
+
}
|
|
343
|
+
const prefix = optionsArg.loginPathPrefix ?? DEFAULT_LOGIN_PATH_PREFIX;
|
|
344
|
+
if (typeof prefix !== 'string' || !/^\/(?:[A-Za-z0-9._~-]+\/)*$/u.test(prefix)) {
|
|
345
|
+
throw new Error('loginPathPrefix must be a path that starts and ends with /');
|
|
346
|
+
}
|
|
347
|
+
const storage = optionsArg.storage;
|
|
348
|
+
if (!storage || typeof storage.get !== 'function' || typeof storage.set !== 'function' || typeof storage.clear !== 'function') {
|
|
349
|
+
throw new Error('storage must have get, set and clear');
|
|
350
|
+
}
|
|
351
|
+
const locks = optionsArg.locks ?? globalThis.navigator?.locks;
|
|
352
|
+
if (!locks || typeof locks.request !== 'function') {
|
|
353
|
+
throw new Error('RelyingPartyBearerClient needs the Web Locks API (navigator.locks)');
|
|
354
|
+
}
|
|
355
|
+
if (typeof globalThis.crypto?.subtle?.digest !== 'function' || typeof globalThis.crypto.getRandomValues !== 'function') {
|
|
356
|
+
throw new Error('RelyingPartyBearerClient needs Web Crypto (crypto.subtle), which a browser offers only in a secure context');
|
|
357
|
+
}
|
|
358
|
+
const fetchFunction = optionsArg.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
359
|
+
if (typeof fetchFunction !== 'function') {
|
|
360
|
+
throw new Error('RelyingPartyBearerClient needs fetch');
|
|
361
|
+
}
|
|
362
|
+
const refreshSkewMs = optionsArg.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS;
|
|
363
|
+
if (!Number.isInteger(refreshSkewMs) || refreshSkewMs < 0) {
|
|
364
|
+
throw new Error('refreshSkewMs must be a whole number of milliseconds');
|
|
365
|
+
}
|
|
366
|
+
// only an absent deadline takes the default: any other value that is not one throws
|
|
367
|
+
const requestTimeoutMs = optionsArg.requestTimeoutMs === undefined ? DEFAULT_REQUEST_TIMEOUT_MS : optionsArg.requestTimeoutMs;
|
|
368
|
+
if (
|
|
369
|
+
typeof requestTimeoutMs !== 'number'
|
|
370
|
+
|| !Number.isFinite(requestTimeoutMs)
|
|
371
|
+
|| requestTimeoutMs <= 0
|
|
372
|
+
|| requestTimeoutMs > MAX_REQUEST_TIMEOUT_MS
|
|
373
|
+
) {
|
|
374
|
+
throw new Error(`requestTimeoutMs must be more than 0 and at most ${MAX_REQUEST_TIMEOUT_MS} milliseconds`);
|
|
375
|
+
}
|
|
376
|
+
this.origin = origin;
|
|
377
|
+
this.#paths = {
|
|
378
|
+
login: `${origin}${prefix}bearer/login`,
|
|
379
|
+
claim: `${origin}${prefix}bearer/claim`,
|
|
380
|
+
refresh: `${origin}${prefix}bearer/refresh`,
|
|
381
|
+
logout: `${origin}${prefix}bearer/logout`,
|
|
382
|
+
};
|
|
383
|
+
this.#storage = storage;
|
|
384
|
+
this.#lockName = optionsArg.lockName ?? `idp.global/relyingparty:bearer-tokens ${origin}`;
|
|
385
|
+
this.#refreshSkewMs = refreshSkewMs;
|
|
386
|
+
this.#requestTimeoutMs = requestTimeoutMs;
|
|
387
|
+
this.#locks = locks;
|
|
388
|
+
this.#fetch = fetchFunction;
|
|
389
|
+
this.#window = optionsArg.window ?? (globalThis as { window?: IRelyingPartyBearerClientWindow }).window;
|
|
390
|
+
this.#now = optionsArg.now ?? Date.now;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A new login: a fresh verifier, its S256 challenge and a state, each from
|
|
395
|
+
* 32 random bytes, and the URL that starts it. Make a popup login before
|
|
396
|
+
* the click that opens it: this is async, and a browser opens a popup only
|
|
397
|
+
* within the user's gesture.
|
|
398
|
+
*/
|
|
399
|
+
public async createLogin(optionsArg: {
|
|
400
|
+
clientKind: TRelyingPartyBearerBrowserClientKind;
|
|
401
|
+
returnTarget: string;
|
|
402
|
+
}): Promise<IRelyingPartyBearerClientLogin> {
|
|
403
|
+
const { clientKind, returnTarget } = optionsArg ?? ({} as typeof optionsArg);
|
|
404
|
+
if (clientKind !== 'web' && clientKind !== 'popup') {
|
|
405
|
+
throw new Error('a browser signs in as a web or popup client');
|
|
406
|
+
}
|
|
407
|
+
if (!isText(returnTarget)) {
|
|
408
|
+
throw new Error('returnTarget must name the configured return target');
|
|
409
|
+
}
|
|
410
|
+
const { codeVerifier, codeChallenge } = await this.createPkce();
|
|
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
|
+
};
|
|
419
|
+
const url = new URL(this.#paths.login);
|
|
420
|
+
for (const [name, value] of Object.entries(query)) {
|
|
421
|
+
url.searchParams.set(name, value);
|
|
422
|
+
}
|
|
423
|
+
return { clientKind, returnTarget, state, codeVerifier, url: url.href };
|
|
424
|
+
}
|
|
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
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Receives a `web` login on its return target page. The delivery leaves
|
|
469
|
+
* the address first, before anything is checked, so neither a reload nor
|
|
470
|
+
* the history holds the code. A delivery whose state is not the login's is
|
|
471
|
+
* refused unclaimed; a code is claimed once, and a failed claim is not
|
|
472
|
+
* retried. The claimed pair replaces the stored one, whose session is
|
|
473
|
+
* ended then (`IRelyingPartyBearerLoginResult`). Answers `null` on a page
|
|
474
|
+
* that holds no delivery.
|
|
475
|
+
*/
|
|
476
|
+
public async completeRedirectLogin(
|
|
477
|
+
loginArg: IRelyingPartyBearerClientLogin | null | undefined,
|
|
478
|
+
): Promise<IRelyingPartyBearerLoginResult | null> {
|
|
479
|
+
const window = this.requireWindow();
|
|
480
|
+
const url = new URL(window.location.href);
|
|
481
|
+
if (!DELIVERY_PARAMS.some((nameArg) => url.searchParams.has(nameArg))) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
const delivered = url.searchParams;
|
|
485
|
+
const code = singleParam(delivered, 'code');
|
|
486
|
+
const error = singleParam(delivered, 'error');
|
|
487
|
+
const state = singleParam(delivered, 'state');
|
|
488
|
+
const kept = new URL(url.href);
|
|
489
|
+
for (const name of DELIVERY_PARAMS) {
|
|
490
|
+
kept.searchParams.delete(name);
|
|
491
|
+
}
|
|
492
|
+
window.history.replaceState(window.history.state, '', kept.pathname + kept.search + kept.hash);
|
|
493
|
+
if (!loginArg || loginArg.clientKind !== 'web' || state === undefined || state !== loginArg.state) {
|
|
494
|
+
throw new RelyingPartyBearerClientError('state_mismatch');
|
|
495
|
+
}
|
|
496
|
+
return this.receive(loginArg, code, error);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Opens a `popup` login and receives what its page posts: a message is
|
|
501
|
+
* taken only from the relying party's origin, from this very popup, of the
|
|
502
|
+
* bearer message type and with the login's state; any other is ignored.
|
|
503
|
+
* The popup opens before this returns, so call it in the click handler
|
|
504
|
+
* itself, with a login made beforehand. The claimed pair replaces the
|
|
505
|
+
* stored one like `completeRedirectLogin`'s.
|
|
506
|
+
*/
|
|
507
|
+
public openPopupLogin(
|
|
508
|
+
loginArg: IRelyingPartyBearerClientLogin,
|
|
509
|
+
optionsArg: IRelyingPartyBearerPopupOptions = {},
|
|
510
|
+
): Promise<IRelyingPartyBearerLoginResult> {
|
|
511
|
+
let window: IRelyingPartyBearerClientWindow;
|
|
512
|
+
try {
|
|
513
|
+
window = this.requireWindow();
|
|
514
|
+
if (!loginArg || loginArg.clientKind !== 'popup') {
|
|
515
|
+
throw new Error('openPopupLogin needs a popup login');
|
|
516
|
+
}
|
|
517
|
+
if (this.#receivedStates.has(loginArg.state)) {
|
|
518
|
+
throw new RelyingPartyBearerClientError('already_received');
|
|
519
|
+
}
|
|
520
|
+
} catch (error) {
|
|
521
|
+
return Promise.reject(error);
|
|
522
|
+
}
|
|
523
|
+
let popup: IRelyingPartyBearerPopup | null;
|
|
524
|
+
try {
|
|
525
|
+
popup = window.open(loginArg.url, '_blank', optionsArg.windowFeatures ?? DEFAULT_POPUP_FEATURES);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
return Promise.reject(error);
|
|
528
|
+
}
|
|
529
|
+
if (!popup) {
|
|
530
|
+
return Promise.reject(new RelyingPartyBearerClientError('popup_blocked'));
|
|
531
|
+
}
|
|
532
|
+
return this.receivePopup(window, popup, loginArg, optionsArg);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* An access token to send right now, or `null` when there is no session.
|
|
537
|
+
* One that is about to end is rotated first, by one tab for all of them.
|
|
538
|
+
*/
|
|
539
|
+
public async accessToken(): Promise<string | null> {
|
|
540
|
+
const stored = await this.#storage.get();
|
|
541
|
+
if (!stored) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
if (this.isFresh(stored)) {
|
|
545
|
+
return stored.accessToken;
|
|
546
|
+
}
|
|
547
|
+
return (await this.rotate(stored.refreshToken, null))?.accessToken ?? null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Rotates the pair now — after the relying party refused
|
|
552
|
+
* `rejectedAccessTokenArg`, say — unless another tab already did, and
|
|
553
|
+
* answers the pair to use: `null` once the session is over, which clears
|
|
554
|
+
* the storage. A relying party that cannot refresh right now
|
|
555
|
+
* (`authority_unavailable`) keeps the pair as it is; try again later.
|
|
556
|
+
*/
|
|
557
|
+
public async refresh(rejectedAccessTokenArg?: string): Promise<IRelyingPartyBearerStoredTokens | null> {
|
|
558
|
+
const stored = await this.#storage.get();
|
|
559
|
+
if (!stored) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
return this.rotate(stored.refreshToken, rejectedAccessTokenArg ?? null);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Clears the storage and then ends the session at the relying party with
|
|
567
|
+
* the pair it held, and answers whether that ended a live session. When the
|
|
568
|
+
* relying party cannot be reached, refuses or gives no answer by
|
|
569
|
+
* `requestTimeoutMs`, the call throws `session_not_revoked` with that
|
|
570
|
+
* failure as its `cause`: this device is signed out, and the session may
|
|
571
|
+
* live on at the relying party until it expires, so tell the user they are
|
|
572
|
+
* signed out on this device only. A deadline that fires before the storage
|
|
573
|
+
* is cleared throws `request_failed`, and the pair stays.
|
|
574
|
+
*/
|
|
575
|
+
public async logout(): Promise<boolean> {
|
|
576
|
+
let cleared = false;
|
|
577
|
+
const notRevoked = (errorArg: unknown) => new RelyingPartyBearerClientError('session_not_revoked', { cause: errorArg });
|
|
578
|
+
return this.#locks.request(this.#lockName, () => this.withDeadline(async (signalArg) => {
|
|
579
|
+
const current = await this.#storage.get();
|
|
580
|
+
if (!current) {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
inTime(signalArg);
|
|
584
|
+
await this.#storage.clear({ signal: signalArg });
|
|
585
|
+
cleared = true;
|
|
586
|
+
try {
|
|
587
|
+
return await this.endSessionOf(current, signalArg);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
throw notRevoked(error);
|
|
590
|
+
}
|
|
591
|
+
}, () => {
|
|
592
|
+
// once the pair is cleared this device is signed out, whatever the request still does
|
|
593
|
+
const failure = new RelyingPartyBearerClientError('request_failed');
|
|
594
|
+
throw cleared ? notRevoked(failure) : failure;
|
|
595
|
+
}));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private requireWindow(): IRelyingPartyBearerClientWindow {
|
|
599
|
+
if (!this.#window) {
|
|
600
|
+
throw new Error('the login receivers of RelyingPartyBearerClient need a window');
|
|
601
|
+
}
|
|
602
|
+
return this.#window;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private isFresh(storedArg: IRelyingPartyBearerStoredTokens): boolean {
|
|
606
|
+
const lifetimeMs = storedArg.expiresIn * 1_000;
|
|
607
|
+
const skewMs = Math.min(this.#refreshSkewMs, lifetimeMs / 2);
|
|
608
|
+
return this.#now() < storedArg.receivedAt + lifetimeMs - skewMs;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Takes a delivery for its login, once, claims its code and stores the pair in place of the one before. */
|
|
612
|
+
private async receive(
|
|
613
|
+
loginArg: IRelyingPartyBearerClientLogin,
|
|
614
|
+
codeArg: unknown,
|
|
615
|
+
errorArg: unknown,
|
|
616
|
+
): Promise<IRelyingPartyBearerLoginResult> {
|
|
617
|
+
if (this.#receivedStates.has(loginArg.state)) {
|
|
618
|
+
throw new RelyingPartyBearerClientError('already_received');
|
|
619
|
+
}
|
|
620
|
+
this.#receivedStates.add(loginArg.state);
|
|
621
|
+
if (errorArg !== undefined) {
|
|
622
|
+
throw new RelyingPartyBearerClientError(isText(errorArg) && codeArg === undefined ? 'login_failed' : 'unexpected_answer', {
|
|
623
|
+
...(isText(errorArg) ? { deliveryError: errorArg } : {}),
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
if (!isText(codeArg)) {
|
|
627
|
+
throw new RelyingPartyBearerClientError('unexpected_answer');
|
|
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 };
|
|
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
|
|
636
|
+
const answer = tokenAnswerOf(await this.withDeadline((signalArg) => this.post(this.#paths.claim, body, {}, signalArg)));
|
|
637
|
+
if (!answer) {
|
|
638
|
+
throw new RelyingPartyBearerClientError('unexpected_answer', { status: 200 });
|
|
639
|
+
}
|
|
640
|
+
return this.replacePair({ ...answer, receivedAt: this.#now() });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Stores a claimed pair in place of the stored one and then ends the
|
|
645
|
+
* session of that one with its tokens, held here, through the logout
|
|
646
|
+
* route. Both run under the lock a refresh holds, so no tab rotates the
|
|
647
|
+
* replaced pair meanwhile. A session that cannot be ended is the result's
|
|
648
|
+
* `previous_session_not_revoked`, neither thrown nor dropped: the new pair
|
|
649
|
+
* is stored by then.
|
|
650
|
+
*/
|
|
651
|
+
private replacePair(tokensArg: IRelyingPartyBearerStoredTokens): Promise<IRelyingPartyBearerLoginResult> {
|
|
652
|
+
/** what the login answers if the deadline fires now: nothing before the pair is stored */
|
|
653
|
+
let atDeadline: IRelyingPartyBearerLoginResult | null = null;
|
|
654
|
+
const notRevoked = (errorArg: unknown): IRelyingPartyBearerLoginResult => ({
|
|
655
|
+
tokens: tokensArg,
|
|
656
|
+
previousSession: 'previous_session_not_revoked',
|
|
657
|
+
previousSessionError: errorArg instanceof RelyingPartyBearerClientError
|
|
658
|
+
? errorArg
|
|
659
|
+
: new RelyingPartyBearerClientError('unexpected_answer', { cause: errorArg }),
|
|
660
|
+
});
|
|
661
|
+
return this.#locks.request(this.#lockName, () => this.withDeadline<IRelyingPartyBearerLoginResult>(async (signalArg) => {
|
|
662
|
+
const previous = await this.#storage.get();
|
|
663
|
+
inTime(signalArg);
|
|
664
|
+
await this.#storage.set(tokensArg, { signal: signalArg });
|
|
665
|
+
if (!previous) {
|
|
666
|
+
return { tokens: tokensArg, previousSession: 'no_previous_session' };
|
|
667
|
+
}
|
|
668
|
+
if (previous.session.sessionId === tokensArg.session.sessionId) {
|
|
669
|
+
return { tokens: tokensArg, previousSession: 'same_session' };
|
|
670
|
+
}
|
|
671
|
+
atDeadline = notRevoked(new RelyingPartyBearerClientError('request_failed'));
|
|
672
|
+
try {
|
|
673
|
+
await this.endSessionOf(previous, signalArg);
|
|
674
|
+
return { tokens: tokensArg, previousSession: 'previous_session_ended' };
|
|
675
|
+
} catch (error) {
|
|
676
|
+
return notRevoked(error);
|
|
677
|
+
}
|
|
678
|
+
}, () => {
|
|
679
|
+
// past its write the login stands; only the end of the previous session is cut short
|
|
680
|
+
if (!atDeadline) {
|
|
681
|
+
throw new RelyingPartyBearerClientError('request_failed');
|
|
682
|
+
}
|
|
683
|
+
return atDeadline;
|
|
684
|
+
}));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private receivePopup(
|
|
688
|
+
windowArg: IRelyingPartyBearerClientWindow,
|
|
689
|
+
popupArg: IRelyingPartyBearerPopup,
|
|
690
|
+
loginArg: IRelyingPartyBearerClientLogin,
|
|
691
|
+
optionsArg: IRelyingPartyBearerPopupOptions,
|
|
692
|
+
): Promise<IRelyingPartyBearerLoginResult> {
|
|
693
|
+
return new Promise<IRelyingPartyBearerLoginResult>((resolve, reject) => {
|
|
694
|
+
const cleanups: Array<() => void> = [];
|
|
695
|
+
let settled = false;
|
|
696
|
+
const settle = (actionArg: () => void) => {
|
|
697
|
+
if (settled) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
settled = true;
|
|
701
|
+
for (const cleanup of cleanups) {
|
|
702
|
+
cleanup();
|
|
703
|
+
}
|
|
704
|
+
actionArg();
|
|
705
|
+
};
|
|
706
|
+
const fail = (errorArg: unknown) => settle(() => reject(errorArg));
|
|
707
|
+
|
|
708
|
+
const onMessage = (eventArg: IRelyingPartyBearerMessageEvent) => {
|
|
709
|
+
const data: unknown = eventArg?.data;
|
|
710
|
+
if (
|
|
711
|
+
eventArg?.origin !== this.origin
|
|
712
|
+
|| eventArg.source !== popupArg
|
|
713
|
+
|| !isRecord(data)
|
|
714
|
+
|| data.type !== RELYING_PARTY_BEARER_MESSAGE_TYPE
|
|
715
|
+
|| data.state !== loginArg.state
|
|
716
|
+
) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
settle(() => {
|
|
720
|
+
this.receive(loginArg, data.code, data.error).then(resolve, reject);
|
|
721
|
+
});
|
|
722
|
+
};
|
|
723
|
+
windowArg.addEventListener('message', onMessage);
|
|
724
|
+
cleanups.push(() => windowArg.removeEventListener('message', onMessage));
|
|
725
|
+
|
|
726
|
+
const timeout = setTimeout(
|
|
727
|
+
() => fail(new RelyingPartyBearerClientError('popup_timeout')),
|
|
728
|
+
optionsArg.timeoutMs ?? DEFAULT_POPUP_TIMEOUT_MS,
|
|
729
|
+
);
|
|
730
|
+
cleanups.push(() => clearTimeout(timeout));
|
|
731
|
+
let closedGrace: ReturnType<typeof setTimeout> | undefined;
|
|
732
|
+
const closedPoll = setInterval(() => {
|
|
733
|
+
if (popupArg.closed && closedGrace === undefined) {
|
|
734
|
+
closedGrace = setTimeout(() => fail(new RelyingPartyBearerClientError('popup_closed')), POPUP_CLOSED_GRACE_MS);
|
|
735
|
+
}
|
|
736
|
+
}, POPUP_CLOSED_POLL_MS);
|
|
737
|
+
cleanups.push(() => {
|
|
738
|
+
clearInterval(closedPoll);
|
|
739
|
+
if (closedGrace !== undefined) {
|
|
740
|
+
clearTimeout(closedGrace);
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
const signal = optionsArg.signal;
|
|
745
|
+
if (signal) {
|
|
746
|
+
if (signal.aborted) {
|
|
747
|
+
fail(signal.reason);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const onAbort = () => fail(signal.reason);
|
|
751
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
752
|
+
cleanups.push(() => signal.removeEventListener('abort', onAbort));
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* One rotation under the lock. The pair is read again inside it: one
|
|
759
|
+
* whose refresh token is no longer `seenRefreshTokenArg`, or whose access
|
|
760
|
+
* token is no longer `rejectedAccessTokenArg`, was rotated already and is
|
|
761
|
+
* used as it is. A rotated pair is stored before the lock is released. The
|
|
762
|
+
* whole section has one deadline: past it, the lock is released with
|
|
763
|
+
* `request_failed` and an answer that still arrives is dropped unstored.
|
|
764
|
+
*/
|
|
765
|
+
private rotate(
|
|
766
|
+
seenRefreshTokenArg: string,
|
|
767
|
+
rejectedAccessTokenArg: string | null,
|
|
768
|
+
): Promise<IRelyingPartyBearerStoredTokens | null> {
|
|
769
|
+
return this.#locks.request(this.#lockName, () => this.withDeadline(async (signalArg) => {
|
|
770
|
+
const current = await this.#storage.get();
|
|
771
|
+
if (!current) {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
if (
|
|
775
|
+
current.refreshToken !== seenRefreshTokenArg
|
|
776
|
+
|| (rejectedAccessTokenArg !== null && current.accessToken !== rejectedAccessTokenArg)
|
|
777
|
+
) {
|
|
778
|
+
return current;
|
|
779
|
+
}
|
|
780
|
+
let answer: IRelyingPartyBearerTokenAnswer | null;
|
|
781
|
+
const body: IRelyingPartyBearerRefreshRequest = { refreshToken: current.refreshToken };
|
|
782
|
+
try {
|
|
783
|
+
answer = tokenAnswerOf(await this.post(this.#paths.refresh, body, {}, signalArg));
|
|
784
|
+
} catch (error) {
|
|
785
|
+
if (error instanceof RelyingPartyBearerClientError && error.answerError === 'invalid_bearer_grant') {
|
|
786
|
+
// the session is over: this pair opens nothing any more
|
|
787
|
+
inTime(signalArg);
|
|
788
|
+
await this.#storage.clear({ signal: signalArg });
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
throw error;
|
|
792
|
+
}
|
|
793
|
+
if (!answer) {
|
|
794
|
+
throw new RelyingPartyBearerClientError('unexpected_answer', { status: 200 });
|
|
795
|
+
}
|
|
796
|
+
const stored: IRelyingPartyBearerStoredTokens = { ...answer, receivedAt: this.#now() };
|
|
797
|
+
inTime(signalArg);
|
|
798
|
+
await this.#storage.set(stored, { signal: signalArg });
|
|
799
|
+
return stored;
|
|
800
|
+
}));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Ends the session of a pair through the logout route and answers whether that ended a live one. */
|
|
804
|
+
private async endSessionOf(pairArg: IRelyingPartyBearerStoredTokens, signalArg: AbortSignal): Promise<boolean> {
|
|
805
|
+
const body: IRelyingPartyBearerLogoutRequest = { refreshToken: pairArg.refreshToken };
|
|
806
|
+
const answer = await this.post(this.#paths.logout, body, { authorization: `Bearer ${pairArg.accessToken}` }, signalArg);
|
|
807
|
+
if (!isRecord(answer) || typeof answer.endedLiveSession !== 'boolean') {
|
|
808
|
+
throw new RelyingPartyBearerClientError('unexpected_answer', { status: 200 });
|
|
809
|
+
}
|
|
810
|
+
return (answer as unknown as IRelyingPartyBearerLogoutAnswer).endedLiveSession;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Runs one operation, a claim or a section that holds the lock, under
|
|
815
|
+
* `requestTimeoutMs`. When the deadline fires, the section's signal aborts
|
|
816
|
+
* its request and its storage write and keeps it from writing anything
|
|
817
|
+
* more (`inTime`), and the call answers `onDeadlineArg()` at once, so the
|
|
818
|
+
* lock is released whatever the request or the storage still does.
|
|
819
|
+
*/
|
|
820
|
+
private async withDeadline<T>(
|
|
821
|
+
sectionArg: (signalArg: AbortSignal) => Promise<T>,
|
|
822
|
+
onDeadlineArg: () => T = () => {
|
|
823
|
+
throw new RelyingPartyBearerClientError('request_failed');
|
|
824
|
+
},
|
|
825
|
+
): Promise<T> {
|
|
826
|
+
const controller = new AbortController();
|
|
827
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
828
|
+
const deadline = new Promise<T>((resolve, reject) => {
|
|
829
|
+
timer = setTimeout(() => {
|
|
830
|
+
controller.abort(new RelyingPartyBearerClientError('request_failed'));
|
|
831
|
+
try {
|
|
832
|
+
resolve(onDeadlineArg());
|
|
833
|
+
} catch (error) {
|
|
834
|
+
reject(error);
|
|
835
|
+
}
|
|
836
|
+
}, this.#requestTimeoutMs);
|
|
837
|
+
});
|
|
838
|
+
const section = sectionArg(controller.signal);
|
|
839
|
+
// past the deadline the section's own end, its aborted request among them, has nobody left to answer
|
|
840
|
+
section.catch(() => undefined);
|
|
841
|
+
try {
|
|
842
|
+
return await Promise.race([section, deadline]);
|
|
843
|
+
} finally {
|
|
844
|
+
clearTimeout(timer);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* A bearer API call: JSON in, JSON out, no cookie, no cache, no redirect.
|
|
850
|
+
* With `signalArg` none is sent past the deadline and an answer that
|
|
851
|
+
* arrives after it counts as none.
|
|
852
|
+
*/
|
|
853
|
+
private async post(
|
|
854
|
+
urlArg: string,
|
|
855
|
+
bodyArg: TRelyingPartyBearerRequestBody,
|
|
856
|
+
headersArg: Record<string, string> = {},
|
|
857
|
+
signalArg?: AbortSignal,
|
|
858
|
+
): Promise<unknown> {
|
|
859
|
+
if (signalArg) {
|
|
860
|
+
inTime(signalArg);
|
|
861
|
+
}
|
|
862
|
+
let response: Response;
|
|
863
|
+
try {
|
|
864
|
+
response = await this.#fetch(urlArg, {
|
|
865
|
+
method: 'POST',
|
|
866
|
+
headers: { 'content-type': 'application/json', ...headersArg },
|
|
867
|
+
body: JSON.stringify(bodyArg),
|
|
868
|
+
credentials: 'omit',
|
|
869
|
+
cache: 'no-store',
|
|
870
|
+
redirect: 'error',
|
|
871
|
+
referrerPolicy: 'no-referrer',
|
|
872
|
+
...(signalArg ? { signal: signalArg } : {}),
|
|
873
|
+
});
|
|
874
|
+
} catch (error) {
|
|
875
|
+
throw new RelyingPartyBearerClientError('request_failed', { cause: error });
|
|
876
|
+
}
|
|
877
|
+
let answer: unknown;
|
|
878
|
+
try {
|
|
879
|
+
answer = await response.json();
|
|
880
|
+
} catch {
|
|
881
|
+
// the parse error is not kept: its message quotes the body
|
|
882
|
+
answer = undefined;
|
|
883
|
+
if (signalArg) {
|
|
884
|
+
inTime(signalArg);
|
|
885
|
+
}
|
|
886
|
+
if (response.status === 200) {
|
|
887
|
+
throw new RelyingPartyBearerClientError('unexpected_answer', { status: 200 });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (signalArg) {
|
|
891
|
+
inTime(signalArg);
|
|
892
|
+
}
|
|
893
|
+
if (response.status !== 200) {
|
|
894
|
+
throw new RelyingPartyBearerClientError('request_refused', {
|
|
895
|
+
status: response.status,
|
|
896
|
+
...(isRecord(answer) && isText(answer.error) ? { answerError: answer.error } : {}),
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
return answer;
|
|
900
|
+
}
|
|
901
|
+
}
|