@idp.global/sdk 18.3.1 → 19.1.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 +27 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_relyingparty/classes.errors.d.ts +9 -1
- package/dist_ts_relyingparty/classes.errors.js +11 -2
- package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +10 -0
- package/dist_ts_relyingparty/classes.idpoidcprovider.js +3 -1
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +6 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +19 -1
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +8 -3
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +34 -13
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +2 -0
- package/dist_ts_relyingparty/classes.rpmodels.js +9 -2
- package/dist_ts_relyingparty/classes.socketbinding.d.ts +33 -14
- package/dist_ts_relyingparty/classes.socketbinding.js +1 -2
- package/dist_ts_relyingparty/interfaces.d.ts +22 -3
- package/dist_ts_relyingparty/plugins.d.ts +1 -2
- package/dist_ts_relyingparty/plugins.js +2 -3
- package/dist_ts_relyingparty/testing.d.ts +103 -0
- package/dist_ts_relyingparty/testing.js +222 -0
- package/package.json +8 -7
- package/readme.md +74 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.errors.ts +11 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +13 -0
- package/ts_relyingparty/classes.relyingpartyroutes.ts +19 -0
- package/ts_relyingparty/classes.relyingpartysessions.ts +37 -13
- package/ts_relyingparty/classes.rpmodels.ts +4 -0
- package/ts_relyingparty/classes.socketbinding.ts +38 -18
- package/ts_relyingparty/interfaces.ts +24 -3
- package/ts_relyingparty/plugins.ts +1 -2
- package/ts_relyingparty/testing.ts +306 -0
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
RelyingPartyRefusal,
|
|
7
7
|
} from './classes.errors.js';
|
|
8
8
|
import {
|
|
9
|
+
OIDC_PROMPTS,
|
|
9
10
|
OidcCompletionError,
|
|
10
11
|
OidcRefreshRefusedError,
|
|
11
12
|
type IOidcCompletion,
|
|
@@ -22,7 +23,9 @@ import type {
|
|
|
22
23
|
IProviderToken,
|
|
23
24
|
IRelyingPartyEvent,
|
|
24
25
|
IRelyingPartyHooks,
|
|
26
|
+
IRelyingPartyAdmission,
|
|
25
27
|
IRelyingPartyLiveSession,
|
|
28
|
+
IRelyingPartyLoginOptions,
|
|
26
29
|
IRelyingPartyLoginStart,
|
|
27
30
|
IRelyingPartySession,
|
|
28
31
|
IRelyingPartySessionOpening,
|
|
@@ -41,6 +44,8 @@ const MAX_COOKIE_TOKEN_LENGTH = 256;
|
|
|
41
44
|
const SESSION_TOUCH_INTERVAL_MS = 60_000;
|
|
42
45
|
/** A sign-in transaction has to survive an interactive login at the provider, not a working day. */
|
|
43
46
|
export const LOGIN_ATTEMPT_MAX_AGE_SECONDS = 10 * 60;
|
|
47
|
+
/** The longest intent a login carries (`IRelyingPartyLoginOptions.intent`), in characters. */
|
|
48
|
+
export const RELYING_PARTY_MAX_INTENT_LENGTH = 4_096;
|
|
44
49
|
/** Attempts are swept once they are this much older than their expiry. */
|
|
45
50
|
const LOGIN_ATTEMPT_SWEEP_GRACE_MS = 60 * 60 * 1_000;
|
|
46
51
|
/** How many of them one sign-in removes; the next sign-in takes the following page. */
|
|
@@ -141,7 +146,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
141
146
|
public readonly loginPathPrefix: string;
|
|
142
147
|
public readonly sessionMaxAgeSeconds: number;
|
|
143
148
|
readonly #models: IRelyingPartyModels;
|
|
144
|
-
readonly #admit:
|
|
149
|
+
readonly #admit: (completionArg: IOidcCompletion, admissionArg: IRelyingPartyAdmission) => Promise<{ principalId: string }>;
|
|
145
150
|
/** the principal of a session when it may act right now, `null` otherwise */
|
|
146
151
|
readonly #activePrincipal: (principalIdArg: string) => Promise<{ principal: TPrincipal } | null>;
|
|
147
152
|
readonly #provider?: IOidcProvider;
|
|
@@ -195,7 +200,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
195
200
|
if ((typeof resolve === 'function') === (typeof isActive === 'function')) {
|
|
196
201
|
throw new Error('principals needs exactly one of isActive and resolve');
|
|
197
202
|
}
|
|
198
|
-
this.#admit = (completionArg) => principals.admit(completionArg);
|
|
203
|
+
this.#admit = (completionArg, admissionArg) => principals.admit(completionArg, admissionArg);
|
|
199
204
|
// choose the form by the same test the check above used, so a present but
|
|
200
205
|
// undefined `resolve` (a spread, a widened object) selects `isActive`
|
|
201
206
|
if (typeof resolve === 'function') {
|
|
@@ -243,16 +248,22 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
243
248
|
|
|
244
249
|
/**
|
|
245
250
|
* Opens a session the application authenticated itself (a local password
|
|
246
|
-
* login). It carries no provider tokens.
|
|
251
|
+
* login, a one-time link of its own). It carries no provider tokens.
|
|
252
|
+
* `maxAgeSeconds` makes it live shorter than `sessionMaxAgeSeconds`, the
|
|
253
|
+
* cookie with it; it is a whole number of seconds from 1 to that maximum.
|
|
247
254
|
*/
|
|
248
|
-
public async openSession(optionsArg: { principalId: string }): Promise<IRelyingPartySessionOpening> {
|
|
255
|
+
public async openSession(optionsArg: { principalId: string; maxAgeSeconds?: number }): Promise<IRelyingPartySessionOpening> {
|
|
249
256
|
if (typeof optionsArg?.principalId !== 'string' || optionsArg.principalId.length === 0) {
|
|
250
257
|
throw new Error('openSession needs a principal id');
|
|
251
258
|
}
|
|
259
|
+
const maxAgeSeconds = optionsArg.maxAgeSeconds ?? this.sessionMaxAgeSeconds;
|
|
260
|
+
if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
|
|
261
|
+
throw new RangeError(`openSession maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
|
|
262
|
+
}
|
|
252
263
|
const token = randomToken(32);
|
|
253
|
-
const session = this.newSessionDoc(token, optionsArg.principalId);
|
|
264
|
+
const session = this.newSessionDoc(token, optionsArg.principalId, maxAgeSeconds);
|
|
254
265
|
await session.save();
|
|
255
|
-
return this.opening(session, token);
|
|
266
|
+
return this.opening(session, token, maxAgeSeconds);
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
/** The live session behind the session cookie, or `null`. A read touches it at most once a minute. */
|
|
@@ -378,8 +389,15 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
378
389
|
* sealed next to it, and an intent cookie that binds the callback to this
|
|
379
390
|
* browser.
|
|
380
391
|
*/
|
|
381
|
-
public async startLogin(returnPathArg: unknown): Promise<IRelyingPartyLoginStart> {
|
|
392
|
+
public async startLogin(returnPathArg: unknown, optionsArg: IRelyingPartyLoginOptions = {}): Promise<IRelyingPartyLoginStart> {
|
|
382
393
|
const provider = this.requireProvider();
|
|
394
|
+
const { intent, prompt } = optionsArg;
|
|
395
|
+
if (intent !== undefined && (typeof intent !== 'string' || intent.length === 0 || intent.length > RELYING_PARTY_MAX_INTENT_LENGTH)) {
|
|
396
|
+
throw new RangeError(`a login intent is text of 1 to ${RELYING_PARTY_MAX_INTENT_LENGTH} characters`);
|
|
397
|
+
}
|
|
398
|
+
if (prompt !== undefined && !OIDC_PROMPTS.includes(prompt)) {
|
|
399
|
+
throw new RangeError(`a login prompt is one of ${OIDC_PROMPTS.join(', ')}`);
|
|
400
|
+
}
|
|
383
401
|
await this.sweepExpiredAttempts();
|
|
384
402
|
const state = randomToken(32);
|
|
385
403
|
const nonce = randomToken(32);
|
|
@@ -394,12 +412,15 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
394
412
|
attempt.sealedPkceVerifier = this.#secretBox.seal(pkceVerifier);
|
|
395
413
|
attempt.nonce = nonce;
|
|
396
414
|
attempt.returnPath = sanitizeReturnPath(returnPathArg, this.publicOrigin, this.loginPathPrefix);
|
|
415
|
+
if (intent !== undefined) {
|
|
416
|
+
attempt.sealedIntent = this.#secretBox.seal(intent);
|
|
417
|
+
}
|
|
397
418
|
attempt.createdAt = now;
|
|
398
419
|
attempt.expiresAt = now + LOGIN_ATTEMPT_MAX_AGE_SECONDS * 1_000;
|
|
399
420
|
await attempt.save();
|
|
400
421
|
|
|
401
422
|
return {
|
|
402
|
-
authorizationUrl: await provider.buildAuthorizationUrl({ state, nonce, pkceVerifier }),
|
|
423
|
+
authorizationUrl: await provider.buildAuthorizationUrl({ state, nonce, pkceVerifier, ...(prompt ? { prompt } : {}) }),
|
|
403
424
|
intentCookie: serializeCookie(this.intentCookieName, bindingToken, {
|
|
404
425
|
httpOnly: true,
|
|
405
426
|
secure: this.secureCookies,
|
|
@@ -456,7 +477,10 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
456
477
|
pkceVerifier: this.#secretBox.open(attempt.sealedPkceVerifier),
|
|
457
478
|
});
|
|
458
479
|
stage = 'principal_admission';
|
|
459
|
-
const { principalId } = await this.#admit(completion
|
|
480
|
+
const { principalId } = await this.#admit(completion, {
|
|
481
|
+
loginAttemptId: attempt.id,
|
|
482
|
+
...(attempt.sealedIntent ? { intent: this.#secretBox.open(attempt.sealedIntent) } : {}),
|
|
483
|
+
});
|
|
460
484
|
stage = 'session_persistence';
|
|
461
485
|
return await this.createProviderSession(attempt, principalId, completion);
|
|
462
486
|
} catch (error) {
|
|
@@ -696,7 +720,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
696
720
|
return this.#provider;
|
|
697
721
|
}
|
|
698
722
|
|
|
699
|
-
private newSessionDoc(tokenArg: string, principalIdArg: string): RelyingPartySessionDoc {
|
|
723
|
+
private newSessionDoc(tokenArg: string, principalIdArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): RelyingPartySessionDoc {
|
|
700
724
|
const now = this.#now();
|
|
701
725
|
const session = new this.#models.Session();
|
|
702
726
|
session.id = 'ses_' + randomToken(18);
|
|
@@ -704,7 +728,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
704
728
|
session.userId = principalIdArg;
|
|
705
729
|
session.createdAt = now;
|
|
706
730
|
session.lastSeenAt = now;
|
|
707
|
-
session.expiresAt = now +
|
|
731
|
+
session.expiresAt = now + maxAgeSecondsArg * 1_000;
|
|
708
732
|
return session;
|
|
709
733
|
}
|
|
710
734
|
|
|
@@ -712,7 +736,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
712
736
|
return deriveCsrfToken(tokenArg, this.#csrfKey, this.#csrfLabel);
|
|
713
737
|
}
|
|
714
738
|
|
|
715
|
-
private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string): IRelyingPartySessionOpening {
|
|
739
|
+
private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): IRelyingPartySessionOpening {
|
|
716
740
|
return {
|
|
717
741
|
session: this.snapshot(sessionArg),
|
|
718
742
|
csrfToken: this.csrfTokenOf(tokenArg),
|
|
@@ -720,7 +744,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
720
744
|
httpOnly: true,
|
|
721
745
|
secure: this.secureCookies,
|
|
722
746
|
sameSite: 'Lax',
|
|
723
|
-
maxAgeSeconds:
|
|
747
|
+
maxAgeSeconds: maxAgeSecondsArg,
|
|
724
748
|
}),
|
|
725
749
|
};
|
|
726
750
|
}
|
|
@@ -86,6 +86,10 @@ export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<
|
|
|
86
86
|
@plugins.nosqldb.svDb()
|
|
87
87
|
public returnPath: string = '/';
|
|
88
88
|
|
|
89
|
+
/** the application's intent for this login, sealed like the verifier (`IRelyingPartyLoginOptions.intent`); absent without one */
|
|
90
|
+
@plugins.nosqldb.svDb()
|
|
91
|
+
public sealedIntent?: string;
|
|
92
|
+
|
|
89
93
|
@plugins.nosqldb.svDb()
|
|
90
94
|
public createdAt: number = 0;
|
|
91
95
|
|
|
@@ -1,21 +1,35 @@
|
|
|
1
|
-
import * as plugins from './plugins.js';
|
|
2
1
|
import type { RelyingPartySessions } from './classes.relyingpartysessions.js';
|
|
3
2
|
import { sha256, timingSafeEqualString } from './helpers.crypto.js';
|
|
4
3
|
import type { IRelyingPartySession } from './interfaces.js';
|
|
5
4
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The WebSocket peer underneath a connection, as far as the binding reads it:
|
|
7
|
+
* the headers its upgrade arrived with. A SmartServe peer of any major fits.
|
|
8
|
+
*/
|
|
9
|
+
export type TRelyingPartySocketPeer = {
|
|
10
|
+
context: { headers: { get(nameArg: string): string | null } };
|
|
11
|
+
};
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
/** A server connection as the binding reads it: its peer. A TypedSocket connection wrapper of any major fits. */
|
|
14
|
+
export type TRelyingPartySocketConnection = {
|
|
15
|
+
peer: TRelyingPartySocketPeer;
|
|
16
|
+
};
|
|
13
17
|
|
|
14
|
-
/**
|
|
15
|
-
export type
|
|
18
|
+
/** A connection or its peer; `TypedSocket.setServerTag` takes either. */
|
|
19
|
+
export type TRelyingPartySocketTarget<TConnection extends TRelyingPartySocketConnection = TRelyingPartySocketConnection> =
|
|
20
|
+
| TConnection
|
|
21
|
+
| TConnection['peer'];
|
|
16
22
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The part of the application's server `TypedSocket` the binding uses. The
|
|
25
|
+
* binding names no TypedSocket or SmartServe version: the application's own
|
|
26
|
+
* transport fits whichever major it runs, and `connectionsOfSession` answers
|
|
27
|
+
* its own connection type.
|
|
28
|
+
*/
|
|
29
|
+
export type TRelyingPartySocketTransport<TConnection extends TRelyingPartySocketConnection = TRelyingPartySocketConnection> = {
|
|
30
|
+
setServerTag(targetArg: TRelyingPartySocketTarget<TConnection>, nameArg: string): void;
|
|
31
|
+
findAllTargetConnectionsByTag(nameArg: string): Promise<TConnection[]>;
|
|
32
|
+
};
|
|
19
33
|
|
|
20
34
|
const peerOf = (targetArg: TRelyingPartySocketTarget): TRelyingPartySocketPeer =>
|
|
21
35
|
'peer' in targetArg ? targetArg.peer : targetArg;
|
|
@@ -37,10 +51,13 @@ export type TRelyingPartySocketRegistration =
|
|
|
37
51
|
| { registered: true; pinned: IRelyingPartyPinnedSession; expiresAt: number }
|
|
38
52
|
| { registered: false; refusal: IRelyingPartySocketRefusal };
|
|
39
53
|
|
|
40
|
-
export interface IRelyingPartySocketBindingOptions<
|
|
54
|
+
export interface IRelyingPartySocketBindingOptions<
|
|
55
|
+
TPrincipal extends object | undefined = undefined,
|
|
56
|
+
TConnection extends TRelyingPartySocketConnection = TRelyingPartySocketConnection,
|
|
57
|
+
> {
|
|
41
58
|
sessions: RelyingPartySessions<TPrincipal>;
|
|
42
59
|
/** the transport typedserver created; `undefined` before the server runs */
|
|
43
|
-
transport: () => TRelyingPartySocketTransport | undefined;
|
|
60
|
+
transport: () => TRelyingPartySocketTransport<TConnection> | undefined;
|
|
44
61
|
/** the server-owned tag prefix that finds the connections of a session */
|
|
45
62
|
tagPrefix?: string;
|
|
46
63
|
}
|
|
@@ -54,12 +71,15 @@ export interface IRelyingPartySocketBindingOptions<TPrincipal extends object | u
|
|
|
54
71
|
* request. Connection limits, rate limits and sweeps belong to the
|
|
55
72
|
* application's socket gate.
|
|
56
73
|
*/
|
|
57
|
-
export class RelyingPartySocketBinding<
|
|
74
|
+
export class RelyingPartySocketBinding<
|
|
75
|
+
TPrincipal extends object | undefined = undefined,
|
|
76
|
+
TConnection extends TRelyingPartySocketConnection = TRelyingPartySocketConnection,
|
|
77
|
+
> {
|
|
58
78
|
readonly #sessions: RelyingPartySessions<TPrincipal>;
|
|
59
|
-
readonly #transport: () => TRelyingPartySocketTransport | undefined;
|
|
79
|
+
readonly #transport: () => TRelyingPartySocketTransport<TConnection> | undefined;
|
|
60
80
|
public readonly tagPrefix: string;
|
|
61
81
|
|
|
62
|
-
constructor(optionsArg: IRelyingPartySocketBindingOptions<TPrincipal>) {
|
|
82
|
+
constructor(optionsArg: IRelyingPartySocketBindingOptions<TPrincipal, TConnection>) {
|
|
63
83
|
this.#sessions = optionsArg.sessions;
|
|
64
84
|
this.#transport = optionsArg.transport;
|
|
65
85
|
this.tagPrefix = optionsArg.tagPrefix ?? 'idp_rp_session:';
|
|
@@ -112,7 +132,7 @@ export class RelyingPartySocketBinding<TPrincipal extends object | undefined = u
|
|
|
112
132
|
* Tags a connection — its wrapper or its peer — with its session; throws
|
|
113
133
|
* when the transport is not running or the connection is gone.
|
|
114
134
|
*/
|
|
115
|
-
public tagConnection(targetArg: TRelyingPartySocketTarget
|
|
135
|
+
public tagConnection(targetArg: TRelyingPartySocketTarget<TConnection>, sessionIdArg: string): void {
|
|
116
136
|
const transport = this.#transport();
|
|
117
137
|
if (!transport) {
|
|
118
138
|
throw new Error('The socket transport is not running');
|
|
@@ -146,7 +166,7 @@ export class RelyingPartySocketBinding<TPrincipal extends object | undefined = u
|
|
|
146
166
|
}
|
|
147
167
|
|
|
148
168
|
/** The live connections of one session, for closing them when it ends. */
|
|
149
|
-
public async connectionsOfSession(sessionIdArg: string): Promise<readonly
|
|
169
|
+
public async connectionsOfSession(sessionIdArg: string): Promise<readonly TConnection[]> {
|
|
150
170
|
const transport = this.#transport();
|
|
151
171
|
if (!transport) {
|
|
152
172
|
return [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IOidcCompletion, TOidcCompletionFailureStage, TOidcTokenHint } from './classes.idpoidcprovider.js';
|
|
1
|
+
import type { IOidcCompletion, TOidcCompletionFailureStage, TOidcPrompt, TOidcTokenHint } from './classes.idpoidcprovider.js';
|
|
2
2
|
|
|
3
3
|
/** A session as the application reads it; no token and no sealed value is part of it. */
|
|
4
4
|
export interface IRelyingPartySession {
|
|
@@ -35,6 +35,27 @@ export interface IRelyingPartySessionOpening {
|
|
|
35
35
|
sessionCookie: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* What `startLogin` carries besides the return path. `intent` is the
|
|
40
|
+
* application's own text — the invitation a login accepts, the account it
|
|
41
|
+
* links, the client it authorizes — sealed into the login attempt and handed
|
|
42
|
+
* to admission (`IRelyingPartyAdmission`) when exactly this login comes back;
|
|
43
|
+
* it never travels through the browser or the provider.
|
|
44
|
+
*/
|
|
45
|
+
export interface IRelyingPartyLoginOptions {
|
|
46
|
+
/** at most `RELYING_PARTY_MAX_INTENT_LENGTH` characters, not empty */
|
|
47
|
+
intent?: string;
|
|
48
|
+
prompt?: TOidcPrompt;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What admission learns about the login besides the provider's completion. */
|
|
52
|
+
export interface IRelyingPartyAdmission {
|
|
53
|
+
/** the intent `startLogin` was given for this login, opened from its seal; absent when there was none */
|
|
54
|
+
intent?: string;
|
|
55
|
+
/** the login attempt the session will name (`IRelyingPartySession.loginAttemptId`) */
|
|
56
|
+
loginAttemptId: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
/** The redirect that starts a provider login, and the cookie that binds it to this browser. */
|
|
39
60
|
export interface IRelyingPartyLoginStart {
|
|
40
61
|
authorizationUrl: URL;
|
|
@@ -66,7 +87,7 @@ export interface IAuthorityTokenSource {
|
|
|
66
87
|
* then handed back and the refusal reaches the caller unchanged.
|
|
67
88
|
*/
|
|
68
89
|
export interface IRelyingPartyPrincipals {
|
|
69
|
-
admit(completionArg: IOidcCompletion): Promise<{ principalId: string }>;
|
|
90
|
+
admit(completionArg: IOidcCompletion, admissionArg: IRelyingPartyAdmission): Promise<{ principalId: string }>;
|
|
70
91
|
/** Whether a principal may act right now; a session of an inactive principal resolves to nothing. */
|
|
71
92
|
isActive(principalIdArg: string): Promise<boolean>;
|
|
72
93
|
}
|
|
@@ -78,7 +99,7 @@ export interface IRelyingPartyPrincipals {
|
|
|
78
99
|
* `principal`. Give either `isActive` or `resolve`, never both.
|
|
79
100
|
*/
|
|
80
101
|
export interface IRelyingPartyPrincipalResolver<TPrincipal extends object | undefined> {
|
|
81
|
-
admit(completionArg: IOidcCompletion): Promise<{ principalId: string }>;
|
|
102
|
+
admit(completionArg: IOidcCompletion, admissionArg: IRelyingPartyAdmission): Promise<{ principalId: string }>;
|
|
82
103
|
/** The principal object when it may act right now, `null` otherwise; any answer that is not an object (including `false`) resolves the session to nothing. */
|
|
83
104
|
resolve(principalIdArg: string): Promise<TPrincipal | null>;
|
|
84
105
|
}
|
|
@@ -4,9 +4,8 @@ import * as crypto from 'node:crypto';
|
|
|
4
4
|
export { asyncHooks, crypto };
|
|
5
5
|
|
|
6
6
|
import * as typedrequest from '@api.global/typedrequest';
|
|
7
|
-
import * as typedsocket from '@api.global/typedsocket';
|
|
8
7
|
|
|
9
|
-
export { typedrequest
|
|
8
|
+
export { typedrequest };
|
|
10
9
|
|
|
11
10
|
import * as nosqldb from '@lossless.org/client/nosqldb';
|
|
12
11
|
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
OidcCompletionError,
|
|
4
|
+
OidcRefreshRefusedError,
|
|
5
|
+
type IOidcAuthorizationOptions,
|
|
6
|
+
type IOidcCompletion,
|
|
7
|
+
type IOidcCompletionOptions,
|
|
8
|
+
type IOidcOrganizationClaim,
|
|
9
|
+
type IOidcOrganizationObservation,
|
|
10
|
+
type IOidcProvider,
|
|
11
|
+
type IOidcRefreshedTokens,
|
|
12
|
+
type TOidcTokenHint,
|
|
13
|
+
} from './classes.idpoidcprovider.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `@idp.global/sdk/relyingparty/testing`: an in-process idp.global for the
|
|
17
|
+
* tests of an application built on `RelyingPartySessions`. It is a test
|
|
18
|
+
* double and nothing else — no socket, no persistence, no check a real
|
|
19
|
+
* provider makes beyond the ones named here — and belongs in test code only.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A person at the double, with the organizations idp.global reports for them. */
|
|
23
|
+
export interface IProviderDoublePersona {
|
|
24
|
+
/** the stable `sub` */
|
|
25
|
+
subject: string;
|
|
26
|
+
displayName?: string;
|
|
27
|
+
email?: string;
|
|
28
|
+
/** `email_verified`; `true` when absent, as for an active idp.global account */
|
|
29
|
+
emailVerified?: boolean;
|
|
30
|
+
/** the `organizations` claim, and what introspection answers for each organization */
|
|
31
|
+
organizations?: IOidcOrganizationClaim[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IProviderDoubleOptions {
|
|
35
|
+
/** the issuer origin the authorization URLs point at; `https://idp.test` by default */
|
|
36
|
+
issuer?: string;
|
|
37
|
+
/** how long an access token lives, in seconds; 3600 by default, `0` names no lifetime */
|
|
38
|
+
accessTokenLifetimeSeconds?: number;
|
|
39
|
+
/** the clock every expiry reads; tests step it instead of sleeping */
|
|
40
|
+
now?: () => number;
|
|
41
|
+
requestTimeoutSeconds?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface IIssuedCode {
|
|
45
|
+
state: string;
|
|
46
|
+
nonce: string;
|
|
47
|
+
subject: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface IIssuedToken {
|
|
51
|
+
subject: string;
|
|
52
|
+
family: string;
|
|
53
|
+
/** epoch milliseconds; `0` for no expiry */
|
|
54
|
+
expiresAt: number;
|
|
55
|
+
kind: 'access' | 'refresh';
|
|
56
|
+
revoked: boolean;
|
|
57
|
+
/** a refresh token that was rotated: using it again is reuse */
|
|
58
|
+
used: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const randomToken = (): string => plugins.crypto.randomBytes(18).toString('base64url');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The provider as `IOidcProvider` sees it. A test adds personas, names the one
|
|
65
|
+
* the next login signs in as (`signInNextAs`), follows the authorization URL
|
|
66
|
+
* back with `callbackUrlFor`, and changes what idp.global reports live
|
|
67
|
+
* (`setOrganizationRoles`, `endLoginsOf`). Codes are single use and bound to
|
|
68
|
+
* their login's state; access tokens introspect for their subject while they
|
|
69
|
+
* live; refresh tokens rotate, and a superseded one coming back revokes its
|
|
70
|
+
* whole family, as idp.global's reuse detection does. Every call is recorded,
|
|
71
|
+
* and each step can be made to fail.
|
|
72
|
+
*/
|
|
73
|
+
export class RelyingPartyProviderDouble implements IOidcProvider {
|
|
74
|
+
public readonly requestTimeoutSeconds: number;
|
|
75
|
+
public readonly issuer: string;
|
|
76
|
+
public accessTokenLifetimeSeconds: number;
|
|
77
|
+
|
|
78
|
+
public readonly authorizations: IOidcAuthorizationOptions[] = [];
|
|
79
|
+
public readonly completions: Array<{ subject: string; state: string }> = [];
|
|
80
|
+
public readonly revocations: Array<{ token: string; hint: TOidcTokenHint }> = [];
|
|
81
|
+
public readonly introspections: Array<{ accessToken: string; subject: string; organizationId: string }> = [];
|
|
82
|
+
public readonly refreshes: string[] = [];
|
|
83
|
+
|
|
84
|
+
/** when set, the completion of a login fails with it (an `OidcCompletionError` names its stage) */
|
|
85
|
+
public completionFailure?: Error;
|
|
86
|
+
/** when set, every introspection fails with it (an `OidcAuthorityUnavailableError` for an unreachable provider) */
|
|
87
|
+
public introspectionFailure?: Error;
|
|
88
|
+
/** when set, every refresh fails with it */
|
|
89
|
+
public refreshFailure?: Error;
|
|
90
|
+
/** when set, every revocation fails with it, after it was recorded */
|
|
91
|
+
public revocationFailure?: Error;
|
|
92
|
+
/** runs inside an introspection before it answers: a test holds one open with it */
|
|
93
|
+
public onIntrospect?: (optionsArg: { accessToken: string; subject: string; organizationId: string }) => Promise<void>;
|
|
94
|
+
/** runs inside a refresh before it answers: a test races a logout with it */
|
|
95
|
+
public onRefresh?: (refreshTokenArg: string) => Promise<void>;
|
|
96
|
+
|
|
97
|
+
readonly #now: () => number;
|
|
98
|
+
readonly #personas = new Map<string, IProviderDoublePersona>();
|
|
99
|
+
readonly #codes = new Map<string, IIssuedCode>();
|
|
100
|
+
readonly #tokens = new Map<string, IIssuedToken>();
|
|
101
|
+
#next?: string;
|
|
102
|
+
|
|
103
|
+
constructor(optionsArg: IProviderDoubleOptions = {}) {
|
|
104
|
+
this.issuer = new URL(optionsArg.issuer ?? 'https://idp.test').origin;
|
|
105
|
+
this.accessTokenLifetimeSeconds = optionsArg.accessTokenLifetimeSeconds ?? 3_600;
|
|
106
|
+
this.requestTimeoutSeconds = optionsArg.requestTimeoutSeconds ?? 10;
|
|
107
|
+
this.#now = optionsArg.now ?? (() => Date.now());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- personas ----
|
|
111
|
+
|
|
112
|
+
/** Adds a persona, or replaces the one with its subject. */
|
|
113
|
+
public addPersona(personaArg: IProviderDoublePersona): void {
|
|
114
|
+
this.#personas.set(personaArg.subject, {
|
|
115
|
+
...personaArg,
|
|
116
|
+
organizations: (personaArg.organizations ?? []).map((entryArg) => ({ ...entryArg, roles: [...entryArg.roles] })),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public persona(subjectArg: string): IProviderDoublePersona | undefined {
|
|
121
|
+
return this.#personas.get(subjectArg);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The persona the next authorization signs in as; a persona given whole is added first. */
|
|
125
|
+
public signInNextAs(personaArg: string | IProviderDoublePersona): void {
|
|
126
|
+
if (typeof personaArg !== 'string') {
|
|
127
|
+
this.addPersona(personaArg);
|
|
128
|
+
}
|
|
129
|
+
const subject = typeof personaArg === 'string' ? personaArg : personaArg.subject;
|
|
130
|
+
if (!this.#personas.has(subject)) {
|
|
131
|
+
throw new Error(`RelyingPartyProviderDouble: no persona ${subject}; addPersona first`);
|
|
132
|
+
}
|
|
133
|
+
this.#next = subject;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What idp.global reports for the persona in an organization from now on:
|
|
138
|
+
* its roles, or `null` when the persona is no member any more. Introspection
|
|
139
|
+
* reads it at once; the claims of a login are those of its sign-in.
|
|
140
|
+
*/
|
|
141
|
+
public setOrganizationRoles(subjectArg: string, organizationIdArg: string, rolesArg: string[] | null): void {
|
|
142
|
+
const persona = this.requirePersona(subjectArg);
|
|
143
|
+
const organizations = (persona.organizations ?? []).filter((entryArg) => entryArg.id !== organizationIdArg);
|
|
144
|
+
if (rolesArg) {
|
|
145
|
+
const known = persona.organizations?.find((entryArg) => entryArg.id === organizationIdArg);
|
|
146
|
+
organizations.push({
|
|
147
|
+
id: organizationIdArg,
|
|
148
|
+
name: known?.name ?? organizationIdArg,
|
|
149
|
+
slug: known?.slug ?? organizationIdArg,
|
|
150
|
+
roles: [...rolesArg],
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
persona.organizations = organizations;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Ends every login of the persona at the provider: its tokens neither introspect nor rotate any more. */
|
|
157
|
+
public endLoginsOf(subjectArg: string): void {
|
|
158
|
+
for (const token of this.#tokens.values()) {
|
|
159
|
+
if (token.subject === subjectArg) {
|
|
160
|
+
token.revoked = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Where the provider would send the browser back after the login this authorization URL started. */
|
|
166
|
+
public callbackUrlFor(authorizationUrlArg: URL, redirectUriArg: URL | string): URL {
|
|
167
|
+
const callback = new URL(redirectUriArg);
|
|
168
|
+
callback.searchParams.set('code', authorizationUrlArg.searchParams.get('code') ?? '');
|
|
169
|
+
callback.searchParams.set('state', authorizationUrlArg.searchParams.get('state') ?? '');
|
|
170
|
+
return callback;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- IOidcProvider ----
|
|
174
|
+
|
|
175
|
+
public async init(): Promise<void> {}
|
|
176
|
+
|
|
177
|
+
public async buildAuthorizationUrl(optionsArg: IOidcAuthorizationOptions): Promise<URL> {
|
|
178
|
+
if (!this.#next) {
|
|
179
|
+
throw new Error('RelyingPartyProviderDouble: name the persona this login signs in as with signInNextAs');
|
|
180
|
+
}
|
|
181
|
+
this.authorizations.push({ ...optionsArg });
|
|
182
|
+
const code = 'code_' + randomToken();
|
|
183
|
+
this.#codes.set(code, { state: optionsArg.state, nonce: optionsArg.nonce, subject: this.#next });
|
|
184
|
+
const url = new URL('/oauth/authorize', this.issuer);
|
|
185
|
+
url.searchParams.set('response_type', 'code');
|
|
186
|
+
url.searchParams.set('state', optionsArg.state);
|
|
187
|
+
url.searchParams.set('nonce', optionsArg.nonce);
|
|
188
|
+
if (optionsArg.prompt) {
|
|
189
|
+
url.searchParams.set('prompt', optionsArg.prompt);
|
|
190
|
+
}
|
|
191
|
+
// what the double hands back at once; a real provider answers it after the login page
|
|
192
|
+
url.searchParams.set('code', code);
|
|
193
|
+
return url;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
public async completeAuthorization(optionsArg: IOidcCompletionOptions): Promise<IOidcCompletion> {
|
|
197
|
+
const code = optionsArg.callbackUrl.searchParams.get('code') ?? '';
|
|
198
|
+
const issued = this.#codes.get(code);
|
|
199
|
+
this.#codes.delete(code);
|
|
200
|
+
if (!issued || issued.state !== optionsArg.state) {
|
|
201
|
+
throw new OidcCompletionError('token_exchange', new Error('unknown, used or foreign code'));
|
|
202
|
+
}
|
|
203
|
+
if (issued.nonce !== optionsArg.nonce) {
|
|
204
|
+
throw new OidcCompletionError('token_validation', new Error('nonce mismatch'));
|
|
205
|
+
}
|
|
206
|
+
this.completions.push({ subject: issued.subject, state: issued.state });
|
|
207
|
+
if (this.completionFailure) {
|
|
208
|
+
throw this.completionFailure;
|
|
209
|
+
}
|
|
210
|
+
const persona = this.requirePersona(issued.subject);
|
|
211
|
+
const family = randomToken();
|
|
212
|
+
const access = this.issue('access', issued.subject, family);
|
|
213
|
+
const refresh = this.issue('refresh', issued.subject, family);
|
|
214
|
+
return {
|
|
215
|
+
subject: persona.subject,
|
|
216
|
+
accessToken: access.token,
|
|
217
|
+
refreshToken: refresh.token,
|
|
218
|
+
accessTokenExpiresAt: access.expiresAt,
|
|
219
|
+
...(persona.email !== undefined ? { email: persona.email } : {}),
|
|
220
|
+
emailVerified: persona.emailVerified ?? true,
|
|
221
|
+
...(persona.displayName !== undefined ? { displayName: persona.displayName } : {}),
|
|
222
|
+
organizations: (persona.organizations ?? []).map((entryArg) => ({ ...entryArg, roles: [...entryArg.roles] })),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public async revokeToken(tokenArg: string, hintArg: TOidcTokenHint): Promise<void> {
|
|
227
|
+
this.revocations.push({ token: tokenArg, hint: hintArg });
|
|
228
|
+
if (this.revocationFailure) {
|
|
229
|
+
throw this.revocationFailure;
|
|
230
|
+
}
|
|
231
|
+
const token = this.#tokens.get(tokenArg);
|
|
232
|
+
if (token) {
|
|
233
|
+
token.revoked = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
public async introspectOrganization(optionsArg: {
|
|
238
|
+
accessToken: string;
|
|
239
|
+
subject: string;
|
|
240
|
+
organizationId: string;
|
|
241
|
+
}): Promise<IOidcOrganizationObservation> {
|
|
242
|
+
this.introspections.push({ ...optionsArg });
|
|
243
|
+
await this.onIntrospect?.({ ...optionsArg });
|
|
244
|
+
if (this.introspectionFailure) {
|
|
245
|
+
throw this.introspectionFailure;
|
|
246
|
+
}
|
|
247
|
+
const token = this.#tokens.get(optionsArg.accessToken);
|
|
248
|
+
const inactive: IOidcOrganizationObservation = { active: false, roles: [], expiresAt: 0 };
|
|
249
|
+
if (!token || token.kind !== 'access' || !this.isLive(token) || token.subject !== optionsArg.subject) {
|
|
250
|
+
return inactive;
|
|
251
|
+
}
|
|
252
|
+
const membership = this.#personas.get(token.subject)?.organizations?.find((entryArg) => entryArg.id === optionsArg.organizationId);
|
|
253
|
+
if (!membership) {
|
|
254
|
+
return inactive;
|
|
255
|
+
}
|
|
256
|
+
return { active: true, roles: [...membership.roles].sort(), expiresAt: token.expiresAt };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
public async refreshTokens(refreshTokenArg: string): Promise<IOidcRefreshedTokens> {
|
|
260
|
+
this.refreshes.push(refreshTokenArg);
|
|
261
|
+
await this.onRefresh?.(refreshTokenArg);
|
|
262
|
+
if (this.refreshFailure) {
|
|
263
|
+
throw this.refreshFailure;
|
|
264
|
+
}
|
|
265
|
+
const token = this.#tokens.get(refreshTokenArg);
|
|
266
|
+
if (!token || token.kind !== 'refresh' || token.revoked) {
|
|
267
|
+
throw new OidcRefreshRefusedError(new Error('invalid_grant'));
|
|
268
|
+
}
|
|
269
|
+
if (token.used) {
|
|
270
|
+
// reuse of a superseded refresh token: the family dies
|
|
271
|
+
for (const entry of this.#tokens.values()) {
|
|
272
|
+
if (entry.family === token.family) {
|
|
273
|
+
entry.revoked = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
throw new OidcRefreshRefusedError(new Error('invalid_grant'));
|
|
277
|
+
}
|
|
278
|
+
token.used = true;
|
|
279
|
+
const access = this.issue('access', token.subject, token.family);
|
|
280
|
+
const refresh = this.issue('refresh', token.subject, token.family);
|
|
281
|
+
return { accessToken: access.token, refreshToken: refresh.token, accessTokenExpiresAt: access.expiresAt };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---- internals ----
|
|
285
|
+
|
|
286
|
+
private requirePersona(subjectArg: string): IProviderDoublePersona {
|
|
287
|
+
const persona = this.#personas.get(subjectArg);
|
|
288
|
+
if (!persona) {
|
|
289
|
+
throw new Error(`RelyingPartyProviderDouble: no persona ${subjectArg}`);
|
|
290
|
+
}
|
|
291
|
+
return persona;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private issue(kindArg: IIssuedToken['kind'], subjectArg: string, familyArg: string): { token: string; expiresAt: number } {
|
|
295
|
+
const token = (kindArg === 'access' ? 'at_' : 'rt_') + randomToken();
|
|
296
|
+
const expiresAt = kindArg === 'access' && this.accessTokenLifetimeSeconds > 0
|
|
297
|
+
? this.#now() + this.accessTokenLifetimeSeconds * 1_000
|
|
298
|
+
: 0;
|
|
299
|
+
this.#tokens.set(token, { subject: subjectArg, family: familyArg, expiresAt, kind: kindArg, revoked: false, used: false });
|
|
300
|
+
return { token, expiresAt };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private isLive(tokenArg: IIssuedToken): boolean {
|
|
304
|
+
return !tokenArg.revoked && (tokenArg.expiresAt === 0 || tokenArg.expiresAt > this.#now());
|
|
305
|
+
}
|
|
306
|
+
}
|