@idp.global/sdk 19.2.1 → 19.4.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 +58 -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 +276 -0
- package/dist_ts_browser/classes.relyingpartybearerclient.js +591 -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 +11 -1
- package/dist_ts_relyingparty/classes.errors.js +6 -1
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +74 -2
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +289 -14
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +209 -7
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +734 -87
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +71 -8
- package/dist_ts_relyingparty/classes.rpmodels.js +224 -10
- package/dist_ts_relyingparty/contract.bearer.d.ts +82 -0
- package/dist_ts_relyingparty/contract.bearer.js +9 -0
- package/dist_ts_relyingparty/helpers.bearer.d.ts +48 -0
- package/dist_ts_relyingparty/helpers.bearer.js +199 -0
- package/dist_ts_relyingparty/index.d.ts +1 -0
- package/dist_ts_relyingparty/index.js +2 -1
- package/dist_ts_relyingparty/interfaces.d.ts +122 -2
- package/dist_ts_relyingparty/testing.d.ts +22 -0
- package/dist_ts_relyingparty/testing.js +42 -1
- package/package.json +9 -11
- package/readme.md +353 -8
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_browser/classes.relyingpartybearerclient.ts +821 -0
- package/ts_browser/index.ts +1 -0
- package/ts_relyingparty/classes.authorityobserver.ts +80 -12
- package/ts_relyingparty/classes.errors.ts +16 -1
- package/ts_relyingparty/classes.relyingpartyroutes.ts +340 -16
- package/ts_relyingparty/classes.relyingpartysessions.ts +907 -94
- package/ts_relyingparty/classes.rpmodels.ts +131 -9
- package/ts_relyingparty/contract.bearer.ts +99 -0
- package/ts_relyingparty/helpers.bearer.ts +259 -0
- package/ts_relyingparty/index.ts +1 -0
- package/ts_relyingparty/interfaces.ts +144 -2
- package/ts_relyingparty/testing.ts +60 -0
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) {
|
|
@@ -5,7 +5,17 @@ export type TRelyingPartyErrorCode =
|
|
|
5
5
|
| 'invalid_login_callback'
|
|
6
6
|
| 'missing_login_intent'
|
|
7
7
|
| 'invalid_or_replayed_login'
|
|
8
|
-
| 'invalid_login_finalize'
|
|
8
|
+
| 'invalid_login_finalize'
|
|
9
|
+
/** a bearer login start the relying party cannot accept: a missing or malformed parameter */
|
|
10
|
+
| 'invalid_bearer_request'
|
|
11
|
+
/** a bearer client named a return target that is not configured for its kind, exactly */
|
|
12
|
+
| 'return_target_not_allowed'
|
|
13
|
+
/** a hand-off code that is unknown, expired, already claimed, or claimed with the wrong verifier */
|
|
14
|
+
| 'invalid_bearer_handoff'
|
|
15
|
+
/** a refresh token that is unknown, rotated, expired, or of a session that is over */
|
|
16
|
+
| 'invalid_bearer_grant'
|
|
17
|
+
/** a `sessionsOfPrincipal` cursor that no page of it answered */
|
|
18
|
+
| 'invalid_sessions_cursor';
|
|
9
19
|
|
|
10
20
|
const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number>> = {
|
|
11
21
|
login_method_unavailable: 404,
|
|
@@ -13,6 +23,11 @@ const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number
|
|
|
13
23
|
missing_login_intent: 400,
|
|
14
24
|
invalid_or_replayed_login: 400,
|
|
15
25
|
invalid_login_finalize: 400,
|
|
26
|
+
invalid_bearer_request: 400,
|
|
27
|
+
return_target_not_allowed: 400,
|
|
28
|
+
invalid_bearer_handoff: 400,
|
|
29
|
+
invalid_bearer_grant: 400,
|
|
30
|
+
invalid_sessions_cursor: 400,
|
|
16
31
|
};
|
|
17
32
|
|
|
18
33
|
/** A request the login flow refuses; the code is safe to answer, the browser learns nothing more. */
|
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import { LoginCallbackError, RelyingPartyError, RelyingPartyRefusal } from './classes.errors.js';
|
|
2
|
+
import { AuthorityTokenError, LoginCallbackError, RelyingPartyError, RelyingPartyRefusal } from './classes.errors.js';
|
|
3
3
|
import type { RelyingPartySessions } from './classes.relyingpartysessions.js';
|
|
4
|
+
import {
|
|
5
|
+
bearerDeliveryLocation,
|
|
6
|
+
bearerPopupContentSecurityPolicy,
|
|
7
|
+
bearerPopupPage,
|
|
8
|
+
parseBearerAuthorization,
|
|
9
|
+
type TRelyingPartyBearerOutcome,
|
|
10
|
+
} from './helpers.bearer.js';
|
|
11
|
+
import { randomToken } from './helpers.crypto.js';
|
|
4
12
|
import { sanitizeReturnPath } from './helpers.returnpath.js';
|
|
5
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
IRelyingPartyBearerDelivery,
|
|
15
|
+
IRelyingPartyBearerLoginRequest,
|
|
16
|
+
IRelyingPartyBearerLogoutAnswer,
|
|
17
|
+
IRelyingPartyBearerSessionAnswer,
|
|
18
|
+
IRelyingPartyBearerTokenAnswer,
|
|
19
|
+
IRelyingPartyBearerTokens,
|
|
20
|
+
IRelyingPartySession,
|
|
21
|
+
IResolvedRelyingPartySession,
|
|
22
|
+
} from './interfaces.js';
|
|
6
23
|
|
|
7
24
|
/**
|
|
8
25
|
* The part of an HTTP request context the routes read. `IRequestContext` of
|
|
@@ -42,7 +59,7 @@ export interface IRelyingPartyRoutesOptions<TPrincipal extends object | undefine
|
|
|
42
59
|
* never changes the answer — it is reported as a `hook_failed` route event.
|
|
43
60
|
*/
|
|
44
61
|
onLogout?(eventArg: { session: IRelyingPartySession; endedLiveSession: boolean }): Promise<void>;
|
|
45
|
-
/** the largest logout body read, in bytes; a longer one is cancelled as soon as it passes the limit */
|
|
62
|
+
/** the largest logout, claim or refresh body read, in bytes; a longer one is cancelled as soon as it passes the limit */
|
|
46
63
|
maxBodyBytes?: number;
|
|
47
64
|
/**
|
|
48
65
|
* diagnostics of a failed start or callback; the default writes one JSON
|
|
@@ -55,6 +72,10 @@ export interface IRelyingPartyRoutesOptions<TPrincipal extends object | undefine
|
|
|
55
72
|
| 'auth_callback_failed'
|
|
56
73
|
| 'auth_callback_declined'
|
|
57
74
|
| 'auth_callback_refused'
|
|
75
|
+
/** a bearer login start named something the relying party does not accept; `code` says what */
|
|
76
|
+
| 'auth_bearer_start_refused'
|
|
77
|
+
/** a bearer claim, refresh, session or logout failed for a reason other than its credential */
|
|
78
|
+
| 'auth_bearer_failed'
|
|
58
79
|
/** an application hook of the routes threw; the step it belonged to is complete */
|
|
59
80
|
| 'hook_failed';
|
|
60
81
|
hook?: 'onLogout';
|
|
@@ -87,6 +108,19 @@ const jsonResponse = (bodyArg: object, statusArg: number, headersArg?: Record<st
|
|
|
87
108
|
const redirect = (locationArg: string, headersArg?: Record<string, string>): Response =>
|
|
88
109
|
new Response(null, { status: 302, headers: noStoreHeaders({ Location: locationArg, ...headersArg }) });
|
|
89
110
|
|
|
111
|
+
/** A JSON body's field, or `undefined` for anything that is not an object holding it. */
|
|
112
|
+
const bodyField = (bodyArg: unknown, nameArg: string): unknown =>
|
|
113
|
+
bodyArg && typeof bodyArg === 'object' && Object.hasOwn(bodyArg, nameArg) ? Reflect.get(bodyArg, nameArg) : undefined;
|
|
114
|
+
|
|
115
|
+
/** A query parameter given once; `null` when it is repeated, so it never passes as given. */
|
|
116
|
+
const singleParam = (paramsArg: URLSearchParams, nameArg: string): string | null | undefined => {
|
|
117
|
+
const all = paramsArg.getAll(nameArg);
|
|
118
|
+
return all.length === 0 ? undefined : all.length === 1 ? all[0] : null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** The routes a bearer client calls with `fetch`, possibly from a popup client's origin. */
|
|
122
|
+
type TBearerApiRoute = 'bearerClaim' | 'bearerRefresh' | 'bearerSession' | 'bearerLogout';
|
|
123
|
+
|
|
90
124
|
/**
|
|
91
125
|
* The HTTP steps of a relying-party login, all under the sessions' login path
|
|
92
126
|
* prefix (default `/auth/`):
|
|
@@ -100,6 +134,30 @@ const redirect = (locationArg: string, headersArg?: Record<string, string>): Res
|
|
|
100
134
|
* - `POST logout` ends the session; it needs this origin and the CSRF token,
|
|
101
135
|
* and `onLogout` runs before it answers
|
|
102
136
|
*
|
|
137
|
+
* With bearer mode on (`RelyingPartySessions` `bearer`), the routes of the
|
|
138
|
+
* application's own clients join them; none of them reads or sets a cookie:
|
|
139
|
+
*
|
|
140
|
+
* - `GET bearer/login?client_kind=…&return_target=…&code_challenge=…&code_challenge_method=S256[&state=…]`
|
|
141
|
+
* redirects to the provider
|
|
142
|
+
* - `GET callback` answers a bearer login's callback by delivering the
|
|
143
|
+
* hand-off code — or the `error` — to the return target: a redirect with
|
|
144
|
+
* `code` (or `error`) and `state` in the query, or for a popup a page that
|
|
145
|
+
* posts them to its opener at the registered origin
|
|
146
|
+
* - `POST bearer/claim` `{ code, codeVerifier }` and `POST bearer/refresh`
|
|
147
|
+
* `{ refreshToken }` answer the tokens
|
|
148
|
+
* - `GET bearer/session` answers the session of `Authorization: Bearer <access token>`
|
|
149
|
+
* - `POST bearer/logout` ends the session of the `Authorization` access token
|
|
150
|
+
* or of the body's `{ refreshToken }`
|
|
151
|
+
*
|
|
152
|
+
* The four API routes answer CORS requests from the popup return targets'
|
|
153
|
+
* origins, exactly, and no others; they never allow credentials.
|
|
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
|
+
*
|
|
103
161
|
* `handle()` answers `null` for every other request, so the application
|
|
104
162
|
* chains its own routes (a local password login) around it. Cookies are only
|
|
105
163
|
* ever set here: a `/typedrequest` answer cannot carry `Set-Cookie`.
|
|
@@ -115,8 +173,19 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
115
173
|
this.#maxBodyBytes = optionsArg.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
116
174
|
}
|
|
117
175
|
|
|
118
|
-
/** The paths the routes answer, for registering them one by one. */
|
|
119
|
-
public get paths(): {
|
|
176
|
+
/** The paths the routes answer, for registering them one by one; the `bearer…` paths answer only in bearer mode. */
|
|
177
|
+
public get paths(): {
|
|
178
|
+
login: string;
|
|
179
|
+
callback: string;
|
|
180
|
+
finalize: string;
|
|
181
|
+
session: string;
|
|
182
|
+
logout: string;
|
|
183
|
+
bearerLogin: string;
|
|
184
|
+
bearerClaim: string;
|
|
185
|
+
bearerRefresh: string;
|
|
186
|
+
bearerSession: string;
|
|
187
|
+
bearerLogout: string;
|
|
188
|
+
} {
|
|
120
189
|
const prefix = this.#sessions.loginPathPrefix;
|
|
121
190
|
return {
|
|
122
191
|
login: prefix + 'login',
|
|
@@ -124,6 +193,11 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
124
193
|
finalize: prefix + 'finalize',
|
|
125
194
|
session: prefix + 'session',
|
|
126
195
|
logout: prefix + 'logout',
|
|
196
|
+
bearerLogin: prefix + 'bearer/login',
|
|
197
|
+
bearerClaim: prefix + 'bearer/claim',
|
|
198
|
+
bearerRefresh: prefix + 'bearer/refresh',
|
|
199
|
+
bearerSession: prefix + 'bearer/session',
|
|
200
|
+
bearerLogout: prefix + 'bearer/logout',
|
|
127
201
|
};
|
|
128
202
|
}
|
|
129
203
|
|
|
@@ -133,12 +207,21 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
133
207
|
const method = contextArg.method.toUpperCase();
|
|
134
208
|
const providerLogin = this.#sessions.providerLoginEnabled;
|
|
135
209
|
|
|
136
|
-
if (
|
|
137
|
-
|
|
210
|
+
if (this.#sessions.bearerLoginEnabled) {
|
|
211
|
+
const bearerResponse = await this.handleBearer(contextArg, path, method);
|
|
212
|
+
if (bearerResponse) {
|
|
213
|
+
return bearerResponse;
|
|
214
|
+
}
|
|
138
215
|
}
|
|
139
216
|
if (path === paths.callback && method === 'GET' && providerLogin) {
|
|
140
217
|
return this.callback(contextArg);
|
|
141
218
|
}
|
|
219
|
+
if (!this.#sessions.cookieSessionsEnabled) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
if (path === paths.login && method === 'GET' && providerLogin) {
|
|
223
|
+
return this.login(contextArg);
|
|
224
|
+
}
|
|
142
225
|
if (path === paths.finalize && method === 'GET' && providerLogin) {
|
|
143
226
|
let location = this.#options.failureLocation;
|
|
144
227
|
try {
|
|
@@ -185,6 +268,22 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
185
268
|
private async callback(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|
|
186
269
|
// the callback is read from the public origin, never from a forwarded host
|
|
187
270
|
const callbackUrl = new URL(contextArg.url.pathname + contextArg.url.search, this.#sessions.publicOrigin);
|
|
271
|
+
if (this.#sessions.bearerLoginEnabled) {
|
|
272
|
+
const cookieSessions = this.#sessions.cookieSessionsEnabled;
|
|
273
|
+
let delivery: IRelyingPartyBearerDelivery | null;
|
|
274
|
+
try {
|
|
275
|
+
delivery = await this.#sessions.bearerDeliveryOf(callbackUrl);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
// unknown whose callback this is: answered as the cookie path answers a failure
|
|
278
|
+
return this.callbackFailure(error, cookieSessions ? { 'Set-Cookie': this.#sessions.clearIntentCookie() } : {});
|
|
279
|
+
}
|
|
280
|
+
if (delivery) {
|
|
281
|
+
return this.bearerCallback(callbackUrl, delivery);
|
|
282
|
+
}
|
|
283
|
+
if (!cookieSessions) {
|
|
284
|
+
return this.callbackOfNoBearerLogin(callbackUrl);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
188
287
|
const cookieHeader = contextArg.headers.get('cookie');
|
|
189
288
|
const providerError = callbackUrl.searchParams.get('error');
|
|
190
289
|
if (providerError) {
|
|
@@ -211,19 +310,244 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
211
310
|
}
|
|
212
311
|
return jsonResponse({ error: 'authentication_failed' }, error.status, clearIntent);
|
|
213
312
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
313
|
+
return this.callbackFailure(error, clearIntent);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
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
|
+
|
|
331
|
+
/** A callback that failed for a reason other than a refusal: reported when it is the relying party's fault. */
|
|
332
|
+
private callbackFailure(errorArg: unknown, headersArg: Record<string, string>): Response {
|
|
333
|
+
const status = this.callbackFailureStatus(errorArg);
|
|
334
|
+
return jsonResponse({ error: 'authentication_failed' }, status, headersArg);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private callbackFailureStatus(errorArg: unknown): number {
|
|
338
|
+
const status = errorArg instanceof RelyingPartyError ? errorArg.status : 502;
|
|
339
|
+
if (status >= 500) {
|
|
340
|
+
this.emit({
|
|
341
|
+
event: 'auth_callback_failed',
|
|
342
|
+
correlationId: plugins.crypto.randomUUID(),
|
|
343
|
+
stage: errorArg instanceof LoginCallbackError ? errorArg.stage : 'unclassified',
|
|
344
|
+
message: errorArg instanceof Error ? errorArg.message : String(errorArg),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return status;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ==========================================================================
|
|
351
|
+
// bearer hand-off
|
|
352
|
+
// ==========================================================================
|
|
353
|
+
|
|
354
|
+
private async handleBearer(contextArg: IRelyingPartyHttpContext, pathArg: string, methodArg: string): Promise<Response | null> {
|
|
355
|
+
const paths = this.paths;
|
|
356
|
+
if (pathArg === paths.bearerLogin) {
|
|
357
|
+
return methodArg === 'GET' ? this.bearerLogin(contextArg) : null;
|
|
358
|
+
}
|
|
359
|
+
const route = (['bearerClaim', 'bearerRefresh', 'bearerSession', 'bearerLogout'] as const)
|
|
360
|
+
.find((routeArg: TBearerApiRoute) => paths[routeArg] === pathArg);
|
|
361
|
+
if (!route) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
const corsHeaders = this.bearerCorsHeaders(contextArg);
|
|
365
|
+
if (methodArg === 'OPTIONS') {
|
|
366
|
+
if (!corsHeaders) {
|
|
367
|
+
return jsonResponse({ error: 'forbidden' }, 403, { Vary: 'Origin' });
|
|
368
|
+
}
|
|
369
|
+
return new Response(null, {
|
|
370
|
+
status: 204,
|
|
371
|
+
headers: noStoreHeaders({
|
|
372
|
+
...corsHeaders,
|
|
373
|
+
'Access-Control-Allow-Methods': 'GET, POST',
|
|
374
|
+
'Access-Control-Allow-Headers': 'authorization, content-type',
|
|
375
|
+
'Access-Control-Max-Age': '600',
|
|
376
|
+
}),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const headers = corsHeaders ?? { Vary: 'Origin' };
|
|
380
|
+
if (route === 'bearerSession') {
|
|
381
|
+
return methodArg === 'GET' ? this.bearerApi(headers, () => this.bearerSessionAnswer(contextArg)) : null;
|
|
382
|
+
}
|
|
383
|
+
if (methodArg !== 'POST') {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
if (route === 'bearerClaim') {
|
|
387
|
+
return this.bearerApi(headers, async () => {
|
|
388
|
+
const body = await this.readJsonBody(contextArg);
|
|
389
|
+
return this.bearerTokenAnswer(
|
|
390
|
+
await this.#sessions.claimBearerHandoff(bodyField(body, 'code'), bodyField(body, 'codeVerifier')),
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (route === 'bearerRefresh') {
|
|
395
|
+
return this.bearerApi(headers, async () => {
|
|
396
|
+
const body = await this.readJsonBody(contextArg);
|
|
397
|
+
return this.bearerTokenAnswer(await this.#sessions.refreshBearerTokens(bodyField(body, 'refreshToken')));
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
return this.bearerApi(headers, async () => {
|
|
401
|
+
const accessToken = parseBearerAuthorization(contextArg.headers.get('authorization'));
|
|
402
|
+
let endedLiveSession = accessToken ? await this.#sessions.endBearerSession(accessToken) : false;
|
|
403
|
+
if (!endedLiveSession) {
|
|
404
|
+
const refreshToken = bodyField(await this.readJsonBody(contextArg), 'refreshToken');
|
|
405
|
+
endedLiveSession = refreshToken !== undefined ? await this.#sessions.endBearerSession(refreshToken) : false;
|
|
406
|
+
}
|
|
407
|
+
const answer: IRelyingPartyBearerLogoutAnswer = { endedLiveSession };
|
|
408
|
+
return answer;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The CORS answer for a request from a popup return target's origin, which
|
|
414
|
+
* is matched exactly; `null` for every other origin, which gets none.
|
|
415
|
+
* Credentials are never allowed: bearer mode carries no cookie.
|
|
416
|
+
*/
|
|
417
|
+
private bearerCorsHeaders(contextArg: IRelyingPartyHttpContext): Record<string, string> | null {
|
|
418
|
+
const origin = contextArg.headers.get('origin');
|
|
419
|
+
const popupOrigins = this.#sessions.bearerReturnTargets?.popup ?? [];
|
|
420
|
+
if (origin === null || !popupOrigins.includes(origin)) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
return { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private async bearerLogin(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|
|
427
|
+
const query = contextArg.url.searchParams;
|
|
428
|
+
const state = singleParam(query, 'state');
|
|
429
|
+
// every field is checked by startBearerLogin, which takes them as untrusted input
|
|
430
|
+
const request = {
|
|
431
|
+
clientKind: singleParam(query, 'client_kind'),
|
|
432
|
+
returnTarget: singleParam(query, 'return_target'),
|
|
433
|
+
codeChallenge: singleParam(query, 'code_challenge'),
|
|
434
|
+
codeChallengeMethod: singleParam(query, 'code_challenge_method'),
|
|
435
|
+
...(state !== undefined ? { state } : {}),
|
|
436
|
+
} as unknown as IRelyingPartyBearerLoginRequest;
|
|
437
|
+
try {
|
|
438
|
+
const start = await this.#sessions.startBearerLogin(request);
|
|
439
|
+
return redirect(start.authorizationUrl.href, { 'Referrer-Policy': 'no-referrer' });
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (error instanceof RelyingPartyError && error.status < 500) {
|
|
442
|
+
this.emit({ event: 'auth_bearer_start_refused', code: error.code });
|
|
443
|
+
return jsonResponse({ error: error.code }, error.status);
|
|
222
444
|
}
|
|
223
|
-
|
|
445
|
+
this.emit({ event: 'auth_login_start_failed', message: error instanceof Error ? error.message : String(error) });
|
|
446
|
+
return jsonResponse({ error: 'authentication_unavailable' }, 503);
|
|
224
447
|
}
|
|
225
448
|
}
|
|
226
449
|
|
|
450
|
+
/**
|
|
451
|
+
* The callback of a bearer login, delivered to its return target whatever
|
|
452
|
+
* the outcome: the hand-off code, `access_denied` when the provider came
|
|
453
|
+
* back without one, the code of an application refusal (its `location` is
|
|
454
|
+
* a page of the cookie flow and not followed), or `authentication_failed`.
|
|
455
|
+
*/
|
|
456
|
+
private async bearerCallback(callbackUrlArg: URL, deliveryArg: IRelyingPartyBearerDelivery): Promise<Response> {
|
|
457
|
+
const providerError = callbackUrlArg.searchParams.get('error');
|
|
458
|
+
if (providerError) {
|
|
459
|
+
await this.#sessions.abandonBearerLogin(callbackUrlArg);
|
|
460
|
+
this.emit({ event: 'auth_callback_declined', code: providerError.slice(0, MAX_PROVIDER_ERROR_CODE_LENGTH) });
|
|
461
|
+
return this.deliverBearerOutcome(deliveryArg, { error: 'access_denied' });
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
const handoff = await this.#sessions.completeBearerLogin(callbackUrlArg);
|
|
465
|
+
return this.deliverBearerOutcome(handoff.delivery, { code: handoff.code });
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (error instanceof RelyingPartyRefusal) {
|
|
468
|
+
this.emit({ event: 'auth_callback_refused', code: error.code });
|
|
469
|
+
return this.deliverBearerOutcome(deliveryArg, { error: error.code });
|
|
470
|
+
}
|
|
471
|
+
this.callbackFailureStatus(error);
|
|
472
|
+
return this.deliverBearerOutcome(deliveryArg, { error: 'authentication_failed' });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* A redirect to the return target with the outcome in its query, or for a
|
|
478
|
+
* popup the page that posts it to the opener. Neither sends a referrer, and
|
|
479
|
+
* the popup page runs its one nonce script and nothing else.
|
|
480
|
+
*/
|
|
481
|
+
private deliverBearerOutcome(deliveryArg: IRelyingPartyBearerDelivery, outcomeArg: TRelyingPartyBearerOutcome): Response {
|
|
482
|
+
if (deliveryArg.clientKind !== 'popup') {
|
|
483
|
+
return redirect(
|
|
484
|
+
bearerDeliveryLocation(deliveryArg, outcomeArg, this.#sessions.publicOrigin),
|
|
485
|
+
{ 'Referrer-Policy': 'no-referrer' },
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
const nonce = randomToken(16);
|
|
489
|
+
return new Response(bearerPopupPage(deliveryArg, outcomeArg, nonce), {
|
|
490
|
+
status: 200,
|
|
491
|
+
headers: noStoreHeaders({
|
|
492
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
493
|
+
'Content-Security-Policy': bearerPopupContentSecurityPolicy(nonce),
|
|
494
|
+
'X-Content-Type-Options': 'nosniff',
|
|
495
|
+
'Referrer-Policy': 'no-referrer',
|
|
496
|
+
}),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Runs one bearer API step and answers its JSON. A refused credential
|
|
502
|
+
* answers its code with 400; an unreachable provider answers
|
|
503
|
+
* `authority_unavailable` with 503 and spends nothing; any other failure is
|
|
504
|
+
* reported and answers `authentication_unavailable`.
|
|
505
|
+
*/
|
|
506
|
+
private async bearerApi(headersArg: Record<string, string>, stepArg: () => Promise<object>): Promise<Response> {
|
|
507
|
+
try {
|
|
508
|
+
return jsonResponse(await stepArg(), 200, headersArg);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (error instanceof RelyingPartyError && error.status < 500) {
|
|
511
|
+
return jsonResponse({ error: error.code }, error.status, headersArg);
|
|
512
|
+
}
|
|
513
|
+
if (error instanceof AuthorityTokenError && error.code === 'authority_unavailable') {
|
|
514
|
+
return jsonResponse({ error: error.code }, error.status, headersArg);
|
|
515
|
+
}
|
|
516
|
+
this.emit({
|
|
517
|
+
event: 'auth_bearer_failed',
|
|
518
|
+
correlationId: plugins.crypto.randomUUID(),
|
|
519
|
+
message: error instanceof Error ? error.message : String(error),
|
|
520
|
+
});
|
|
521
|
+
return jsonResponse({ error: 'authentication_unavailable' }, 503, headersArg);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private bearerTokenAnswer(tokensArg: IRelyingPartyBearerTokens): IRelyingPartyBearerTokenAnswer {
|
|
526
|
+
return {
|
|
527
|
+
tokenType: 'Bearer',
|
|
528
|
+
accessToken: tokensArg.accessToken,
|
|
529
|
+
accessTokenExpiresAt: tokensArg.accessTokenExpiresAt,
|
|
530
|
+
// counted on the relying party's clock, so a client with another clock can use it
|
|
531
|
+
expiresIn: Math.max(0, Math.floor((tokensArg.accessTokenExpiresAt - tokensArg.issuedAt) / 1_000)),
|
|
532
|
+
refreshToken: tokensArg.refreshToken,
|
|
533
|
+
refreshTokenExpiresAt: tokensArg.refreshTokenExpiresAt,
|
|
534
|
+
session: {
|
|
535
|
+
sessionId: tokensArg.session.id,
|
|
536
|
+
principalId: tokensArg.session.principalId,
|
|
537
|
+
expiresAt: tokensArg.session.expiresAt,
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private async bearerSessionAnswer(contextArg: IRelyingPartyHttpContext): Promise<IRelyingPartyBearerSessionAnswer> {
|
|
543
|
+
const live = await this.#sessions.resolveBearerSession(contextArg.headers.get('authorization'));
|
|
544
|
+
return {
|
|
545
|
+
session: live
|
|
546
|
+
? { sessionId: live.session.id, principalId: live.session.principalId, expiresAt: live.session.expiresAt }
|
|
547
|
+
: null,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
227
551
|
private async logout(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|
|
228
552
|
if (contextArg.headers.get('origin') !== this.#sessions.publicOrigin) {
|
|
229
553
|
return jsonResponse({ error: 'forbidden' }, 403);
|