@idp.global/sdk 18.0.0 → 18.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.
Files changed (50) hide show
  1. package/changelog.md +28 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_relyingparty/classes.actorscope.d.ts +23 -0
  4. package/dist_ts_relyingparty/classes.actorscope.js +22 -0
  5. package/dist_ts_relyingparty/classes.authorityobserver.d.ts +81 -0
  6. package/dist_ts_relyingparty/classes.authorityobserver.js +185 -0
  7. package/dist_ts_relyingparty/classes.errors.d.ts +33 -0
  8. package/dist_ts_relyingparty/classes.errors.js +53 -0
  9. package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +142 -0
  10. package/dist_ts_relyingparty/classes.idpoidcprovider.js +255 -0
  11. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +84 -0
  12. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +232 -0
  13. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +210 -0
  14. package/dist_ts_relyingparty/classes.relyingpartysessions.js +786 -0
  15. package/dist_ts_relyingparty/classes.rpmodels.d.ts +79 -0
  16. package/dist_ts_relyingparty/classes.rpmodels.js +283 -0
  17. package/dist_ts_relyingparty/classes.secretbox.d.ts +14 -0
  18. package/dist_ts_relyingparty/classes.secretbox.js +45 -0
  19. package/dist_ts_relyingparty/classes.socketbinding.d.ts +75 -0
  20. package/dist_ts_relyingparty/classes.socketbinding.js +94 -0
  21. package/dist_ts_relyingparty/helpers.cookies.d.ts +11 -0
  22. package/dist_ts_relyingparty/helpers.cookies.js +62 -0
  23. package/dist_ts_relyingparty/helpers.crypto.d.ts +12 -0
  24. package/dist_ts_relyingparty/helpers.crypto.js +40 -0
  25. package/dist_ts_relyingparty/helpers.returnpath.d.ts +9 -0
  26. package/dist_ts_relyingparty/helpers.returnpath.js +39 -0
  27. package/dist_ts_relyingparty/index.d.ts +13 -0
  28. package/dist_ts_relyingparty/index.js +14 -0
  29. package/dist_ts_relyingparty/interfaces.d.ts +113 -0
  30. package/dist_ts_relyingparty/interfaces.js +2 -0
  31. package/dist_ts_relyingparty/plugins.d.ts +10 -0
  32. package/dist_ts_relyingparty/plugins.js +11 -0
  33. package/package.json +14 -11
  34. package/readme.md +158 -4
  35. package/ts/00_commitinfo_data.ts +1 -1
  36. package/ts_relyingparty/classes.actorscope.ts +39 -0
  37. package/ts_relyingparty/classes.authorityobserver.ts +253 -0
  38. package/ts_relyingparty/classes.errors.ts +69 -0
  39. package/ts_relyingparty/classes.idpoidcprovider.ts +386 -0
  40. package/ts_relyingparty/classes.relyingpartyroutes.ts +286 -0
  41. package/ts_relyingparty/classes.relyingpartysessions.ts +970 -0
  42. package/ts_relyingparty/classes.rpmodels.ts +146 -0
  43. package/ts_relyingparty/classes.secretbox.ts +49 -0
  44. package/ts_relyingparty/classes.socketbinding.ts +142 -0
  45. package/ts_relyingparty/helpers.cookies.ts +79 -0
  46. package/ts_relyingparty/helpers.crypto.ts +49 -0
  47. package/ts_relyingparty/helpers.returnpath.ts +44 -0
  48. package/ts_relyingparty/index.ts +13 -0
  49. package/ts_relyingparty/interfaces.ts +133 -0
  50. package/ts_relyingparty/plugins.ts +17 -0
@@ -0,0 +1,146 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ /**
4
+ * One browser session of a relying party. The persisted field names, their
5
+ * types and the index declarations are the ones nevermind.finance has stored
6
+ * under `SessionDoc` since before this module existed, so its collection is
7
+ * read and written unchanged.
8
+ */
9
+ export class RelyingPartySessionDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartySessionDoc, RelyingPartySessionDoc> {
10
+ @plugins.nosqldb.unI()
11
+ public id: string = '';
12
+
13
+ /** sha256 of the cookie token; the token itself is never stored */
14
+ @plugins.nosqldb.unI()
15
+ public tokenHash: string = '';
16
+
17
+ /** the application's principal (user) this session acts as */
18
+ @plugins.nosqldb.svDb()
19
+ @plugins.nosqldb.index()
20
+ public userId: string = '';
21
+
22
+ @plugins.nosqldb.svDb()
23
+ public createdAt: number = 0;
24
+
25
+ @plugins.nosqldb.svDb()
26
+ public lastSeenAt: number = 0;
27
+
28
+ @plugins.nosqldb.svDb()
29
+ public expiresAt: number = 0;
30
+
31
+ /** absent while the session is valid */
32
+ @plugins.nosqldb.svDb()
33
+ public revokedAt?: number;
34
+
35
+ /** the provider subject of a provider login; absent for a session the application opened itself */
36
+ @plugins.nosqldb.svDb()
37
+ public idpSubject?: string;
38
+
39
+ /** the login attempt this session came out of; finalize matches it */
40
+ @plugins.nosqldb.svDb()
41
+ public loginAttemptId?: string;
42
+
43
+ /** sealed with the session encryption key; only the server ever sees the tokens */
44
+ @plugins.nosqldb.svDb()
45
+ public sealedAccessToken?: string;
46
+
47
+ @plugins.nosqldb.svDb()
48
+ public sealedRefreshToken?: string;
49
+
50
+ /** when the sealed access token dies, epoch milliseconds; `0` when the provider named no lifetime */
51
+ @plugins.nosqldb.svDb()
52
+ public accessTokenExpiresAt?: number;
53
+
54
+ /** until when one replica holds the right to rotate this session's tokens, epoch milliseconds */
55
+ @plugins.nosqldb.svDb()
56
+ public refreshLockUntil?: number;
57
+ }
58
+
59
+ /**
60
+ * One browser's sign-in transaction: the state hash the callback is matched
61
+ * against, the binding to the browser that started it, the sealed PKCE
62
+ * verifier and the return path. Consumed exactly once by a conditional update.
63
+ * Field names and indexes are the ones nevermind.finance stores under
64
+ * `LoginAttemptDoc`.
65
+ */
66
+ export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyLoginAttemptDoc, RelyingPartyLoginAttemptDoc> {
67
+ @plugins.nosqldb.unI()
68
+ public id: string = '';
69
+
70
+ /** sha256 of the `state` parameter; the parameter itself is never stored */
71
+ @plugins.nosqldb.unI()
72
+ public stateHash: string = '';
73
+
74
+ /** sha256 of the intent cookie value; binds the callback to the browser that started it */
75
+ @plugins.nosqldb.unI()
76
+ public bindingTokenHash: string = '';
77
+
78
+ @plugins.nosqldb.svDb()
79
+ public sealedPkceVerifier: string = '';
80
+
81
+ @plugins.nosqldb.svDb()
82
+ public nonce: string = '';
83
+
84
+ /** same-origin path the browser returns to */
85
+ @plugins.nosqldb.svDb()
86
+ public returnPath: string = '/';
87
+
88
+ @plugins.nosqldb.svDb()
89
+ public createdAt: number = 0;
90
+
91
+ @plugins.nosqldb.svDb()
92
+ public expiresAt: number = 0;
93
+
94
+ /** absent until the callback consumes the attempt; `null` matches "not consumed" in a filter */
95
+ @plugins.nosqldb.svDb()
96
+ public consumedAt?: number | null;
97
+
98
+ /** the session the callback created; finalize releases the return path against it */
99
+ @plugins.nosqldb.svDb()
100
+ public createdSessionId?: string | null;
101
+ }
102
+
103
+ export const RELYING_PARTY_DEFAULT_COLLECTION_NAMES = {
104
+ sessions: 'IdpRelyingPartySession',
105
+ loginAttempts: 'IdpRelyingPartyLoginAttempt',
106
+ } as const;
107
+
108
+ export interface IRelyingPartyModelsOptions {
109
+ db: plugins.nosqldb.SmartdataDb;
110
+ /**
111
+ * The persisted collection names. An application that already stores these
112
+ * documents names its existing collections here.
113
+ */
114
+ collectionNames?: {
115
+ sessions?: string;
116
+ loginAttempts?: string;
117
+ };
118
+ }
119
+
120
+ export interface IRelyingPartyModels {
121
+ readonly Session: typeof RelyingPartySessionDoc;
122
+ readonly LoginAttempt: typeof RelyingPartyLoginAttemptDoc;
123
+ }
124
+
125
+ /**
126
+ * Binds the two relying-party models to one database. Each call declares its
127
+ * own model classes, so several databases — and several applications in one
128
+ * process — never share a binding.
129
+ */
130
+ 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');
134
+ }
135
+ const sessions = optionsArg.collectionNames?.sessions ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.sessions;
136
+ const loginAttempts = optionsArg.collectionNames?.loginAttempts
137
+ ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.loginAttempts;
138
+
139
+ @plugins.nosqldb.Collection(() => db, { collectionName: sessions })
140
+ class BoundSessionDoc extends RelyingPartySessionDoc {}
141
+
142
+ @plugins.nosqldb.Collection(() => db, { collectionName: loginAttempts })
143
+ class BoundLoginAttemptDoc extends RelyingPartyLoginAttemptDoc {}
144
+
145
+ return { Session: BoundSessionDoc, LoginAttempt: BoundLoginAttemptDoc };
146
+ };
@@ -0,0 +1,49 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ /**
4
+ * The persisted format tag of a sealed value. It names the stored layout of
5
+ * values that already exist and is read as it is; it is not an API version.
6
+ */
7
+ const SEALED_FORMAT = 'v1';
8
+ const ALGORITHM = 'aes-256-gcm';
9
+ const IV_LENGTH = 12;
10
+ const TAG_LENGTH = 16;
11
+
12
+ /**
13
+ * Seals small secrets at rest — the PKCE verifier of a login attempt and the
14
+ * provider tokens of a session — with AES-256-GCM under a 32-byte deployment
15
+ * key. The sealed form is `v1:<base64url iv>:<base64url tag>:<base64url
16
+ * ciphertext>`, the form nevermind.finance has stored since before this module
17
+ * existed, so its sealed values open here unchanged.
18
+ */
19
+ export class SecretBox {
20
+ readonly #key: Buffer;
21
+
22
+ constructor(keyArg: Buffer) {
23
+ if (!Buffer.isBuffer(keyArg) || keyArg.length !== 32) {
24
+ throw new Error('SecretBox needs a 32 byte key');
25
+ }
26
+ this.#key = Buffer.from(keyArg);
27
+ }
28
+
29
+ public seal(plainArg: string): string {
30
+ const iv = plugins.crypto.randomBytes(IV_LENGTH);
31
+ const cipher = plugins.crypto.createCipheriv(ALGORITHM, this.#key, iv, { authTagLength: TAG_LENGTH });
32
+ const ciphertext = Buffer.concat([cipher.update(plainArg, 'utf8'), cipher.final()]);
33
+ const tag = cipher.getAuthTag();
34
+ return [SEALED_FORMAT, iv.toString('base64url'), tag.toString('base64url'), ciphertext.toString('base64url')].join(':');
35
+ }
36
+
37
+ /** The plain value, or a throw: another format, another key, or a changed byte. */
38
+ public open(sealedArg: string): string {
39
+ const [format, ivText, tagText, cipherText] = sealedArg.split(':');
40
+ if (format !== SEALED_FORMAT || !ivText || !tagText || cipherText === undefined) {
41
+ throw new Error('Sealed secret has an unknown format');
42
+ }
43
+ const decipher = plugins.crypto.createDecipheriv(ALGORITHM, this.#key, Buffer.from(ivText, 'base64url'), {
44
+ authTagLength: TAG_LENGTH,
45
+ });
46
+ decipher.setAuthTag(Buffer.from(tagText, 'base64url'));
47
+ return Buffer.concat([decipher.update(Buffer.from(cipherText, 'base64url')), decipher.final()]).toString('utf8');
48
+ }
49
+ }
@@ -0,0 +1,142 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { RelyingPartySessions } from './classes.relyingpartysessions.js';
3
+ import { sha256, timingSafeEqualString } from './helpers.crypto.js';
4
+ import type { IRelyingPartySession } from './interfaces.js';
5
+
6
+ /** The part of a server `TypedSocket` the binding uses. */
7
+ export type TRelyingPartySocketTransport = Pick<
8
+ plugins.typedsocket.TypedSocket,
9
+ 'setServerTag' | 'findAllTargetConnectionsByTag'
10
+ >;
11
+
12
+ export type TRelyingPartySocketConnection = plugins.typedsocket.ISmartServeConnectionWrapper;
13
+
14
+ /** What a browser connection is pinned to for its whole life; no cookie and no CSRF token is kept. */
15
+ export interface IRelyingPartyPinnedSession {
16
+ sessionId: string;
17
+ principalId: string;
18
+ /** SHA-256 of the CSRF token the registration proved */
19
+ csrfTokenHash: string;
20
+ }
21
+
22
+ export interface IRelyingPartySocketRefusal {
23
+ code: 'invalid_input' | 'forbidden' | 'unauthorized';
24
+ status: number;
25
+ }
26
+
27
+ export type TRelyingPartySocketRegistration =
28
+ | { registered: true; pinned: IRelyingPartyPinnedSession; expiresAt: number }
29
+ | { registered: false; refusal: IRelyingPartySocketRefusal };
30
+
31
+ export interface IRelyingPartySocketBindingOptions {
32
+ sessions: RelyingPartySessions;
33
+ /** the transport typedserver created; `undefined` before the server runs */
34
+ transport: () => TRelyingPartySocketTransport | undefined;
35
+ /** the server-owned tag prefix that finds the connections of a session */
36
+ tagPrefix?: string;
37
+ }
38
+
39
+ /**
40
+ * Binds typed socket connections to relying-party browser sessions. A
41
+ * connection registers once with the CSRF token of the page; the proof is the
42
+ * upgrade's own `Origin` and `Cookie` — which only a browser on this origin
43
+ * sends — plus the token, and the connection is pinned to the session. Every
44
+ * later request re-validates the session with the same strength as an HTTP
45
+ * request. Connection limits, rate limits and sweeps belong to the
46
+ * application's socket gate.
47
+ */
48
+ export class RelyingPartySocketBinding {
49
+ readonly #sessions: RelyingPartySessions;
50
+ readonly #transport: () => TRelyingPartySocketTransport | undefined;
51
+ public readonly tagPrefix: string;
52
+
53
+ constructor(optionsArg: IRelyingPartySocketBindingOptions) {
54
+ this.#sessions = optionsArg.sessions;
55
+ this.#transport = optionsArg.transport;
56
+ this.tagPrefix = optionsArg.tagPrefix ?? 'idp_rp_session:';
57
+ }
58
+
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;
62
+ return { origin: headers.get('origin'), cookie: headers.get('cookie') };
63
+ }
64
+
65
+ /**
66
+ * What a registration payload `{ csrfToken }` proves, given the upgrade it
67
+ * came on. No connection state is touched, so the application decides about
68
+ * its limits between this answer and `tagConnection`.
69
+ */
70
+ public async resolveBrowserRegistration(
71
+ payloadArg: unknown,
72
+ upgradeArg: { origin: string | null; cookie: string | null },
73
+ ): Promise<TRelyingPartySocketRegistration> {
74
+ if (!payloadArg || typeof payloadArg !== 'object' || !Reflect.has(payloadArg, 'csrfToken')) {
75
+ return { registered: false, refusal: { code: 'invalid_input', status: 400 } };
76
+ }
77
+ if (upgradeArg.origin !== this.#sessions.publicOrigin) {
78
+ return { registered: false, refusal: { code: 'forbidden', status: 403 } };
79
+ }
80
+ const resolved = await this.#sessions.resolveCookieSession(upgradeArg.cookie);
81
+ if (!resolved) {
82
+ return { registered: false, refusal: { code: 'unauthorized', status: 401 } };
83
+ }
84
+ if (!this.#sessions.validateCsrf(resolved, Reflect.get(payloadArg, 'csrfToken'))) {
85
+ return { registered: false, refusal: { code: 'forbidden', status: 403 } };
86
+ }
87
+ return {
88
+ registered: true,
89
+ pinned: {
90
+ sessionId: resolved.session.id,
91
+ principalId: resolved.session.principalId,
92
+ csrfTokenHash: sha256(resolved.csrfToken),
93
+ },
94
+ expiresAt: resolved.session.expiresAt,
95
+ };
96
+ }
97
+
98
+ public tagOf(sessionIdArg: string): string {
99
+ return this.tagPrefix + sessionIdArg;
100
+ }
101
+
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 {
104
+ const transport = this.#transport();
105
+ if (!transport) {
106
+ throw new Error('The socket transport is not running');
107
+ }
108
+ transport.setServerTag(connectionArg, this.tagOf(sessionIdArg));
109
+ }
110
+
111
+ /**
112
+ * 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
114
+ * browser keeps proving itself with its CSRF token, compared against the
115
+ * hash the registration kept. Nothing of this is cached.
116
+ */
117
+ public async revalidate(
118
+ 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) {
122
+ return null;
123
+ }
124
+ const expectedHash = pinnedArg.csrfTokenHash;
125
+ return {
126
+ session,
127
+ verifyCsrf: (candidateArg) =>
128
+ typeof candidateArg === 'string'
129
+ && expectedHash.length > 0
130
+ && timingSafeEqualString(expectedHash, sha256(candidateArg)),
131
+ };
132
+ }
133
+
134
+ /** The live connections of one session, for closing them when it ends. */
135
+ public async connectionsOfSession(sessionIdArg: string): Promise<readonly TRelyingPartySocketConnection[]> {
136
+ const transport = this.#transport();
137
+ if (!transport) {
138
+ return [];
139
+ }
140
+ return transport.findAllTargetConnectionsByTag(this.tagOf(sessionIdArg));
141
+ }
142
+ }
@@ -0,0 +1,79 @@
1
+ export interface ICookieOptions {
2
+ httpOnly?: boolean;
3
+ maxAgeSeconds?: number;
4
+ sameSite?: 'Lax' | 'Strict' | 'None';
5
+ secure?: boolean;
6
+ }
7
+
8
+ // Control characters, space, DEL and the cookie delimiters are never valid.
9
+ const isInvalidCookieCharacter = (characterArg: string): boolean => {
10
+ const code = characterArg.charCodeAt(0);
11
+ return code <= 0x20 || code === 0x7f || characterArg === ';' || characterArg === ',';
12
+ };
13
+
14
+ const assertCookiePart = (valueArg: string, labelArg: string): void => {
15
+ if (!valueArg || Array.from(valueArg).some(isInvalidCookieCharacter)) {
16
+ throw new Error('Invalid cookie ' + labelArg);
17
+ }
18
+ };
19
+
20
+ /** The cookies of a `Cookie` header; the first value of a repeated name wins, an undecodable value is skipped. */
21
+ export const parseCookies = (cookieHeaderArg: string | null | undefined): Map<string, string> => {
22
+ const result = new Map<string, string>();
23
+ if (!cookieHeaderArg) {
24
+ return result;
25
+ }
26
+ for (const part of cookieHeaderArg.split(';')) {
27
+ const separatorIndex = part.indexOf('=');
28
+ if (separatorIndex <= 0) {
29
+ continue;
30
+ }
31
+ const name = part.slice(0, separatorIndex).trim();
32
+ const encodedValue = part.slice(separatorIndex + 1).trim();
33
+ if (!name || result.has(name)) {
34
+ continue;
35
+ }
36
+ try {
37
+ result.set(name, decodeURIComponent(encodedValue));
38
+ } catch {
39
+ continue;
40
+ }
41
+ }
42
+ return result;
43
+ };
44
+
45
+ /** One `Set-Cookie` value, always `Path=/` and never with a `Domain`, so a `__Host-` name stays valid. */
46
+ export const serializeCookie = (
47
+ nameArg: string,
48
+ valueArg: string,
49
+ optionsArg: ICookieOptions = {},
50
+ ): string => {
51
+ assertCookiePart(nameArg, 'name');
52
+ assertCookiePart(valueArg, 'value');
53
+ const parts = [nameArg + '=' + encodeURIComponent(valueArg), 'Path=/'];
54
+ if (optionsArg.httpOnly) {
55
+ parts.push('HttpOnly');
56
+ }
57
+ if (optionsArg.secure) {
58
+ parts.push('Secure');
59
+ }
60
+ if (optionsArg.sameSite) {
61
+ parts.push('SameSite=' + optionsArg.sameSite);
62
+ }
63
+ if (optionsArg.maxAgeSeconds !== undefined) {
64
+ if (!Number.isSafeInteger(optionsArg.maxAgeSeconds) || optionsArg.maxAgeSeconds < 0) {
65
+ throw new Error('Invalid cookie max age');
66
+ }
67
+ parts.push('Max-Age=' + optionsArg.maxAgeSeconds);
68
+ }
69
+ return parts.join('; ');
70
+ };
71
+
72
+ export const serializeClearedCookie = (
73
+ nameArg: string,
74
+ optionsArg: Omit<ICookieOptions, 'maxAgeSeconds'> = {},
75
+ ): string =>
76
+ serializeCookie(nameArg, 'deleted', {
77
+ ...optionsArg,
78
+ maxAgeSeconds: 0,
79
+ }) + '; Expires=Thu, 01 Jan 1970 00:00:00 GMT';
@@ -0,0 +1,49 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ /** A 32-byte key given as base64 or base64url; anything else is refused by `labelArg`, never echoed. */
4
+ export const decodeSecretKey = (valueArg: string, labelArg: string): Buffer => {
5
+ const normalized = typeof valueArg === 'string' ? valueArg.trim() : '';
6
+ if (!normalized) {
7
+ throw new Error(labelArg + ' must not be empty');
8
+ }
9
+ const base64Normalized = normalized
10
+ .replace(/-/gu, '+')
11
+ .replace(/_/gu, '/')
12
+ .replace(/=+$/u, '');
13
+ const decoded = Buffer.from(normalized, 'base64');
14
+ if (
15
+ decoded.length !== 32
16
+ || decoded.toString('base64').replace(/=+$/u, '') !== base64Normalized
17
+ ) {
18
+ throw new Error(labelArg + ' must decode to exactly 32 bytes');
19
+ }
20
+ return decoded;
21
+ };
22
+
23
+ export const randomToken = (byteLengthArg: number = 32): string =>
24
+ plugins.crypto.randomBytes(byteLengthArg).toString('base64url');
25
+
26
+ /** base64url SHA-256 of a UTF-8 string; how tokens, states and bindings are stored. */
27
+ export const sha256 = (valueArg: string): string =>
28
+ plugins.crypto.createHash('sha256').update(valueArg, 'utf8').digest('base64url');
29
+
30
+ export const timingSafeEqualString = (leftArg: string, rightArg: string): boolean => {
31
+ const left = Buffer.from(leftArg, 'utf8');
32
+ const right = Buffer.from(rightArg, 'utf8');
33
+ if (left.length !== right.length) {
34
+ return false;
35
+ }
36
+ return plugins.crypto.timingSafeEqual(left, right);
37
+ };
38
+
39
+ /**
40
+ * The CSRF token of a session: an HMAC of the session cookie token under the
41
+ * deployment's CSRF key, after a label that separates this use of the key from
42
+ * any other. It is derived again on every request and never stored.
43
+ */
44
+ export const deriveCsrfToken = (sessionTokenArg: string, csrfKeyArg: Buffer, labelArg: string): string =>
45
+ plugins.crypto
46
+ .createHmac('sha256', csrfKeyArg)
47
+ .update(labelArg, 'utf8')
48
+ .update(sessionTokenArg, 'utf8')
49
+ .digest('base64url');
@@ -0,0 +1,44 @@
1
+ /** A return path is a location the application produced, not a place to park data. */
2
+ const MAX_RETURN_PATH_LENGTH = 1_024;
3
+
4
+ /**
5
+ * The one path a login may return to, judged on the normalized result and
6
+ * never on the shape of the input: `/.//evil.com` parses on this origin but
7
+ * normalizes to `//evil.com`, which a browser reads as another host. Everything
8
+ * that is not a plain same-origin path outside the login routes — a scheme, a
9
+ * host, a backslash, a control character, an overlong value, a path under
10
+ * `loginPathPrefixArg` — becomes `/`.
11
+ */
12
+ export const sanitizeReturnPath = (
13
+ returnPathArg: unknown,
14
+ publicOriginArg: string,
15
+ loginPathPrefixArg: string,
16
+ ): string => {
17
+ const candidate = typeof returnPathArg === 'string' ? returnPathArg.trim() : '';
18
+ if (
19
+ !candidate.startsWith('/')
20
+ || candidate.length > MAX_RETURN_PATH_LENGTH
21
+ || candidate.includes('\\')
22
+ // the URL parser drops tabs and newlines silently; such a path is refused instead of mangled
23
+ || /\p{Cc}/u.test(candidate)
24
+ ) {
25
+ return '/';
26
+ }
27
+ const loginRoot = loginPathPrefixArg.endsWith('/') ? loginPathPrefixArg.slice(0, -1) : loginPathPrefixArg;
28
+ try {
29
+ const parsed = new URL(candidate, publicOriginArg);
30
+ if (parsed.origin !== publicOriginArg) {
31
+ return '/';
32
+ }
33
+ if (parsed.pathname === loginRoot || parsed.pathname.startsWith(loginRoot + '/')) {
34
+ return '/';
35
+ }
36
+ const normalized = parsed.pathname + parsed.search + parsed.hash;
37
+ if (!normalized.startsWith('/') || normalized.startsWith('//') || normalized.includes('\\')) {
38
+ return '/';
39
+ }
40
+ return normalized;
41
+ } catch {
42
+ return '/';
43
+ }
44
+ };
@@ -0,0 +1,13 @@
1
+ export * from './interfaces.js';
2
+ export * from './classes.errors.js';
3
+ export * from './helpers.cookies.js';
4
+ export * from './helpers.crypto.js';
5
+ export * from './helpers.returnpath.js';
6
+ export * from './classes.secretbox.js';
7
+ export * from './classes.idpoidcprovider.js';
8
+ export * from './classes.rpmodels.js';
9
+ export * from './classes.relyingpartysessions.js';
10
+ export * from './classes.authorityobserver.js';
11
+ export * from './classes.relyingpartyroutes.js';
12
+ export * from './classes.actorscope.js';
13
+ export * from './classes.socketbinding.js';
@@ -0,0 +1,133 @@
1
+ import type { IOidcCompletion, TOidcCompletionFailureStage, TOidcTokenHint } from './classes.idpoidcprovider.js';
2
+
3
+ /** A session as the application reads it; no token and no sealed value is part of it. */
4
+ export interface IRelyingPartySession {
5
+ id: string;
6
+ /** the application's principal (user) the session acts as */
7
+ principalId: string;
8
+ createdAt: number;
9
+ lastSeenAt: number;
10
+ expiresAt: number;
11
+ /** the provider subject; absent for a session the application opened itself */
12
+ idpSubject?: string;
13
+ loginAttemptId?: string;
14
+ }
15
+
16
+ /** A live session behind a cookie, with the CSRF token derived for it. */
17
+ export interface IResolvedRelyingPartySession {
18
+ session: IRelyingPartySession;
19
+ csrfToken: string;
20
+ }
21
+
22
+ /** A session just opened: the answer, the CSRF token and the `Set-Cookie` value that carries it. */
23
+ export interface IRelyingPartySessionOpening extends IResolvedRelyingPartySession {
24
+ sessionCookie: string;
25
+ }
26
+
27
+ /** The redirect that starts a provider login, and the cookie that binds it to this browser. */
28
+ export interface IRelyingPartyLoginStart {
29
+ authorizationUrl: URL;
30
+ intentCookie: string;
31
+ }
32
+
33
+ /** A provider access token usable right now, with the subject it belongs to. */
34
+ export interface IProviderToken {
35
+ accessToken: string;
36
+ subject: string;
37
+ /** epoch milliseconds; `0` when the provider named no lifetime */
38
+ expiresAt: number;
39
+ }
40
+
41
+ /** What an authority observer needs from the session owner. `RelyingPartySessions` implements it. */
42
+ export interface IAuthorityTokenSource {
43
+ /**
44
+ * The access token of a login session, rotated first when it is due. The
45
+ * session must belong to `expectedPrincipalIdArg`. Throws `AuthorityTokenError`.
46
+ */
47
+ liveProviderToken(sessionIdArg: string, expectedPrincipalIdArg: string): Promise<IProviderToken>;
48
+ }
49
+
50
+ /**
51
+ * The application's side of a provider login: who the verified subject is
52
+ * here. `admit` creates or updates the local principal (and whatever the
53
+ * application projects from the organization claims) and answers its id, or
54
+ * throws `RelyingPartyRefusal` to refuse the login; the provider tokens are
55
+ * then handed back and the refusal reaches the caller unchanged.
56
+ */
57
+ export interface IRelyingPartyPrincipals {
58
+ admit(completionArg: IOidcCompletion): Promise<{ principalId: string }>;
59
+ /** Whether a principal may act right now; a session of an inactive principal resolves to nothing. */
60
+ isActive(principalIdArg: string): Promise<boolean>;
61
+ }
62
+
63
+ export type TRelyingPartySessionEndReason =
64
+ /** the application or the user ended it */
65
+ | 'logout'
66
+ /** the provider refused to rotate its tokens: the login is over there */
67
+ | 'refresh_token_refused'
68
+ /** its refresh token was sealed under a key this deployment no longer has */
69
+ | 'sealed_refresh_token_unreadable'
70
+ /** its access token is dead and there is nothing to rotate it with */
71
+ | 'access_token_unusable'
72
+ /** a rotation found it revoked by someone else */
73
+ | 'revoked_elsewhere';
74
+
75
+ export type TRelyingPartyEventName =
76
+ | 'idp_token_refresh_failed'
77
+ | 'idp_token_store_failed'
78
+ | 'idp_session_ended'
79
+ | 'oidc_sealed_token_unreadable'
80
+ | 'oidc_token_revocation_failed'
81
+ | 'oidc_token_revocation_timed_out'
82
+ | 'login_attempt_sweep_failed'
83
+ /** an application hook threw; the step it belonged to ran on regardless */
84
+ | 'hook_failed'
85
+ /** a session whose opening failed could not be revoked; its provider tokens went back anyway */
86
+ | 'session_discard_failed';
87
+
88
+ /** The application hooks whose failures `hook_failed` names. */
89
+ export type TRelyingPartyHookName = 'onProviderSessionOpened' | 'onSessionEnded' | 'onEvent';
90
+
91
+ /** One structured diagnostic line. It never carries a token, a cookie or a sealed value. */
92
+ export interface IRelyingPartyEvent {
93
+ event: TRelyingPartyEventName;
94
+ sessionId?: string;
95
+ reason?: string;
96
+ hint?: TOidcTokenHint;
97
+ hook?: TRelyingPartyHookName;
98
+ message?: string;
99
+ }
100
+
101
+ export interface IRelyingPartyHooks {
102
+ /**
103
+ * A provider login opened a session, linked to its attempt. Runs before the
104
+ * login answers; a throw fails the login: the session is revoked, the
105
+ * provider tokens are handed back and the original error is rethrown.
106
+ */
107
+ onProviderSessionOpened?(eventArg: { session: IRelyingPartySession }): Promise<void>;
108
+ /**
109
+ * A session ended, or a caller tried to end one that was already over
110
+ * (`revokedHere: false`). Close its live connections when `revokedHere`,
111
+ * drop cached authority either way. Must not wait. A throw (or a rejected
112
+ * promise) is reported as a `hook_failed` event and never stops the end:
113
+ * the provider tokens go back regardless.
114
+ */
115
+ onSessionEnded?(eventArg: {
116
+ sessionId: string;
117
+ reason: TRelyingPartySessionEndReason;
118
+ revokedHere: boolean;
119
+ }): void;
120
+ /**
121
+ * Structured diagnostics; the default writes one JSON line to
122
+ * `console.error`. A throwing sink never interrupts the step that reported:
123
+ * the event and a `hook_failed` line go to `console.error` instead.
124
+ */
125
+ onEvent?(eventArg: IRelyingPartyEvent): void;
126
+ }
127
+
128
+ /** Where a failed login callback stopped; for the log, never for the browser. */
129
+ export type TLoginCallbackStage =
130
+ | TOidcCompletionFailureStage
131
+ | 'attempt_persistence'
132
+ | 'principal_admission'
133
+ | 'session_persistence';
@@ -0,0 +1,17 @@
1
+ import * as asyncHooks from 'node:async_hooks';
2
+ import * as crypto from 'node:crypto';
3
+
4
+ export { asyncHooks, crypto };
5
+
6
+ import * as typedrequest from '@api.global/typedrequest';
7
+ import * as typedsocket from '@api.global/typedsocket';
8
+
9
+ export { typedrequest, typedsocket };
10
+
11
+ import * as nosqldb from '@lossless.org/client/nosqldb';
12
+
13
+ export { nosqldb };
14
+
15
+ import * as openidClient from 'openid-client';
16
+
17
+ export { openidClient };