@idp.global/sdk 19.4.0 → 19.6.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.
Files changed (33) hide show
  1. package/changelog.md +60 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +40 -3
  4. package/dist_ts_browser/classes.relyingpartybearerclient.js +67 -12
  5. package/dist_ts_relyingparty/classes.errors.d.ts +2 -0
  6. package/dist_ts_relyingparty/classes.errors.js +2 -1
  7. package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +69 -11
  8. package/dist_ts_relyingparty/classes.idpoidcprovider.js +127 -17
  9. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +128 -18
  10. package/dist_ts_relyingparty/classes.relyingpartysessions.js +334 -69
  11. package/dist_ts_relyingparty/classes.rpmodels.d.ts +21 -4
  12. package/dist_ts_relyingparty/classes.rpmodels.js +35 -8
  13. package/dist_ts_relyingparty/contract.bearer.d.ts +37 -0
  14. package/dist_ts_relyingparty/contract.bearer.js +5 -1
  15. package/dist_ts_relyingparty/helpers.bearer.d.ts +2 -3
  16. package/dist_ts_relyingparty/helpers.bearer.js +4 -6
  17. package/dist_ts_relyingparty/helpers.cookies.d.ts +6 -0
  18. package/dist_ts_relyingparty/helpers.cookies.js +8 -1
  19. package/dist_ts_relyingparty/interfaces.d.ts +73 -3
  20. package/dist_ts_relyingparty/testing.js +8 -4
  21. package/package.json +3 -3
  22. package/readme.md +280 -34
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts_browser/classes.relyingpartybearerclient.ts +98 -18
  25. package/ts_relyingparty/classes.errors.ts +3 -0
  26. package/ts_relyingparty/classes.idpoidcprovider.ts +183 -17
  27. package/ts_relyingparty/classes.relyingpartysessions.ts +393 -78
  28. package/ts_relyingparty/classes.rpmodels.ts +27 -4
  29. package/ts_relyingparty/contract.bearer.ts +46 -0
  30. package/ts_relyingparty/helpers.bearer.ts +3 -6
  31. package/ts_relyingparty/helpers.cookies.ts +13 -0
  32. package/ts_relyingparty/interfaces.ts +76 -7
  33. package/ts_relyingparty/testing.ts +7 -3
@@ -129,7 +129,9 @@ export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<
129
129
  /**
130
130
  * The single-use code a bearer login hands its client. Only the hash of the
131
131
  * code is stored; the code is bound to the client's S256 challenge, lives at
132
- * most a minute and is consumed by one conditional update.
132
+ * most a minute and is consumed by one conditional update. The `sessionId`
133
+ * index is what the session sweep and the erase delete a session's hand-offs
134
+ * by.
133
135
  */
134
136
  export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyBearerHandoffDoc, RelyingPartyBearerHandoffDoc> {
135
137
  @plugins.nosqldb.unI()
@@ -141,6 +143,7 @@ export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc
141
143
 
142
144
  /** the session the login opened; the code is its only way out */
143
145
  @plugins.nosqldb.svDb()
146
+ @plugins.nosqldb.index()
144
147
  public sessionId: string = '';
145
148
 
146
149
  @plugins.nosqldb.svDb()
@@ -163,8 +166,9 @@ export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc
163
166
  * One generation of a bearer session's tokens: an access token and the
164
167
  * refresh token that replaces both. Only hashes are stored. Exactly one
165
168
  * generation of a session is current (`rotatedAt` `null`); a rotated one is
166
- * kept until the session expires, so its refresh token coming back is
167
- * recognized as reuse.
169
+ * kept until its session is swept, so its refresh token coming back is
170
+ * recognized as reuse. The `sessionId` index is what the session sweep and
171
+ * the erase delete a session's generations by.
168
172
  */
169
173
  export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyBearerTokenDoc, RelyingPartyBearerTokenDoc> {
170
174
  @plugins.nosqldb.unI()
@@ -177,6 +181,7 @@ export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<R
177
181
  public refreshTokenHash: string = '';
178
182
 
179
183
  @plugins.nosqldb.svDb()
184
+ @plugins.nosqldb.index()
180
185
  public sessionId: string = '';
181
186
 
182
187
  @plugins.nosqldb.svDb()
@@ -190,9 +195,27 @@ export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<R
190
195
  @plugins.nosqldb.index()
191
196
  public expiresAt: number = 0;
192
197
 
193
- /** `null` while this generation is current; when its refresh token was spent otherwise */
198
+ /** `null` while this generation is current; when its refresh token was spent, or the generation revoked, otherwise */
194
199
  @plugins.nosqldb.svDb()
195
200
  public rotatedAt: number | null = null;
201
+
202
+ /**
203
+ * When its access token or its refresh token was first presented; `null`
204
+ * until then (and on a generation stored before 19.6.0). A rotated
205
+ * generation whose successor was never presented may come back within the
206
+ * reuse grace window as a lost answer.
207
+ */
208
+ @plugins.nosqldb.svDb()
209
+ public firstUsedAt: number | null = null;
210
+
211
+ /**
212
+ * The generation that replaced this one: set by its rotation and moved to
213
+ * the new one when a lost answer is issued again. `null` on a current
214
+ * generation, on one that was revoked unpresented, and on one stored before
215
+ * 19.6.0.
216
+ */
217
+ @plugins.nosqldb.svDb()
218
+ public successorId: string | null = null;
196
219
  }
197
220
 
198
221
  export const RELYING_PARTY_DEFAULT_COLLECTION_NAMES = {
@@ -41,6 +41,52 @@ export interface IRelyingPartyBearerMessage {
41
41
  */
42
42
  export type TRelyingPartyBearerDeliveryError = 'access_denied' | 'authentication_failed' | (string & {});
43
43
 
44
+ /** RFC 7636 §4.1: a code verifier is 43 to 128 characters of `A-Z a-z 0-9 . _ ~ -`. */
45
+ const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;
46
+
47
+ /** Whether a value is a well-formed PKCE code verifier (RFC 7636 §4.1). */
48
+ export const isPkceVerifier = (valueArg: unknown): valueArg is string =>
49
+ typeof valueArg === 'string' && PKCE_VERIFIER.test(valueArg);
50
+
51
+ /**
52
+ * The query of `GET <prefix>bearer/login`, by its parameter names on the
53
+ * wire. The routes check every parameter as untrusted input.
54
+ */
55
+ export interface IRelyingPartyBearerLoginQuery {
56
+ client_kind: TRelyingPartyBearerClientKind;
57
+ /** one of the configured return targets of the kind, exactly; a loopback target with the client's port */
58
+ return_target: string;
59
+ /** BASE64URL(SHA-256(code verifier)), 43 characters */
60
+ code_challenge: string;
61
+ /** only `S256` is accepted */
62
+ code_challenge_method: 'S256';
63
+ /** the client's own correlation value, 1 to 256 characters of `A-Z a-z 0-9 . _ ~ -`, handed back with the outcome */
64
+ state?: string;
65
+ }
66
+
67
+ /** The JSON body of `POST <prefix>bearer/claim`. */
68
+ export interface IRelyingPartyBearerClaimRequest {
69
+ /** the hand-off code, claimed once */
70
+ code: string;
71
+ /** the verifier of the challenge the code is bound to (`isPkceVerifier`) */
72
+ codeVerifier: string;
73
+ }
74
+
75
+ /** The JSON body of `POST <prefix>bearer/refresh`. */
76
+ export interface IRelyingPartyBearerRefreshRequest {
77
+ /** the current refresh token; this call spends it */
78
+ refreshToken: string;
79
+ }
80
+
81
+ /**
82
+ * The JSON body of `POST <prefix>bearer/logout`. The session is named by
83
+ * `Authorization: Bearer <accessToken>`, or by `refreshToken` once that access
84
+ * token has expired.
85
+ */
86
+ export interface IRelyingPartyBearerLogoutRequest {
87
+ refreshToken?: string;
88
+ }
89
+
44
90
  /** The session a bearer answer names. */
45
91
  export interface IRelyingPartyBearerSessionInfo {
46
92
  sessionId: string;
@@ -1,4 +1,5 @@
1
1
  import {
2
+ isPkceVerifier,
2
3
  RELYING_PARTY_BEARER_MESSAGE_TYPE,
3
4
  type IRelyingPartyBearerMessage,
4
5
  type TRelyingPartyBearerClientKind,
@@ -7,7 +8,8 @@ import { sha256, timingSafeEqualString } from './helpers.crypto.js';
7
8
  import { sanitizeReturnPath } from './helpers.returnpath.js';
8
9
  import type { IRelyingPartyBearerDelivery, IRelyingPartyBearerReturnTargets } from './interfaces.js';
9
10
 
10
- export { RELYING_PARTY_BEARER_MESSAGE_TYPE };
11
+ // the verifier check lives in the import-free contract, which the browser client shares
12
+ export { isPkceVerifier, RELYING_PARTY_BEARER_MESSAGE_TYPE };
11
13
 
12
14
  export const RELYING_PARTY_BEARER_CLIENT_KINDS: readonly TRelyingPartyBearerClientKind[] = Object.freeze([
13
15
  'web',
@@ -20,8 +22,6 @@ export const RELYING_PARTY_BEARER_CLIENT_KINDS: readonly TRelyingPartyBearerClie
20
22
  const MAX_RETURN_TARGET_LENGTH = 2_048;
21
23
  /** BASE64URL of a SHA-256 digest, without padding (RFC 7636 §4.2). */
22
24
  const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/u;
23
- /** RFC 7636 §4.1: 43 to 128 unreserved characters. */
24
- const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;
25
25
  const CLIENT_STATE = /^[A-Za-z0-9._~-]{1,256}$/u;
26
26
  const LOOPBACK_HOSTS: readonly string[] = ['127.0.0.1', '[::1]'];
27
27
  /** A private-use scheme is a reverse domain name: it has a dot, and no web scheme has one (RFC 8252 §7.1). */
@@ -33,9 +33,6 @@ export const isBearerClientKind = (valueArg: unknown): valueArg is TRelyingParty
33
33
  export const isPkceChallenge = (valueArg: unknown): valueArg is string =>
34
34
  typeof valueArg === 'string' && PKCE_CHALLENGE.test(valueArg);
35
35
 
36
- export const isPkceVerifier = (valueArg: unknown): valueArg is string =>
37
- typeof valueArg === 'string' && PKCE_VERIFIER.test(valueArg);
38
-
39
36
  export const isBearerClientState = (valueArg: unknown): valueArg is string =>
40
37
  typeof valueArg === 'string' && CLIENT_STATE.test(valueArg);
41
38
 
@@ -3,6 +3,12 @@ export interface ICookieOptions {
3
3
  maxAgeSeconds?: number;
4
4
  sameSite?: 'Lax' | 'Strict' | 'None';
5
5
  secure?: boolean;
6
+ /**
7
+ * `Partitioned` (CHIPS): the browser keeps the cookie per top-level site, so
8
+ * a page framed on another site gets a cookie jar of its own there. Needs
9
+ * `secure`; a cookie set partitioned is also cleared partitioned.
10
+ */
11
+ partitioned?: boolean;
6
12
  }
7
13
 
8
14
  // Control characters, space, DEL and the cookie delimiters are never valid.
@@ -60,6 +66,13 @@ export const serializeCookie = (
60
66
  if (optionsArg.sameSite) {
61
67
  parts.push('SameSite=' + optionsArg.sameSite);
62
68
  }
69
+ if (optionsArg.partitioned) {
70
+ if (!optionsArg.secure) {
71
+ // browsers drop a partitioned cookie that is not set Secure
72
+ throw new Error('A Partitioned cookie needs Secure');
73
+ }
74
+ parts.push('Partitioned');
75
+ }
63
76
  if (optionsArg.maxAgeSeconds !== undefined) {
64
77
  if (!Number.isSafeInteger(optionsArg.maxAgeSeconds) || optionsArg.maxAgeSeconds < 0) {
65
78
  throw new Error('Invalid cookie max age');
@@ -2,9 +2,13 @@ import type { IOidcCompletion, TOidcCompletionFailureStage, TOidcPrompt, TOidcTo
2
2
  import type { TRelyingPartyBearerClientKind } from './contract.bearer.js';
3
3
 
4
4
  export type {
5
+ IRelyingPartyBearerClaimRequest,
5
6
  IRelyingPartyBearerErrorAnswer,
7
+ IRelyingPartyBearerLoginQuery,
6
8
  IRelyingPartyBearerLogoutAnswer,
9
+ IRelyingPartyBearerLogoutRequest,
7
10
  IRelyingPartyBearerMessage,
11
+ IRelyingPartyBearerRefreshRequest,
8
12
  IRelyingPartyBearerSessionAnswer,
9
13
  IRelyingPartyBearerSessionInfo,
10
14
  IRelyingPartyBearerTokenAnswer,
@@ -41,6 +45,27 @@ export interface IResolvedRelyingPartySession<TPrincipal extends object | undefi
41
45
  csrfToken: string;
42
46
  }
43
47
 
48
+ /**
49
+ * How the session cookie and the login's intent cookie are sent across
50
+ * sites; one policy for both, on every cookie they are set and cleared with.
51
+ */
52
+ export interface IRelyingPartyCookiePolicy {
53
+ /**
54
+ * `Lax`, the default: the browser sends the cookies with this origin's own
55
+ * requests and with a top-level navigation to it, never into a frame on
56
+ * another site. `None`: also for a page of this origin framed on another
57
+ * site; it needs an https `publicOrigin`, because a `SameSite=None` cookie
58
+ * must be `Secure`.
59
+ */
60
+ sameSite?: 'Lax' | 'None';
61
+ /**
62
+ * `Partitioned` (CHIPS): the browser keeps the cookies per top-level site,
63
+ * so the framed application has a jar of its own under each site that frames
64
+ * it. Only with `sameSite: 'None'`. `false` by default.
65
+ */
66
+ partitioned?: boolean;
67
+ }
68
+
44
69
  /** A session just opened: the answer, the CSRF token and the `Set-Cookie` value that carries it. */
45
70
  export interface IRelyingPartySessionOpening {
46
71
  session: IRelyingPartySession;
@@ -115,6 +140,15 @@ export interface IRelyingPartyBearerOptions {
115
140
  returnTargets: IRelyingPartyBearerReturnTargets;
116
141
  /** how long an access token lives, in seconds; 900 by default, never past its session */
117
142
  accessTokenMaxAgeSeconds?: number;
143
+ /**
144
+ * How long after a rotation its spent refresh token may come back as a lost
145
+ * answer instead of reuse, in whole seconds: 60 by default, 0 turns it off,
146
+ * at most 300. Within it, a spent refresh token whose successor was never
147
+ * presented (neither its access token nor its refresh token) revokes that
148
+ * successor and answers a new pair; once the successor was presented, or
149
+ * after the window, it is reuse and ends the session.
150
+ */
151
+ refreshReuseGraceSeconds?: number;
118
152
  }
119
153
 
120
154
  /** What a bearer client sends to start its login. Every field is checked as untrusted input. */
@@ -156,6 +190,37 @@ export interface IRelyingPartyBearerHandoff {
156
190
  expiresAt: number;
157
191
  }
158
192
 
193
+ /**
194
+ * A bearer session the application opened for a device it paired
195
+ * (`openBearerHandoff`): the session, which has no provider login of its own
196
+ * (`idpSubject` and `loginAttemptId` are absent), and the single-use code the
197
+ * device claims at `bearer/claim` with the verifier of its challenge. The
198
+ * code is no token; only its hash is stored.
199
+ */
200
+ export interface IRelyingPartyBearerHandoffOpening {
201
+ session: IRelyingPartySession;
202
+ code: string;
203
+ /** epoch milliseconds, at most `BEARER_HANDOFF_MAX_AGE_SECONDS` after the opening and never past the session */
204
+ expiresAt: number;
205
+ }
206
+
207
+ /**
208
+ * What `eraseSessionsOfPrincipal` removed of one principal: the sessions it
209
+ * ended first, and the rows it deleted.
210
+ */
211
+ export interface IRelyingPartySessionsErasure {
212
+ /** sessions not revoked yet (an expired one too) that it ended, each with `onSessionEnded` and its provider tokens handed back */
213
+ endedSessions: number;
214
+ /** session rows, live or ended */
215
+ deletedSessions: number;
216
+ /** the sign-in transactions the provider sessions came out of */
217
+ deletedLoginAttempts: number;
218
+ /** bearer hand-offs, claimed or not */
219
+ deletedBearerHandoffs: number;
220
+ /** bearer token generations, current or rotated */
221
+ deletedBearerTokens: number;
222
+ }
223
+
159
224
  /**
160
225
  * The relying party's own tokens of a bearer session. The refresh token
161
226
  * rotates on every use; a rotated one coming back ends the session.
@@ -275,6 +340,8 @@ export type TRelyingPartyEventName =
275
340
  | 'session_sweep_failed'
276
341
  /** a bearer hand-off code or refresh token came back after its use; `reason` names which, the session ended */
277
342
  | 'bearer_credential_reused'
343
+ /** a spent refresh token came back within `refreshReuseGraceSeconds` before its successor was presented: the successor was revoked and a new pair issued */
344
+ | 'bearer_refresh_reissued'
278
345
  /** an application hook threw; the step it belonged to ran on regardless */
279
346
  | 'hook_failed'
280
347
  /** a session whose opening failed could not be revoked; its provider tokens went back anyway */
@@ -298,20 +365,22 @@ export interface IRelyingPartyHooks {
298
365
  * A provider login opened a session, linked to its attempt. Runs before the
299
366
  * login answers; a throw fails the login: the session is revoked, the
300
367
  * provider tokens are handed back and the original error is rethrown.
368
+ * `delivery` is where a bearer login's outcome goes (its client kind, return
369
+ * target and the client's `state`); a cookie login has none.
301
370
  */
302
- onProviderSessionOpened?(eventArg: { session: IRelyingPartySession }): Promise<void>;
371
+ onProviderSessionOpened?(eventArg: { session: IRelyingPartySession; delivery?: IRelyingPartyBearerDelivery }): Promise<void>;
303
372
  /**
304
373
  * A session ended, or a caller tried to end one that was already over
305
374
  * (`revokedHere: false`). Close its live connections when `revokedHere`,
306
375
  * drop cached authority either way. Must not wait. A throw (or a rejected
307
376
  * promise) is reported as a `hook_failed` event and never stops the end:
308
- * the provider tokens go back regardless.
377
+ * the provider tokens go back regardless. An end that revoked the session
378
+ * here names its principal (`principalId`); one that found it over already
379
+ * does not.
309
380
  */
310
- onSessionEnded?(eventArg: {
311
- sessionId: string;
312
- reason: TRelyingPartySessionEndReason;
313
- revokedHere: boolean;
314
- }): void;
381
+ onSessionEnded?(eventArg:
382
+ | { sessionId: string; reason: TRelyingPartySessionEndReason; revokedHere: true; principalId: string }
383
+ | { sessionId: string; reason: TRelyingPartySessionEndReason; revokedHere: false }): void;
315
384
  /**
316
385
  * Structured diagnostics; the default writes one JSON line to
317
386
  * `console.error`. A throwing sink never interrupts the step that reported:
@@ -231,16 +231,20 @@ export class RelyingPartyProviderDouble implements IOidcProvider {
231
231
 
232
232
  public async completeAuthorization(optionsArg: IOidcCompletionOptions): Promise<IOidcCompletion> {
233
233
  if (!this.#discovered) {
234
- throw new OidcCompletionError('token_exchange', notDiscovered());
234
+ throw new OidcCompletionError('token_exchange', notDiscovered(), { code: 'provider_unreachable' });
235
235
  }
236
236
  const code = optionsArg.callbackUrl.searchParams.get('code') ?? '';
237
237
  const issued = this.#codes.get(code);
238
238
  this.#codes.delete(code);
239
239
  if (!issued || issued.state !== optionsArg.state) {
240
- throw new OidcCompletionError('token_exchange', new Error('unknown, used or foreign code'));
240
+ // what idp.global's token endpoint answers for such a code
241
+ throw new OidcCompletionError('token_exchange', new Error('unknown, used or foreign code'), {
242
+ code: 'token_exchange_refused',
243
+ oauthError: 'invalid_grant',
244
+ });
241
245
  }
242
246
  if (issued.nonce !== optionsArg.nonce) {
243
- throw new OidcCompletionError('token_validation', new Error('nonce mismatch'));
247
+ throw new OidcCompletionError('token_validation', new Error('nonce mismatch'), { code: 'login_mismatch' });
244
248
  }
245
249
  this.completions.push({ subject: issued.subject, state: issued.state });
246
250
  if (this.completionFailure) {