@idp.global/sdk 18.0.1 → 18.2.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 +28 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_relyingparty/classes.actorscope.d.ts +23 -0
- package/dist_ts_relyingparty/classes.actorscope.js +22 -0
- package/dist_ts_relyingparty/classes.authorityobserver.d.ts +81 -0
- package/dist_ts_relyingparty/classes.authorityobserver.js +185 -0
- package/dist_ts_relyingparty/classes.errors.d.ts +37 -0
- package/dist_ts_relyingparty/classes.errors.js +60 -0
- package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +142 -0
- package/dist_ts_relyingparty/classes.idpoidcprovider.js +255 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +102 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +248 -0
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +223 -0
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +825 -0
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +87 -0
- package/dist_ts_relyingparty/classes.rpmodels.js +302 -0
- package/dist_ts_relyingparty/classes.secretbox.d.ts +14 -0
- package/dist_ts_relyingparty/classes.secretbox.js +45 -0
- package/dist_ts_relyingparty/classes.socketbinding.d.ts +84 -0
- package/dist_ts_relyingparty/classes.socketbinding.js +100 -0
- package/dist_ts_relyingparty/helpers.cookies.d.ts +11 -0
- package/dist_ts_relyingparty/helpers.cookies.js +62 -0
- package/dist_ts_relyingparty/helpers.crypto.d.ts +12 -0
- package/dist_ts_relyingparty/helpers.crypto.js +40 -0
- package/dist_ts_relyingparty/helpers.returnpath.d.ts +9 -0
- package/dist_ts_relyingparty/helpers.returnpath.js +39 -0
- package/dist_ts_relyingparty/index.d.ts +13 -0
- package/dist_ts_relyingparty/index.js +14 -0
- package/dist_ts_relyingparty/interfaces.d.ts +142 -0
- package/dist_ts_relyingparty/interfaces.js +2 -0
- package/dist_ts_relyingparty/plugins.d.ts +10 -0
- package/dist_ts_relyingparty/plugins.js +11 -0
- package/package.json +13 -10
- package/readme.md +215 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.actorscope.ts +39 -0
- package/ts_relyingparty/classes.authorityobserver.ts +253 -0
- package/ts_relyingparty/classes.errors.ts +77 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +386 -0
- package/ts_relyingparty/classes.relyingpartyroutes.ts +319 -0
- package/ts_relyingparty/classes.relyingpartysessions.ts +1017 -0
- package/ts_relyingparty/classes.rpmodels.ts +170 -0
- package/ts_relyingparty/classes.secretbox.ts +49 -0
- package/ts_relyingparty/classes.socketbinding.ts +156 -0
- package/ts_relyingparty/helpers.cookies.ts +79 -0
- package/ts_relyingparty/helpers.crypto.ts +49 -0
- package/ts_relyingparty/helpers.returnpath.ts +44 -0
- package/ts_relyingparty/index.ts +13 -0
- package/ts_relyingparty/interfaces.ts +165 -0
- package/ts_relyingparty/plugins.ts +17 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { LoginCallbackError, RelyingPartyError, RelyingPartyRefusal } from './classes.errors.js';
|
|
3
|
+
import type { RelyingPartySessions } from './classes.relyingpartysessions.js';
|
|
4
|
+
import type { IRelyingPartySession, IResolvedRelyingPartySession } from './interfaces.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The part of an HTTP request context the routes read. `IRequestContext` of
|
|
8
|
+
* `@push.rocks/smartserve` — what a typedserver `IRouteHandler` receives —
|
|
9
|
+
* satisfies it, so the routes plug into `addRoute()` / `addCustomRoutes()`
|
|
10
|
+
* without the sdk depending on either package.
|
|
11
|
+
*/
|
|
12
|
+
export interface IRelyingPartyHttpContext {
|
|
13
|
+
readonly method: string;
|
|
14
|
+
readonly url: URL;
|
|
15
|
+
readonly headers: Headers;
|
|
16
|
+
readonly request: Request;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IRelyingPartyRoutesOptions<TPrincipal extends object | undefined = undefined> {
|
|
20
|
+
sessions: RelyingPartySessions<TPrincipal>;
|
|
21
|
+
/** where a sign-in that did not finish sends the browser; no provider text is ever echoed into it */
|
|
22
|
+
failureLocation: string;
|
|
23
|
+
/**
|
|
24
|
+
* Where a refusal of the application (`RelyingPartyRefusal.code`) sends the
|
|
25
|
+
* browser, so the application can explain it; a refusal not named here is
|
|
26
|
+
* answered as a failed authentication with its status.
|
|
27
|
+
*/
|
|
28
|
+
refusalLocations?: Readonly<Record<string, string>>;
|
|
29
|
+
/**
|
|
30
|
+
* The body `GET <prefix>session` answers with. Default:
|
|
31
|
+
* `{ session: { sessionId, principalId, expiresAt, csrfToken } | null }`.
|
|
32
|
+
*/
|
|
33
|
+
sessionAnswer?(resolvedArg: IResolvedRelyingPartySession<TPrincipal> | null): Promise<object>;
|
|
34
|
+
/**
|
|
35
|
+
* A `POST <prefix>logout` passed the origin and CSRF checks and the session
|
|
36
|
+
* was ended. Awaited before the answer, so an audit record is written
|
|
37
|
+
* before the browser learns the logout is done. `endedLiveSession` is `true`
|
|
38
|
+
* only when this call ended a live session; a logout that lost a race to
|
|
39
|
+
* another end answers `false` and records nothing. The session is already
|
|
40
|
+
* over when this runs: a throw (or a rejection) never keeps it alive and
|
|
41
|
+
* never changes the answer — it is reported as a `hook_failed` route event.
|
|
42
|
+
*/
|
|
43
|
+
onLogout?(eventArg: { session: IRelyingPartySession; endedLiveSession: boolean }): Promise<void>;
|
|
44
|
+
/** the largest logout body read, in bytes; a longer one is cancelled as soon as it passes the limit */
|
|
45
|
+
maxBodyBytes?: number;
|
|
46
|
+
/**
|
|
47
|
+
* diagnostics of a failed start or callback; the default writes one JSON
|
|
48
|
+
* line to `console.error`. A throwing sink never changes the answer: the
|
|
49
|
+
* event and its failure go to `console.error` instead.
|
|
50
|
+
*/
|
|
51
|
+
onRouteEvent?(eventArg: {
|
|
52
|
+
event:
|
|
53
|
+
| 'auth_login_start_failed'
|
|
54
|
+
| 'auth_callback_failed'
|
|
55
|
+
| 'auth_callback_declined'
|
|
56
|
+
| 'auth_callback_refused'
|
|
57
|
+
/** an application hook of the routes threw; the step it belonged to is complete */
|
|
58
|
+
| 'hook_failed';
|
|
59
|
+
hook?: 'onLogout';
|
|
60
|
+
sessionId?: string;
|
|
61
|
+
stage?: string;
|
|
62
|
+
code?: string;
|
|
63
|
+
correlationId?: string;
|
|
64
|
+
message?: string;
|
|
65
|
+
}): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DEFAULT_MAX_BODY_BYTES = 16 * 1024;
|
|
69
|
+
/** What the provider names in a callback it did not issue a code for. */
|
|
70
|
+
const MAX_PROVIDER_ERROR_CODE_LENGTH = 64;
|
|
71
|
+
|
|
72
|
+
const noStoreHeaders = (headersArg?: Record<string, string>): Headers => {
|
|
73
|
+
const headers = new Headers(headersArg);
|
|
74
|
+
headers.set('Cache-Control', 'no-store');
|
|
75
|
+
headers.set('Pragma', 'no-cache');
|
|
76
|
+
headers.set('Cross-Origin-Resource-Policy', 'same-origin');
|
|
77
|
+
return headers;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const jsonResponse = (bodyArg: object, statusArg: number, headersArg?: Record<string, string>): Response => {
|
|
81
|
+
const headers = noStoreHeaders(headersArg);
|
|
82
|
+
headers.set('Content-Type', 'application/json; charset=utf-8');
|
|
83
|
+
return new Response(JSON.stringify(bodyArg), { status: statusArg, headers });
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const redirect = (locationArg: string, headersArg?: Record<string, string>): Response =>
|
|
87
|
+
new Response(null, { status: 302, headers: noStoreHeaders({ Location: locationArg, ...headersArg }) });
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The HTTP steps of a relying-party login, all under the sessions' login path
|
|
91
|
+
* prefix (default `/auth/`):
|
|
92
|
+
*
|
|
93
|
+
* - `GET login?return=…` redirects to the provider and sets the intent cookie
|
|
94
|
+
* - `GET callback` consumes the attempt once and sets the session cookie; a
|
|
95
|
+
* provider `error` abandons the attempt
|
|
96
|
+
* - `GET finalize` releases the return path only to the browser holding the
|
|
97
|
+
* session this attempt created
|
|
98
|
+
* - `GET session` answers the current session
|
|
99
|
+
* - `POST logout` ends the session; it needs this origin and the CSRF token,
|
|
100
|
+
* and `onLogout` runs before it answers
|
|
101
|
+
*
|
|
102
|
+
* `handle()` answers `null` for every other request, so the application
|
|
103
|
+
* chains its own routes (a local password login) around it. Cookies are only
|
|
104
|
+
* ever set here: a `/typedrequest` answer cannot carry `Set-Cookie`.
|
|
105
|
+
*/
|
|
106
|
+
export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefined> {
|
|
107
|
+
readonly #sessions: RelyingPartySessions<TPrincipal>;
|
|
108
|
+
readonly #options: IRelyingPartyRoutesOptions<TPrincipal>;
|
|
109
|
+
readonly #maxBodyBytes: number;
|
|
110
|
+
|
|
111
|
+
constructor(optionsArg: IRelyingPartyRoutesOptions<TPrincipal>) {
|
|
112
|
+
this.#sessions = optionsArg.sessions;
|
|
113
|
+
this.#options = optionsArg;
|
|
114
|
+
this.#maxBodyBytes = optionsArg.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The paths the routes answer, for registering them one by one. */
|
|
118
|
+
public get paths(): { login: string; callback: string; finalize: string; session: string; logout: string } {
|
|
119
|
+
const prefix = this.#sessions.loginPathPrefix;
|
|
120
|
+
return {
|
|
121
|
+
login: prefix + 'login',
|
|
122
|
+
callback: prefix + 'callback',
|
|
123
|
+
finalize: prefix + 'finalize',
|
|
124
|
+
session: prefix + 'session',
|
|
125
|
+
logout: prefix + 'logout',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public readonly handle = async (contextArg: IRelyingPartyHttpContext): Promise<Response | null> => {
|
|
130
|
+
const paths = this.paths;
|
|
131
|
+
const path = contextArg.url.pathname;
|
|
132
|
+
const method = contextArg.method.toUpperCase();
|
|
133
|
+
const providerLogin = this.#sessions.providerLoginEnabled;
|
|
134
|
+
|
|
135
|
+
if (path === paths.login && method === 'GET' && providerLogin) {
|
|
136
|
+
return this.login(contextArg);
|
|
137
|
+
}
|
|
138
|
+
if (path === paths.callback && method === 'GET' && providerLogin) {
|
|
139
|
+
return this.callback(contextArg);
|
|
140
|
+
}
|
|
141
|
+
if (path === paths.finalize && method === 'GET' && providerLogin) {
|
|
142
|
+
let location = this.#options.failureLocation;
|
|
143
|
+
try {
|
|
144
|
+
location = await this.#sessions.finalizeLogin(contextArg.headers.get('cookie'));
|
|
145
|
+
} catch {
|
|
146
|
+
location = this.#options.failureLocation;
|
|
147
|
+
}
|
|
148
|
+
return redirect(location, { 'Set-Cookie': this.#sessions.clearIntentCookie() });
|
|
149
|
+
}
|
|
150
|
+
if (path === paths.session && method === 'GET') {
|
|
151
|
+
const resolved = await this.#sessions.resolveCookieSession(contextArg.headers.get('cookie'));
|
|
152
|
+
return jsonResponse(await this.sessionAnswer(resolved), 200);
|
|
153
|
+
}
|
|
154
|
+
if (path === paths.logout && method === 'POST') {
|
|
155
|
+
return this.logout(contextArg);
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
private async login(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|
|
161
|
+
try {
|
|
162
|
+
const start = await this.#sessions.startLogin(contextArg.url.searchParams.get('return'));
|
|
163
|
+
return redirect(start.authorizationUrl.href, { 'Set-Cookie': start.intentCookie });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
this.emit({ event: 'auth_login_start_failed', message: error instanceof Error ? error.message : String(error) });
|
|
166
|
+
return jsonResponse({ error: 'authentication_unavailable' }, 503);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async callback(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|
|
171
|
+
// the callback is read from the public origin, never from a forwarded host
|
|
172
|
+
const callbackUrl = new URL(contextArg.url.pathname + contextArg.url.search, this.#sessions.publicOrigin);
|
|
173
|
+
const cookieHeader = contextArg.headers.get('cookie');
|
|
174
|
+
const providerError = callbackUrl.searchParams.get('error');
|
|
175
|
+
if (providerError) {
|
|
176
|
+
// the user cancelled, or the provider refused: nothing to exchange, and
|
|
177
|
+
// the attempt is consumed so its state never completes later
|
|
178
|
+
await this.#sessions.abandonLogin(callbackUrl, cookieHeader);
|
|
179
|
+
this.emit({ event: 'auth_callback_declined', code: providerError.slice(0, MAX_PROVIDER_ERROR_CODE_LENGTH) });
|
|
180
|
+
return redirect(this.#options.failureLocation, { 'Set-Cookie': this.#sessions.clearIntentCookie() });
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const opening = await this.#sessions.completeLogin(callbackUrl, cookieHeader);
|
|
184
|
+
return redirect(this.paths.finalize, { 'Set-Cookie': opening.sessionCookie });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const clearIntent = { 'Set-Cookie': this.#sessions.clearIntentCookie() };
|
|
187
|
+
if (error instanceof RelyingPartyRefusal) {
|
|
188
|
+
this.emit({ event: 'auth_callback_refused', code: error.code });
|
|
189
|
+
const locations = this.#options.refusalLocations ?? {};
|
|
190
|
+
if (Object.hasOwn(locations, error.code)) {
|
|
191
|
+
return redirect(locations[error.code], clearIntent);
|
|
192
|
+
}
|
|
193
|
+
return jsonResponse({ error: 'authentication_failed' }, error.status, clearIntent);
|
|
194
|
+
}
|
|
195
|
+
const status = error instanceof RelyingPartyError ? error.status : 502;
|
|
196
|
+
if (status >= 500) {
|
|
197
|
+
this.emit({
|
|
198
|
+
event: 'auth_callback_failed',
|
|
199
|
+
correlationId: plugins.crypto.randomUUID(),
|
|
200
|
+
stage: error instanceof LoginCallbackError ? error.stage : 'unclassified',
|
|
201
|
+
message: error instanceof Error ? error.message : String(error),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return jsonResponse({ error: 'authentication_failed' }, status, clearIntent);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async logout(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|
|
209
|
+
if (contextArg.headers.get('origin') !== this.#sessions.publicOrigin) {
|
|
210
|
+
return jsonResponse({ error: 'forbidden' }, 403);
|
|
211
|
+
}
|
|
212
|
+
const clearSession = { 'Set-Cookie': this.#sessions.clearSessionCookie() };
|
|
213
|
+
const resolved = await this.#sessions.resolveCookieSession(contextArg.headers.get('cookie'));
|
|
214
|
+
if (!resolved) {
|
|
215
|
+
return jsonResponse({ endedLiveSession: false }, 200, clearSession);
|
|
216
|
+
}
|
|
217
|
+
const body = await this.readJsonBody(contextArg);
|
|
218
|
+
const csrfToken = body && typeof body === 'object' ? Reflect.get(body, 'csrfToken') : undefined;
|
|
219
|
+
if (!this.#sessions.validateCsrf(resolved, csrfToken)) {
|
|
220
|
+
return jsonResponse({ error: 'forbidden' }, 403);
|
|
221
|
+
}
|
|
222
|
+
// the session ends first: whatever the hook does, it cannot keep it alive
|
|
223
|
+
const endedLiveSession = await this.#sessions.logout(resolved.session.id);
|
|
224
|
+
if (this.#options.onLogout) {
|
|
225
|
+
try {
|
|
226
|
+
await this.#options.onLogout({ session: resolved.session, endedLiveSession });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
this.emit({
|
|
229
|
+
event: 'hook_failed',
|
|
230
|
+
hook: 'onLogout',
|
|
231
|
+
sessionId: resolved.session.id,
|
|
232
|
+
message: error instanceof Error ? error.message : String(error),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return jsonResponse({ endedLiveSession }, 200, clearSession);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async sessionAnswer(resolvedArg: IResolvedRelyingPartySession<TPrincipal> | null): Promise<object> {
|
|
240
|
+
if (this.#options.sessionAnswer) {
|
|
241
|
+
return this.#options.sessionAnswer(resolvedArg);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
session: resolvedArg
|
|
245
|
+
? {
|
|
246
|
+
sessionId: resolvedArg.session.id,
|
|
247
|
+
principalId: resolvedArg.session.principalId,
|
|
248
|
+
expiresAt: resolvedArg.session.expiresAt,
|
|
249
|
+
csrfToken: resolvedArg.csrfToken,
|
|
250
|
+
}
|
|
251
|
+
: null,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A JSON body within `maxBodyBytes`, or `null`. The limit holds while the
|
|
257
|
+
* body streams, whatever `Content-Length` claims or omits: the read is
|
|
258
|
+
* cancelled at the first chunk that passes it.
|
|
259
|
+
*/
|
|
260
|
+
private async readJsonBody(contextArg: IRelyingPartyHttpContext): Promise<unknown> {
|
|
261
|
+
const declaredLength = Number(contextArg.headers.get('content-length') || 0);
|
|
262
|
+
if (!Number.isFinite(declaredLength) || declaredLength < 0 || declaredLength > this.#maxBodyBytes) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const body = contextArg.request.body;
|
|
266
|
+
if (!body) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const reader = body.getReader();
|
|
270
|
+
const chunks: Uint8Array[] = [];
|
|
271
|
+
let received = 0;
|
|
272
|
+
try {
|
|
273
|
+
for (;;) {
|
|
274
|
+
const { done, value } = await reader.read();
|
|
275
|
+
if (done) {
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
received += value.byteLength;
|
|
279
|
+
if (received > this.#maxBodyBytes) {
|
|
280
|
+
await reader.cancel();
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
chunks.push(value);
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
} finally {
|
|
288
|
+
reader.releaseLock();
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
return JSON.parse(Buffer.concat(chunks, received).toString('utf8')) as unknown;
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private emit(eventArg: Parameters<NonNullable<IRelyingPartyRoutesOptions<TPrincipal>['onRouteEvent']>>[0]): void {
|
|
298
|
+
if (!this.#options.onRouteEvent) {
|
|
299
|
+
console.error(JSON.stringify(eventArg));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const reportFailure = (errorArg: unknown) => {
|
|
303
|
+
console.error(JSON.stringify({
|
|
304
|
+
event: 'hook_failed',
|
|
305
|
+
hook: 'onRouteEvent',
|
|
306
|
+
message: errorArg instanceof Error ? errorArg.message : String(errorArg),
|
|
307
|
+
}));
|
|
308
|
+
console.error(JSON.stringify(eventArg));
|
|
309
|
+
};
|
|
310
|
+
try {
|
|
311
|
+
const result: unknown = this.#options.onRouteEvent(eventArg);
|
|
312
|
+
if (result instanceof Promise) {
|
|
313
|
+
result.catch(reportFailure);
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
reportFailure(error);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|