@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.
@@ -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
- principals: IRelyingPartyPrincipals;
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 #principals: IRelyingPartyPrincipals;
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
- this.#principals = optionsArg.principals;
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.#principals.isActive(session.userId);
301
+ return (await this.#activePrincipal(session.userId)) !== null;
259
302
  }
260
303
 
261
- public validateCsrf(resolvedArg: IResolvedRelyingPartySession, candidateArg: unknown): boolean {
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.#principals.admit(completion);
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 an active principal, touched at most once a minute. */
697
- private async liveSession(sessionArg: RelyingPartySessionDoc | null): Promise<RelyingPartySessionDoc | null> {
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
- if (!(await this.#principals.isActive(sessionArg.userId))) {
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
- db: plugins.nosqldb.SmartdataDb;
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 db = optionsArg.db;
132
- if (!(db instanceof plugins.nosqldb.SmartdataDb)) {
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(() => db, { collectionName: sessions })
163
+ @plugins.nosqldb.Collection(resolveDb, { collectionName: sessions })
140
164
  class BoundSessionDoc extends RelyingPartySessionDoc {}
141
165
 
142
- @plugins.nosqldb.Collection(() => db, { collectionName: loginAttempts })
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(connectionArg: TRelyingPartySocketConnection): { origin: string | null; cookie: string | null } {
61
- const headers = connectionArg.peer.context.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
- /** Tags a connection with its session; throws when the transport is not running or the connection is gone. */
103
- public tagConnection(connectionArg: TRelyingPartySocketConnection, sessionIdArg: string): void {
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(connectionArg, this.tagOf(sessionIdArg));
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, touched under the liveness it was read with. The
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 session = await this.#sessions.revalidateSession(pinnedArg.sessionId);
121
- if (!session || session.principalId !== pinnedArg.principalId) {
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
- /** A live session behind a cookie, with the CSRF token derived for it. */
17
- export interface IResolvedRelyingPartySession {
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 extends IResolvedRelyingPartySession {
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'