@idp.global/sdk 18.1.0 → 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 +11 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_relyingparty/classes.errors.d.ts +4 -0
- package/dist_ts_relyingparty/classes.errors.js +8 -1
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +26 -8
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +19 -3
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +22 -9
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +50 -11
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +10 -2
- package/dist_ts_relyingparty/classes.rpmodels.js +26 -7
- package/dist_ts_relyingparty/classes.socketbinding.d.ts +18 -9
- package/dist_ts_relyingparty/classes.socketbinding.js +17 -11
- package/dist_ts_relyingparty/interfaces.d.ts +32 -3
- package/package.json +1 -1
- package/readme.md +62 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.errors.ts +8 -0
- package/ts_relyingparty/classes.relyingpartyroutes.ts +46 -13
- package/ts_relyingparty/classes.relyingpartysessions.ts +65 -18
- package/ts_relyingparty/classes.rpmodels.ts +31 -7
- package/ts_relyingparty/classes.socketbinding.ts +30 -16
- package/ts_relyingparty/interfaces.ts +35 -3
|
@@ -22,12 +22,13 @@ import type {
|
|
|
22
22
|
IProviderToken,
|
|
23
23
|
IRelyingPartyEvent,
|
|
24
24
|
IRelyingPartyHooks,
|
|
25
|
+
IRelyingPartyLiveSession,
|
|
25
26
|
IRelyingPartyLoginStart,
|
|
26
|
-
IRelyingPartyPrincipals,
|
|
27
27
|
IRelyingPartySession,
|
|
28
28
|
IRelyingPartySessionOpening,
|
|
29
29
|
IResolvedRelyingPartySession,
|
|
30
30
|
TLoginCallbackStage,
|
|
31
|
+
TRelyingPartyPrincipals,
|
|
31
32
|
TRelyingPartySessionEndReason,
|
|
32
33
|
} from './interfaces.js';
|
|
33
34
|
|
|
@@ -57,9 +58,13 @@ export const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000;
|
|
|
57
58
|
*/
|
|
58
59
|
export const REFRESH_LOCK_MARGIN_MS = 20_000;
|
|
59
60
|
|
|
60
|
-
export interface IRelyingPartySessionsOptions {
|
|
61
|
+
export interface IRelyingPartySessionsOptions<TPrincipal extends object | undefined = undefined> {
|
|
61
62
|
models: IRelyingPartyModels;
|
|
62
|
-
|
|
63
|
+
/**
|
|
64
|
+
* `isActive` principals, or a `resolve` that answers the principal itself;
|
|
65
|
+
* its answer is then the `principal` of every live session.
|
|
66
|
+
*/
|
|
67
|
+
principals: TRelyingPartyPrincipals<TPrincipal>;
|
|
63
68
|
/** absent: no provider login; sessions are opened by the application only */
|
|
64
69
|
provider?: IOidcProvider;
|
|
65
70
|
/** the application's public origin, e.g. `https://app.example`; cookies are `Secure` exactly on https */
|
|
@@ -122,9 +127,13 @@ const cookieNameFor = (
|
|
|
122
127
|
*
|
|
123
128
|
* Nothing here runs on a timer: expired login attempts are swept when the next
|
|
124
129
|
* login starts, and the only timer — the revocation budget — is unreferenced
|
|
125
|
-
* and cleared on every path.
|
|
130
|
+
* and cleared on every path. Nothing here does I/O while it is constructed,
|
|
131
|
+
* so it is built before the database connects.
|
|
132
|
+
*
|
|
133
|
+
* `TPrincipal` is what a `principals.resolve` answers; with `isActive`
|
|
134
|
+
* principals it stays `undefined`.
|
|
126
135
|
*/
|
|
127
|
-
export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
136
|
+
export class RelyingPartySessions<TPrincipal extends object | undefined = undefined> implements IAuthorityTokenSource {
|
|
128
137
|
public readonly publicOrigin: string;
|
|
129
138
|
public readonly secureCookies: boolean;
|
|
130
139
|
public readonly sessionCookieName: string;
|
|
@@ -132,7 +141,9 @@ export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
|
132
141
|
public readonly loginPathPrefix: string;
|
|
133
142
|
public readonly sessionMaxAgeSeconds: number;
|
|
134
143
|
readonly #models: IRelyingPartyModels;
|
|
135
|
-
readonly #
|
|
144
|
+
readonly #admit: TRelyingPartyPrincipals<TPrincipal>['admit'];
|
|
145
|
+
/** the principal of a session when it may act right now, `null` otherwise */
|
|
146
|
+
readonly #activePrincipal: (principalIdArg: string) => Promise<{ principal: TPrincipal } | null>;
|
|
136
147
|
readonly #provider?: IOidcProvider;
|
|
137
148
|
readonly #secretBox: SecretBox;
|
|
138
149
|
readonly #csrfKey: Buffer;
|
|
@@ -142,7 +153,7 @@ export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
|
142
153
|
/** one rotation per session in this process, however many requests need it at once */
|
|
143
154
|
readonly #refreshesInFlight = new Map<string, Promise<IProviderToken>>();
|
|
144
155
|
|
|
145
|
-
constructor(optionsArg: IRelyingPartySessionsOptions) {
|
|
156
|
+
constructor(optionsArg: IRelyingPartySessionsOptions<TPrincipal>) {
|
|
146
157
|
const origin = new URL(optionsArg.publicOrigin);
|
|
147
158
|
if (origin.origin !== optionsArg.publicOrigin) {
|
|
148
159
|
throw new Error('publicOrigin must be a bare origin such as https://app.example');
|
|
@@ -175,7 +186,34 @@ export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
|
175
186
|
}
|
|
176
187
|
this.sessionMaxAgeSeconds = maxAge;
|
|
177
188
|
this.#models = optionsArg.models;
|
|
178
|
-
|
|
189
|
+
const principals = optionsArg.principals;
|
|
190
|
+
const resolve: unknown = principals && Reflect.get(principals, 'resolve');
|
|
191
|
+
const isActive: unknown = principals && Reflect.get(principals, 'isActive');
|
|
192
|
+
if (!principals || typeof principals.admit !== 'function') {
|
|
193
|
+
throw new Error('principals needs an admit function');
|
|
194
|
+
}
|
|
195
|
+
if ((typeof resolve === 'function') === (typeof isActive === 'function')) {
|
|
196
|
+
throw new Error('principals needs exactly one of isActive and resolve');
|
|
197
|
+
}
|
|
198
|
+
this.#admit = (completionArg) => principals.admit(completionArg);
|
|
199
|
+
// choose the form by the same test the check above used, so a present but
|
|
200
|
+
// undefined `resolve` (a spread, a widened object) selects `isActive`
|
|
201
|
+
if (typeof resolve === 'function') {
|
|
202
|
+
const resolvePrincipal = resolve as (principalIdArg: string) => Promise<TPrincipal | null | undefined>;
|
|
203
|
+
this.#activePrincipal = async (principalIdArg) => {
|
|
204
|
+
const principal = await resolvePrincipal.call(principals, principalIdArg);
|
|
205
|
+
// only an object is a principal: null, undefined and any primitive such as
|
|
206
|
+
// `false` (an easy slip when moving from isActive) mean "not active"
|
|
207
|
+
return typeof principal === 'object' && principal !== null ? { principal } : null;
|
|
208
|
+
};
|
|
209
|
+
} else {
|
|
210
|
+
const principalIsActive = isActive as (principalIdArg: string) => Promise<boolean>;
|
|
211
|
+
this.#activePrincipal = async (principalIdArg) =>
|
|
212
|
+
(await principalIsActive.call(principals, principalIdArg))
|
|
213
|
+
// the isActive form exists only while TPrincipal is `undefined`
|
|
214
|
+
? { principal: undefined as TPrincipal }
|
|
215
|
+
: null;
|
|
216
|
+
}
|
|
179
217
|
this.#provider = optionsArg.provider;
|
|
180
218
|
this.#secretBox = new SecretBox(optionsArg.sessionEncryptionKey);
|
|
181
219
|
this.#csrfKey = Buffer.from(optionsArg.csrfKey);
|
|
@@ -220,7 +258,7 @@ export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
|
220
258
|
/** The live session behind the session cookie, or `null`. A read touches it at most once a minute. */
|
|
221
259
|
public async resolveCookieSession(
|
|
222
260
|
cookieHeaderArg: string | null | undefined,
|
|
223
|
-
): Promise<IResolvedRelyingPartySession | null> {
|
|
261
|
+
): Promise<IResolvedRelyingPartySession<TPrincipal> | null> {
|
|
224
262
|
const token = parseCookies(cookieHeaderArg).get(this.sessionCookieName);
|
|
225
263
|
if (!token || token.length > MAX_COOKIE_TOKEN_LENGTH) {
|
|
226
264
|
return null;
|
|
@@ -230,7 +268,7 @@ export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
|
230
268
|
if (!live) {
|
|
231
269
|
return null;
|
|
232
270
|
}
|
|
233
|
-
return { session: this.snapshot(live), csrfToken: this.csrfTokenOf(token) };
|
|
271
|
+
return { session: this.snapshot(live.document), principal: live.principal, csrfToken: this.csrfTokenOf(token) };
|
|
234
272
|
}
|
|
235
273
|
|
|
236
274
|
/**
|
|
@@ -239,11 +277,16 @@ export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
|
239
277
|
* with. What a pinned socket connection runs per request.
|
|
240
278
|
*/
|
|
241
279
|
public async revalidateSession(sessionIdArg: string): Promise<IRelyingPartySession | null> {
|
|
280
|
+
return (await this.revalidateLiveSession(sessionIdArg))?.session ?? null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** `revalidateSession`, answering the principal the same read resolved as well. */
|
|
284
|
+
public async revalidateLiveSession(sessionIdArg: string): Promise<IRelyingPartyLiveSession<TPrincipal> | null> {
|
|
242
285
|
if (typeof sessionIdArg !== 'string' || sessionIdArg.length === 0) {
|
|
243
286
|
return null;
|
|
244
287
|
}
|
|
245
288
|
const live = await this.liveSession(await this.#models.Session.getInstance({ id: sessionIdArg }));
|
|
246
|
-
return live ? this.snapshot(live) : null;
|
|
289
|
+
return live ? { session: this.snapshot(live.document), principal: live.principal } : null;
|
|
247
290
|
}
|
|
248
291
|
|
|
249
292
|
/** Whether a session is live right now, without touching it; what a sweep asks. */
|
|
@@ -255,10 +298,11 @@ export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
|
255
298
|
if (!session || session.revokedAt !== undefined || session.expiresAt <= this.#now()) {
|
|
256
299
|
return false;
|
|
257
300
|
}
|
|
258
|
-
return this.#
|
|
301
|
+
return (await this.#activePrincipal(session.userId)) !== null;
|
|
259
302
|
}
|
|
260
303
|
|
|
261
|
-
|
|
304
|
+
/** Whether a candidate is the CSRF token of a resolved (or just opened) session, compared in constant time. */
|
|
305
|
+
public validateCsrf(resolvedArg: { csrfToken: string }, candidateArg: unknown): boolean {
|
|
262
306
|
return typeof candidateArg === 'string' && timingSafeEqualString(resolvedArg.csrfToken, candidateArg);
|
|
263
307
|
}
|
|
264
308
|
|
|
@@ -412,7 +456,7 @@ export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
|
412
456
|
pkceVerifier: this.#secretBox.open(attempt.sealedPkceVerifier),
|
|
413
457
|
});
|
|
414
458
|
stage = 'principal_admission';
|
|
415
|
-
const { principalId } = await this.#
|
|
459
|
+
const { principalId } = await this.#admit(completion);
|
|
416
460
|
stage = 'session_persistence';
|
|
417
461
|
return await this.createProviderSession(attempt, principalId, completion);
|
|
418
462
|
} catch (error) {
|
|
@@ -693,13 +737,16 @@ export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
|
693
737
|
};
|
|
694
738
|
}
|
|
695
739
|
|
|
696
|
-
/** A session document that is live right now with
|
|
697
|
-
private async liveSession(
|
|
740
|
+
/** A session document that is live right now with its active principal, touched at most once a minute. */
|
|
741
|
+
private async liveSession(
|
|
742
|
+
sessionArg: RelyingPartySessionDoc | null,
|
|
743
|
+
): Promise<{ document: RelyingPartySessionDoc; principal: TPrincipal } | null> {
|
|
698
744
|
const now = this.#now();
|
|
699
745
|
if (!sessionArg || sessionArg.revokedAt !== undefined || sessionArg.expiresAt <= now) {
|
|
700
746
|
return null;
|
|
701
747
|
}
|
|
702
|
-
|
|
748
|
+
const active = await this.#activePrincipal(sessionArg.userId);
|
|
749
|
+
if (!active) {
|
|
703
750
|
return null;
|
|
704
751
|
}
|
|
705
752
|
if (now - sessionArg.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
|
|
@@ -714,7 +761,7 @@ export class RelyingPartySessions implements IAuthorityTokenSource {
|
|
|
714
761
|
}
|
|
715
762
|
sessionArg.lastSeenAt = now;
|
|
716
763
|
}
|
|
717
|
-
return sessionArg;
|
|
764
|
+
return { document: sessionArg, principal: active.principal };
|
|
718
765
|
}
|
|
719
766
|
|
|
720
767
|
private async createProviderSession(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
+
import { RelyingPartyDatabaseUnavailableError } from './classes.errors.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* One browser session of a relying party. The persisted field names, their
|
|
@@ -106,7 +107,13 @@ export const RELYING_PARTY_DEFAULT_COLLECTION_NAMES = {
|
|
|
106
107
|
} as const;
|
|
107
108
|
|
|
108
109
|
export interface IRelyingPartyModelsOptions {
|
|
109
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The database, or a function that answers it once it exists. The function
|
|
112
|
+
* is called on every use of a model — never while the models or
|
|
113
|
+
* `RelyingPartySessions` are constructed — so an application creates both
|
|
114
|
+
* before its database connects and binds the database it connects later.
|
|
115
|
+
*/
|
|
116
|
+
db: plugins.nosqldb.SmartdataDb | (() => plugins.nosqldb.SmartdataDb);
|
|
110
117
|
/**
|
|
111
118
|
* The persisted collection names. An application that already stores these
|
|
112
119
|
* documents names its existing collections here.
|
|
@@ -125,21 +132,38 @@ export interface IRelyingPartyModels {
|
|
|
125
132
|
/**
|
|
126
133
|
* Binds the two relying-party models to one database. Each call declares its
|
|
127
134
|
* own model classes, so several databases — and several applications in one
|
|
128
|
-
* process — never share a binding.
|
|
135
|
+
* process — never share a binding. A deferred `db` is resolved on every use,
|
|
136
|
+
* the way `@Collection(() => db)` resolves it; a use before it answers a
|
|
137
|
+
* `SmartdataDb` throws `RelyingPartyDatabaseUnavailableError`.
|
|
129
138
|
*/
|
|
130
139
|
export const createRelyingPartyModels = (optionsArg: IRelyingPartyModelsOptions): IRelyingPartyModels => {
|
|
131
|
-
const
|
|
132
|
-
if (!(
|
|
133
|
-
throw new Error('createRelyingPartyModels needs a SmartdataDb of @lossless.org/client/nosqldb');
|
|
140
|
+
const dbOption = optionsArg.db;
|
|
141
|
+
if (!(dbOption instanceof plugins.nosqldb.SmartdataDb) && typeof dbOption !== 'function') {
|
|
142
|
+
throw new Error('createRelyingPartyModels needs a SmartdataDb of @lossless.org/client/nosqldb or a function answering one');
|
|
134
143
|
}
|
|
144
|
+
const resolveDb = (): plugins.nosqldb.SmartdataDb => {
|
|
145
|
+
if (dbOption instanceof plugins.nosqldb.SmartdataDb) {
|
|
146
|
+
return dbOption;
|
|
147
|
+
}
|
|
148
|
+
let resolved: unknown;
|
|
149
|
+
try {
|
|
150
|
+
resolved = dbOption();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
throw new RelyingPartyDatabaseUnavailableError(error);
|
|
153
|
+
}
|
|
154
|
+
if (!(resolved instanceof plugins.nosqldb.SmartdataDb)) {
|
|
155
|
+
throw new RelyingPartyDatabaseUnavailableError();
|
|
156
|
+
}
|
|
157
|
+
return resolved;
|
|
158
|
+
};
|
|
135
159
|
const sessions = optionsArg.collectionNames?.sessions ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.sessions;
|
|
136
160
|
const loginAttempts = optionsArg.collectionNames?.loginAttempts
|
|
137
161
|
?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.loginAttempts;
|
|
138
162
|
|
|
139
|
-
@plugins.nosqldb.Collection(
|
|
163
|
+
@plugins.nosqldb.Collection(resolveDb, { collectionName: sessions })
|
|
140
164
|
class BoundSessionDoc extends RelyingPartySessionDoc {}
|
|
141
165
|
|
|
142
|
-
@plugins.nosqldb.Collection(
|
|
166
|
+
@plugins.nosqldb.Collection(resolveDb, { collectionName: loginAttempts })
|
|
143
167
|
class BoundLoginAttemptDoc extends RelyingPartyLoginAttemptDoc {}
|
|
144
168
|
|
|
145
169
|
return { Session: BoundSessionDoc, LoginAttempt: BoundLoginAttemptDoc };
|
|
@@ -11,6 +11,15 @@ export type TRelyingPartySocketTransport = Pick<
|
|
|
11
11
|
|
|
12
12
|
export type TRelyingPartySocketConnection = plugins.typedsocket.ISmartServeConnectionWrapper;
|
|
13
13
|
|
|
14
|
+
/** The SmartServe WebSocket peer underneath a connection, as a TypedSocket server hands it out. */
|
|
15
|
+
export type TRelyingPartySocketPeer = TRelyingPartySocketConnection['peer'];
|
|
16
|
+
|
|
17
|
+
/** A connection wrapper or its peer; `TypedSocket.setServerTag` takes either. */
|
|
18
|
+
export type TRelyingPartySocketTarget = TRelyingPartySocketConnection | TRelyingPartySocketPeer;
|
|
19
|
+
|
|
20
|
+
const peerOf = (targetArg: TRelyingPartySocketTarget): TRelyingPartySocketPeer =>
|
|
21
|
+
'peer' in targetArg ? targetArg.peer : targetArg;
|
|
22
|
+
|
|
14
23
|
/** What a browser connection is pinned to for its whole life; no cookie and no CSRF token is kept. */
|
|
15
24
|
export interface IRelyingPartyPinnedSession {
|
|
16
25
|
sessionId: string;
|
|
@@ -28,8 +37,8 @@ export type TRelyingPartySocketRegistration =
|
|
|
28
37
|
| { registered: true; pinned: IRelyingPartyPinnedSession; expiresAt: number }
|
|
29
38
|
| { registered: false; refusal: IRelyingPartySocketRefusal };
|
|
30
39
|
|
|
31
|
-
export interface IRelyingPartySocketBindingOptions {
|
|
32
|
-
sessions: RelyingPartySessions
|
|
40
|
+
export interface IRelyingPartySocketBindingOptions<TPrincipal extends object | undefined = undefined> {
|
|
41
|
+
sessions: RelyingPartySessions<TPrincipal>;
|
|
33
42
|
/** the transport typedserver created; `undefined` before the server runs */
|
|
34
43
|
transport: () => TRelyingPartySocketTransport | undefined;
|
|
35
44
|
/** the server-owned tag prefix that finds the connections of a session */
|
|
@@ -45,20 +54,20 @@ export interface IRelyingPartySocketBindingOptions {
|
|
|
45
54
|
* request. Connection limits, rate limits and sweeps belong to the
|
|
46
55
|
* application's socket gate.
|
|
47
56
|
*/
|
|
48
|
-
export class RelyingPartySocketBinding {
|
|
49
|
-
readonly #sessions: RelyingPartySessions
|
|
57
|
+
export class RelyingPartySocketBinding<TPrincipal extends object | undefined = undefined> {
|
|
58
|
+
readonly #sessions: RelyingPartySessions<TPrincipal>;
|
|
50
59
|
readonly #transport: () => TRelyingPartySocketTransport | undefined;
|
|
51
60
|
public readonly tagPrefix: string;
|
|
52
61
|
|
|
53
|
-
constructor(optionsArg: IRelyingPartySocketBindingOptions) {
|
|
62
|
+
constructor(optionsArg: IRelyingPartySocketBindingOptions<TPrincipal>) {
|
|
54
63
|
this.#sessions = optionsArg.sessions;
|
|
55
64
|
this.#transport = optionsArg.transport;
|
|
56
65
|
this.tagPrefix = optionsArg.tagPrefix ?? 'idp_rp_session:';
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
/** The upgrade headers a connection arrived with. */
|
|
60
|
-
public upgradeOf(
|
|
61
|
-
const headers =
|
|
68
|
+
/** The upgrade headers a connection (or its peer) arrived with. */
|
|
69
|
+
public upgradeOf(targetArg: TRelyingPartySocketTarget): { origin: string | null; cookie: string | null } {
|
|
70
|
+
const headers = peerOf(targetArg).context.headers;
|
|
62
71
|
return { origin: headers.get('origin'), cookie: headers.get('cookie') };
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -99,31 +108,36 @@ export class RelyingPartySocketBinding {
|
|
|
99
108
|
return this.tagPrefix + sessionIdArg;
|
|
100
109
|
}
|
|
101
110
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Tags a connection — its wrapper or its peer — with its session; throws
|
|
113
|
+
* when the transport is not running or the connection is gone.
|
|
114
|
+
*/
|
|
115
|
+
public tagConnection(targetArg: TRelyingPartySocketTarget, sessionIdArg: string): void {
|
|
104
116
|
const transport = this.#transport();
|
|
105
117
|
if (!transport) {
|
|
106
118
|
throw new Error('The socket transport is not running');
|
|
107
119
|
}
|
|
108
|
-
transport.setServerTag(
|
|
120
|
+
transport.setServerTag(targetArg, this.tagOf(sessionIdArg));
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
/**
|
|
112
124
|
* The pinned session as it stands right now: re-read, live, its principal
|
|
113
|
-
* active and unchanged
|
|
125
|
+
* active and unchanged (and resolved, with `principals.resolve`), touched
|
|
126
|
+
* under the liveness it was read with. The
|
|
114
127
|
* browser keeps proving itself with its CSRF token, compared against the
|
|
115
128
|
* hash the registration kept. Nothing of this is cached.
|
|
116
129
|
*/
|
|
117
130
|
public async revalidate(
|
|
118
131
|
pinnedArg: IRelyingPartyPinnedSession,
|
|
119
|
-
): Promise<{ session: IRelyingPartySession; verifyCsrf(candidateArg: unknown): boolean } | null> {
|
|
120
|
-
const
|
|
121
|
-
if (!
|
|
132
|
+
): Promise<{ session: IRelyingPartySession; principal: TPrincipal; verifyCsrf(candidateArg: unknown): boolean } | null> {
|
|
133
|
+
const live = await this.#sessions.revalidateLiveSession(pinnedArg.sessionId);
|
|
134
|
+
if (!live || live.session.principalId !== pinnedArg.principalId) {
|
|
122
135
|
return null;
|
|
123
136
|
}
|
|
124
137
|
const expectedHash = pinnedArg.csrfTokenHash;
|
|
125
138
|
return {
|
|
126
|
-
session,
|
|
139
|
+
session: live.session,
|
|
140
|
+
principal: live.principal,
|
|
127
141
|
verifyCsrf: (candidateArg) =>
|
|
128
142
|
typeof candidateArg === 'string'
|
|
129
143
|
&& expectedHash.length > 0
|
|
@@ -13,14 +13,25 @@ export interface IRelyingPartySession {
|
|
|
13
13
|
loginAttemptId?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* A live session with the principal it acts as. `principal` is what
|
|
18
|
+
* `principals.resolve` answered for it in the same read; with `isActive`
|
|
19
|
+
* principals it is `undefined`.
|
|
20
|
+
*/
|
|
21
|
+
export interface IRelyingPartyLiveSession<TPrincipal extends object | undefined = undefined> {
|
|
18
22
|
session: IRelyingPartySession;
|
|
23
|
+
principal: TPrincipal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A live session behind a cookie, with its principal and the CSRF token derived for it. */
|
|
27
|
+
export interface IResolvedRelyingPartySession<TPrincipal extends object | undefined = undefined> extends IRelyingPartyLiveSession<TPrincipal> {
|
|
19
28
|
csrfToken: string;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/** A session just opened: the answer, the CSRF token and the `Set-Cookie` value that carries it. */
|
|
23
|
-
export interface IRelyingPartySessionOpening
|
|
32
|
+
export interface IRelyingPartySessionOpening {
|
|
33
|
+
session: IRelyingPartySession;
|
|
34
|
+
csrfToken: string;
|
|
24
35
|
sessionCookie: string;
|
|
25
36
|
}
|
|
26
37
|
|
|
@@ -60,6 +71,27 @@ export interface IRelyingPartyPrincipals {
|
|
|
60
71
|
isActive(principalIdArg: string): Promise<boolean>;
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Principals that answer the principal itself instead of a yes or no, so a
|
|
76
|
+
* request reads its user once: `resolve` answers the principal when it may act
|
|
77
|
+
* right now and `null` otherwise, and every live session carries the answer as
|
|
78
|
+
* `principal`. Give either `isActive` or `resolve`, never both.
|
|
79
|
+
*/
|
|
80
|
+
export interface IRelyingPartyPrincipalResolver<TPrincipal extends object | undefined> {
|
|
81
|
+
admit(completionArg: IOidcCompletion): Promise<{ principalId: string }>;
|
|
82
|
+
/** 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
|
+
resolve(principalIdArg: string): Promise<TPrincipal | null>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What `RelyingPartySessions` accepts as its principals: a resolver whose
|
|
88
|
+
* principal type every live session carries, or — only while that type is
|
|
89
|
+
* left `undefined` — the `isActive` form.
|
|
90
|
+
*/
|
|
91
|
+
export type TRelyingPartyPrincipals<TPrincipal extends object | undefined> =
|
|
92
|
+
| IRelyingPartyPrincipalResolver<TPrincipal>
|
|
93
|
+
| (undefined extends TPrincipal ? IRelyingPartyPrincipals : never);
|
|
94
|
+
|
|
63
95
|
export type TRelyingPartySessionEndReason =
|
|
64
96
|
/** the application or the user ended it */
|
|
65
97
|
| 'logout'
|