@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
package/ts_browser/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AuthorityTokenError } from './classes.errors.js';
|
|
2
2
|
import { OidcAuthorityUnavailableError, type IOidcProvider } from './classes.idpoidcprovider.js';
|
|
3
|
-
import type { IAuthorityTokenSource } from './interfaces.js';
|
|
3
|
+
import type { IAuthorityTokenSource, IProviderToken } from './interfaces.js';
|
|
4
4
|
|
|
5
5
|
/** Why an observation refused: no membership at all, or roles the application maps to nothing. */
|
|
6
6
|
export type TAuthorityRefusalReason = 'idp_membership_absent' | 'idp_roles_unmapped';
|
|
@@ -58,6 +58,8 @@ export interface IAuthorityObserverOptions<TGrant> {
|
|
|
58
58
|
const DEFAULT_MAX_ENTRIES = 2_000;
|
|
59
59
|
/** how much room an eviction makes, so it does not run on every insert */
|
|
60
60
|
const EVICTION_HEADROOM = 0.1;
|
|
61
|
+
/** a session's token is rotated for an inactive answer at most once in this long */
|
|
62
|
+
export const AUTHORITY_FORCED_REFRESH_INTERVAL_MS = 60_000;
|
|
61
63
|
|
|
62
64
|
interface ICachedObservation<TGrant> {
|
|
63
65
|
outcome: 'active' | 'inactive';
|
|
@@ -72,6 +74,14 @@ interface ICachedObservation<TGrant> {
|
|
|
72
74
|
reported: boolean;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
/** The one rotation of a session's token that an inactive answer forced. */
|
|
78
|
+
interface IForcedRefresh {
|
|
79
|
+
startedAt: number;
|
|
80
|
+
/** where its start falls among the observer's inactive answers */
|
|
81
|
+
sequence: number;
|
|
82
|
+
token: Promise<IProviderToken>;
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
const assertTtl = (valueArg: number, labelArg: string): number => {
|
|
76
86
|
if (!Number.isSafeInteger(valueArg) || valueArg < 0) {
|
|
77
87
|
throw new Error(labelArg + ' must be a non-negative integer of milliseconds');
|
|
@@ -89,11 +99,28 @@ const assertTtl = (valueArg: number, labelArg: string): number => {
|
|
|
89
99
|
* every attempt while the provider is unreachable. A session that is forgotten
|
|
90
100
|
* while its observation is in flight never stores a positive answer. Nothing
|
|
91
101
|
* runs on a timer: the cache is pruned when it is written to.
|
|
102
|
+
*
|
|
103
|
+
* idp.global answers inactive both for a login that ended and for a
|
|
104
|
+
* membership that is gone, so an inactive answer rotates the session's token
|
|
105
|
+
* (`rejectedAccessToken`) before it counts. A refused rotation is the ended
|
|
106
|
+
* login: it throws `authority_login_required` and stores nothing. Only an
|
|
107
|
+
* inactive answer on a token from a rotation that started after the
|
|
108
|
+
* observation's own inactive answer is a refusal (`idp_membership_absent`). A session's token is
|
|
109
|
+
* rotated so at most once per `AUTHORITY_FORCED_REFRESH_INTERVAL_MS`. An inactive
|
|
110
|
+
* answer that no rotation followed — it came within that time of the last
|
|
111
|
+
* one, the rotation started before it, or the source did not mark the token
|
|
112
|
+
* as its own rotation — confirms nothing: it throws `authority_unavailable`
|
|
113
|
+
* and stores nothing, and after the interval the next one rotates and
|
|
114
|
+
* decides. One monotonic sequence orders answers and rotation starts, so no
|
|
115
|
+
* token is compared with another and no clock with another's.
|
|
92
116
|
*/
|
|
93
117
|
export class AuthorityObserver<TGrant> {
|
|
94
118
|
readonly #cache = new Map<string, ICachedObservation<TGrant>>();
|
|
95
119
|
readonly #inFlight = new Map<string, Promise<ICachedObservation<TGrant>>>();
|
|
96
120
|
readonly #forgotten = new Set<string>();
|
|
121
|
+
readonly #forcedRefreshes = new Map<string, IForcedRefresh>();
|
|
122
|
+
/** counts inactive answers and forced rotation starts, in the order they happen */
|
|
123
|
+
#sequence = 0;
|
|
97
124
|
readonly #provider: IOidcProvider;
|
|
98
125
|
readonly #tokens: IAuthorityTokenSource;
|
|
99
126
|
readonly #project: (rolesArg: readonly string[]) => TGrant | undefined;
|
|
@@ -149,6 +176,7 @@ export class AuthorityObserver<TGrant> {
|
|
|
149
176
|
|
|
150
177
|
/** Drops every observation of a session; wire it to the session layer's `onSessionEnded`. */
|
|
151
178
|
public forgetSession(sessionIdArg: string): void {
|
|
179
|
+
this.#forcedRefreshes.delete(sessionIdArg);
|
|
152
180
|
const prefix = `${sessionIdArg} `;
|
|
153
181
|
for (const key of this.#cache.keys()) {
|
|
154
182
|
if (key.startsWith(prefix)) {
|
|
@@ -188,18 +216,15 @@ export class AuthorityObserver<TGrant> {
|
|
|
188
216
|
optionsArg: { loginSessionId: string; principalId: string; idpOrganizationId: string },
|
|
189
217
|
): Promise<ICachedObservation<TGrant>> {
|
|
190
218
|
const token = await this.#tokens.liveProviderToken(optionsArg.loginSessionId, optionsArg.principalId);
|
|
191
|
-
let answer
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
} catch (error) {
|
|
199
|
-
if (error instanceof OidcAuthorityUnavailableError) {
|
|
200
|
-
throw new AuthorityTokenError('authority_unavailable', error);
|
|
219
|
+
let answer = await this.introspect(token, optionsArg.idpOrganizationId);
|
|
220
|
+
if (!answer.active) {
|
|
221
|
+
const answeredAt = ++this.#sequence;
|
|
222
|
+
const forced = this.forcedRefresh(optionsArg.loginSessionId, optionsArg.principalId, token.accessToken);
|
|
223
|
+
const rotated = await forced.token;
|
|
224
|
+
if (forced.sequence < answeredAt || rotated.rotatedAfterRejection !== true) {
|
|
225
|
+
throw new AuthorityTokenError('authority_unavailable');
|
|
201
226
|
}
|
|
202
|
-
|
|
227
|
+
answer = await this.introspect(rotated, optionsArg.idpOrganizationId);
|
|
203
228
|
}
|
|
204
229
|
const grant = answer.active ? this.#project(answer.roles) : undefined;
|
|
205
230
|
const active = answer.active && grant !== undefined;
|
|
@@ -228,6 +253,49 @@ export class AuthorityObserver<TGrant> {
|
|
|
228
253
|
return entry;
|
|
229
254
|
}
|
|
230
255
|
|
|
256
|
+
private async introspect(
|
|
257
|
+
tokenArg: IProviderToken,
|
|
258
|
+
organizationIdArg: string,
|
|
259
|
+
): Promise<Awaited<ReturnType<IOidcProvider['introspectOrganization']>>> {
|
|
260
|
+
try {
|
|
261
|
+
return await this.#provider.introspectOrganization({
|
|
262
|
+
accessToken: tokenArg.accessToken,
|
|
263
|
+
subject: tokenArg.subject,
|
|
264
|
+
organizationId: organizationIdArg,
|
|
265
|
+
});
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error instanceof OidcAuthorityUnavailableError) {
|
|
268
|
+
throw new AuthorityTokenError('authority_unavailable', error);
|
|
269
|
+
}
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The session's one forced rotation within `AUTHORITY_FORCED_REFRESH_INTERVAL_MS`,
|
|
276
|
+
* started for `rejectedArg` when there is none. Its failure is every
|
|
277
|
+
* caller's failure until the interval is over.
|
|
278
|
+
*/
|
|
279
|
+
private forcedRefresh(sessionIdArg: string, principalIdArg: string, rejectedArg: string): IForcedRefresh {
|
|
280
|
+
const now = this.#now();
|
|
281
|
+
const last = this.#forcedRefreshes.get(sessionIdArg);
|
|
282
|
+
if (last && now - last.startedAt < AUTHORITY_FORCED_REFRESH_INTERVAL_MS) {
|
|
283
|
+
return last;
|
|
284
|
+
}
|
|
285
|
+
for (const [sessionId, forced] of this.#forcedRefreshes) {
|
|
286
|
+
if (now - forced.startedAt >= AUTHORITY_FORCED_REFRESH_INTERVAL_MS) {
|
|
287
|
+
this.#forcedRefreshes.delete(sessionId);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const forced: IForcedRefresh = {
|
|
291
|
+
startedAt: now,
|
|
292
|
+
sequence: ++this.#sequence,
|
|
293
|
+
token: this.#tokens.liveProviderToken(sessionIdArg, principalIdArg, { rejectedAccessToken: rejectedArg }),
|
|
294
|
+
};
|
|
295
|
+
this.#forcedRefreshes.set(sessionIdArg, forced);
|
|
296
|
+
return forced;
|
|
297
|
+
}
|
|
298
|
+
|
|
231
299
|
/** Expired entries go first; if that is not enough, the oldest do. */
|
|
232
300
|
private prune(): void {
|
|
233
301
|
if (this.#cache.size <= this.#maxEntries) {
|
|
@@ -13,7 +13,11 @@ export type TRelyingPartyErrorCode =
|
|
|
13
13
|
/** a hand-off code that is unknown, expired, already claimed, or claimed with the wrong verifier */
|
|
14
14
|
| 'invalid_bearer_handoff'
|
|
15
15
|
/** a refresh token that is unknown, rotated, expired, or of a session that is over */
|
|
16
|
-
| 'invalid_bearer_grant'
|
|
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'
|
|
19
|
+
/** a `sessionsOfPrincipal` cursor that no page of it answered */
|
|
20
|
+
| 'invalid_sessions_cursor';
|
|
17
21
|
|
|
18
22
|
const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number>> = {
|
|
19
23
|
login_method_unavailable: 404,
|
|
@@ -25,6 +29,8 @@ const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number
|
|
|
25
29
|
return_target_not_allowed: 400,
|
|
26
30
|
invalid_bearer_handoff: 400,
|
|
27
31
|
invalid_bearer_grant: 400,
|
|
32
|
+
invalid_handoff_opener: 400,
|
|
33
|
+
invalid_sessions_cursor: 400,
|
|
28
34
|
};
|
|
29
35
|
|
|
30
36
|
/** A request the login flow refuses; the code is safe to answer, the browser learns nothing more. */
|
|
@@ -13,6 +13,9 @@ import { sanitizeReturnPath } from './helpers.returnpath.js';
|
|
|
13
13
|
import type {
|
|
14
14
|
IRelyingPartyBearerDelivery,
|
|
15
15
|
IRelyingPartyBearerLoginRequest,
|
|
16
|
+
IRelyingPartyBearerLogoutAnswer,
|
|
17
|
+
IRelyingPartyBearerSessionAnswer,
|
|
18
|
+
IRelyingPartyBearerTokenAnswer,
|
|
16
19
|
IRelyingPartyBearerTokens,
|
|
17
20
|
IRelyingPartySession,
|
|
18
21
|
IResolvedRelyingPartySession,
|
|
@@ -149,6 +152,12 @@ type TBearerApiRoute = 'bearerClaim' | 'bearerRefresh' | 'bearerSession' | 'bear
|
|
|
149
152
|
* The four API routes answer CORS requests from the popup return targets'
|
|
150
153
|
* origins, exactly, and no others; they never allow credentials.
|
|
151
154
|
*
|
|
155
|
+
* In bearer-only mode (`RelyingPartySessions` `cookieSessions: false`) only
|
|
156
|
+
* the bearer routes and the callback answer, and no answer sets a cookie: a
|
|
157
|
+
* callback that is no bearer login fails without clearing an intent cookie
|
|
158
|
+
* that was never set, and `login`, `finalize`, `session` and `logout` answer
|
|
159
|
+
* `null`.
|
|
160
|
+
*
|
|
152
161
|
* `handle()` answers `null` for every other request, so the application
|
|
153
162
|
* chains its own routes (a local password login) around it. Cookies are only
|
|
154
163
|
* ever set here: a `/typedrequest` answer cannot carry `Set-Cookie`.
|
|
@@ -204,12 +213,15 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
204
213
|
return bearerResponse;
|
|
205
214
|
}
|
|
206
215
|
}
|
|
207
|
-
if (path === paths.login && method === 'GET' && providerLogin) {
|
|
208
|
-
return this.login(contextArg);
|
|
209
|
-
}
|
|
210
216
|
if (path === paths.callback && method === 'GET' && providerLogin) {
|
|
211
217
|
return this.callback(contextArg);
|
|
212
218
|
}
|
|
219
|
+
if (!this.#sessions.cookieSessionsEnabled) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
if (path === paths.login && method === 'GET' && providerLogin) {
|
|
223
|
+
return this.login(contextArg);
|
|
224
|
+
}
|
|
213
225
|
if (path === paths.finalize && method === 'GET' && providerLogin) {
|
|
214
226
|
let location = this.#options.failureLocation;
|
|
215
227
|
try {
|
|
@@ -257,16 +269,20 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
257
269
|
// the callback is read from the public origin, never from a forwarded host
|
|
258
270
|
const callbackUrl = new URL(contextArg.url.pathname + contextArg.url.search, this.#sessions.publicOrigin);
|
|
259
271
|
if (this.#sessions.bearerLoginEnabled) {
|
|
272
|
+
const cookieSessions = this.#sessions.cookieSessionsEnabled;
|
|
260
273
|
let delivery: IRelyingPartyBearerDelivery | null;
|
|
261
274
|
try {
|
|
262
275
|
delivery = await this.#sessions.bearerDeliveryOf(callbackUrl);
|
|
263
276
|
} catch (error) {
|
|
264
277
|
// unknown whose callback this is: answered as the cookie path answers a failure
|
|
265
|
-
return this.callbackFailure(error, { 'Set-Cookie': this.#sessions.clearIntentCookie() });
|
|
278
|
+
return this.callbackFailure(error, cookieSessions ? { 'Set-Cookie': this.#sessions.clearIntentCookie() } : {});
|
|
266
279
|
}
|
|
267
280
|
if (delivery) {
|
|
268
281
|
return this.bearerCallback(callbackUrl, delivery);
|
|
269
282
|
}
|
|
283
|
+
if (!cookieSessions) {
|
|
284
|
+
return this.callbackOfNoBearerLogin(callbackUrl);
|
|
285
|
+
}
|
|
270
286
|
}
|
|
271
287
|
const cookieHeader = contextArg.headers.get('cookie');
|
|
272
288
|
const providerError = callbackUrl.searchParams.get('error');
|
|
@@ -298,6 +314,20 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
298
314
|
}
|
|
299
315
|
}
|
|
300
316
|
|
|
317
|
+
/**
|
|
318
|
+
* A callback of no bearer login in bearer-only mode: no attempt of this
|
|
319
|
+
* relying party can be completed with it, and no cookie is cleared because
|
|
320
|
+
* none was ever set.
|
|
321
|
+
*/
|
|
322
|
+
private callbackOfNoBearerLogin(callbackUrlArg: URL): Response {
|
|
323
|
+
const providerError = callbackUrlArg.searchParams.get('error');
|
|
324
|
+
if (providerError) {
|
|
325
|
+
this.emit({ event: 'auth_callback_declined', code: providerError.slice(0, MAX_PROVIDER_ERROR_CODE_LENGTH) });
|
|
326
|
+
return redirect(this.#options.failureLocation);
|
|
327
|
+
}
|
|
328
|
+
return this.callbackFailure(new RelyingPartyError('invalid_login_callback'), {});
|
|
329
|
+
}
|
|
330
|
+
|
|
301
331
|
/** A callback that failed for a reason other than a refusal: reported when it is the relying party's fault. */
|
|
302
332
|
private callbackFailure(errorArg: unknown, headersArg: Record<string, string>): Response {
|
|
303
333
|
const status = this.callbackFailureStatus(errorArg);
|
|
@@ -374,7 +404,8 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
374
404
|
const refreshToken = bodyField(await this.readJsonBody(contextArg), 'refreshToken');
|
|
375
405
|
endedLiveSession = refreshToken !== undefined ? await this.#sessions.endBearerSession(refreshToken) : false;
|
|
376
406
|
}
|
|
377
|
-
|
|
407
|
+
const answer: IRelyingPartyBearerLogoutAnswer = { endedLiveSession };
|
|
408
|
+
return answer;
|
|
378
409
|
});
|
|
379
410
|
}
|
|
380
411
|
|
|
@@ -491,7 +522,7 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
491
522
|
}
|
|
492
523
|
}
|
|
493
524
|
|
|
494
|
-
private bearerTokenAnswer(tokensArg: IRelyingPartyBearerTokens):
|
|
525
|
+
private bearerTokenAnswer(tokensArg: IRelyingPartyBearerTokens): IRelyingPartyBearerTokenAnswer {
|
|
495
526
|
return {
|
|
496
527
|
tokenType: 'Bearer',
|
|
497
528
|
accessToken: tokensArg.accessToken,
|
|
@@ -508,7 +539,7 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
508
539
|
};
|
|
509
540
|
}
|
|
510
541
|
|
|
511
|
-
private async bearerSessionAnswer(contextArg: IRelyingPartyHttpContext): Promise<
|
|
542
|
+
private async bearerSessionAnswer(contextArg: IRelyingPartyHttpContext): Promise<IRelyingPartyBearerSessionAnswer> {
|
|
512
543
|
const live = await this.#sessions.resolveBearerSession(contextArg.headers.get('authorization'));
|
|
513
544
|
return {
|
|
514
545
|
session: live
|