@idp.global/sdk 18.0.1 → 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.
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 +37 -0
  8. package/dist_ts_relyingparty/classes.errors.js +60 -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 +102 -0
  12. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +248 -0
  13. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +223 -0
  14. package/dist_ts_relyingparty/classes.relyingpartysessions.js +825 -0
  15. package/dist_ts_relyingparty/classes.rpmodels.d.ts +87 -0
  16. package/dist_ts_relyingparty/classes.rpmodels.js +302 -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 +84 -0
  20. package/dist_ts_relyingparty/classes.socketbinding.js +100 -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 +142 -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 +13 -10
  34. package/readme.md +215 -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 +77 -0
  39. package/ts_relyingparty/classes.idpoidcprovider.ts +386 -0
  40. package/ts_relyingparty/classes.relyingpartyroutes.ts +319 -0
  41. package/ts_relyingparty/classes.relyingpartysessions.ts +1017 -0
  42. package/ts_relyingparty/classes.rpmodels.ts +170 -0
  43. package/ts_relyingparty/classes.secretbox.ts +49 -0
  44. package/ts_relyingparty/classes.socketbinding.ts +156 -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 +165 -0
  50. package/ts_relyingparty/plugins.ts +17 -0
@@ -0,0 +1,170 @@
1
+ import * as plugins from './plugins.js';
2
+ import { RelyingPartyDatabaseUnavailableError } from './classes.errors.js';
3
+
4
+ /**
5
+ * One browser session of a relying party. The persisted field names, their
6
+ * types and the index declarations are the ones nevermind.finance has stored
7
+ * under `SessionDoc` since before this module existed, so its collection is
8
+ * read and written unchanged.
9
+ */
10
+ export class RelyingPartySessionDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartySessionDoc, RelyingPartySessionDoc> {
11
+ @plugins.nosqldb.unI()
12
+ public id: string = '';
13
+
14
+ /** sha256 of the cookie token; the token itself is never stored */
15
+ @plugins.nosqldb.unI()
16
+ public tokenHash: string = '';
17
+
18
+ /** the application's principal (user) this session acts as */
19
+ @plugins.nosqldb.svDb()
20
+ @plugins.nosqldb.index()
21
+ public userId: string = '';
22
+
23
+ @plugins.nosqldb.svDb()
24
+ public createdAt: number = 0;
25
+
26
+ @plugins.nosqldb.svDb()
27
+ public lastSeenAt: number = 0;
28
+
29
+ @plugins.nosqldb.svDb()
30
+ public expiresAt: number = 0;
31
+
32
+ /** absent while the session is valid */
33
+ @plugins.nosqldb.svDb()
34
+ public revokedAt?: number;
35
+
36
+ /** the provider subject of a provider login; absent for a session the application opened itself */
37
+ @plugins.nosqldb.svDb()
38
+ public idpSubject?: string;
39
+
40
+ /** the login attempt this session came out of; finalize matches it */
41
+ @plugins.nosqldb.svDb()
42
+ public loginAttemptId?: string;
43
+
44
+ /** sealed with the session encryption key; only the server ever sees the tokens */
45
+ @plugins.nosqldb.svDb()
46
+ public sealedAccessToken?: string;
47
+
48
+ @plugins.nosqldb.svDb()
49
+ public sealedRefreshToken?: string;
50
+
51
+ /** when the sealed access token dies, epoch milliseconds; `0` when the provider named no lifetime */
52
+ @plugins.nosqldb.svDb()
53
+ public accessTokenExpiresAt?: number;
54
+
55
+ /** until when one replica holds the right to rotate this session's tokens, epoch milliseconds */
56
+ @plugins.nosqldb.svDb()
57
+ public refreshLockUntil?: number;
58
+ }
59
+
60
+ /**
61
+ * One browser's sign-in transaction: the state hash the callback is matched
62
+ * against, the binding to the browser that started it, the sealed PKCE
63
+ * verifier and the return path. Consumed exactly once by a conditional update.
64
+ * Field names and indexes are the ones nevermind.finance stores under
65
+ * `LoginAttemptDoc`.
66
+ */
67
+ export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyLoginAttemptDoc, RelyingPartyLoginAttemptDoc> {
68
+ @plugins.nosqldb.unI()
69
+ public id: string = '';
70
+
71
+ /** sha256 of the `state` parameter; the parameter itself is never stored */
72
+ @plugins.nosqldb.unI()
73
+ public stateHash: string = '';
74
+
75
+ /** sha256 of the intent cookie value; binds the callback to the browser that started it */
76
+ @plugins.nosqldb.unI()
77
+ public bindingTokenHash: string = '';
78
+
79
+ @plugins.nosqldb.svDb()
80
+ public sealedPkceVerifier: string = '';
81
+
82
+ @plugins.nosqldb.svDb()
83
+ public nonce: string = '';
84
+
85
+ /** same-origin path the browser returns to */
86
+ @plugins.nosqldb.svDb()
87
+ public returnPath: string = '/';
88
+
89
+ @plugins.nosqldb.svDb()
90
+ public createdAt: number = 0;
91
+
92
+ @plugins.nosqldb.svDb()
93
+ public expiresAt: number = 0;
94
+
95
+ /** absent until the callback consumes the attempt; `null` matches "not consumed" in a filter */
96
+ @plugins.nosqldb.svDb()
97
+ public consumedAt?: number | null;
98
+
99
+ /** the session the callback created; finalize releases the return path against it */
100
+ @plugins.nosqldb.svDb()
101
+ public createdSessionId?: string | null;
102
+ }
103
+
104
+ export const RELYING_PARTY_DEFAULT_COLLECTION_NAMES = {
105
+ sessions: 'IdpRelyingPartySession',
106
+ loginAttempts: 'IdpRelyingPartyLoginAttempt',
107
+ } as const;
108
+
109
+ export interface IRelyingPartyModelsOptions {
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);
117
+ /**
118
+ * The persisted collection names. An application that already stores these
119
+ * documents names its existing collections here.
120
+ */
121
+ collectionNames?: {
122
+ sessions?: string;
123
+ loginAttempts?: string;
124
+ };
125
+ }
126
+
127
+ export interface IRelyingPartyModels {
128
+ readonly Session: typeof RelyingPartySessionDoc;
129
+ readonly LoginAttempt: typeof RelyingPartyLoginAttemptDoc;
130
+ }
131
+
132
+ /**
133
+ * Binds the two relying-party models to one database. Each call declares its
134
+ * own model classes, so several databases — and several applications in one
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`.
138
+ */
139
+ export const createRelyingPartyModels = (optionsArg: IRelyingPartyModelsOptions): IRelyingPartyModels => {
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');
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
+ };
159
+ const sessions = optionsArg.collectionNames?.sessions ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.sessions;
160
+ const loginAttempts = optionsArg.collectionNames?.loginAttempts
161
+ ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.loginAttempts;
162
+
163
+ @plugins.nosqldb.Collection(resolveDb, { collectionName: sessions })
164
+ class BoundSessionDoc extends RelyingPartySessionDoc {}
165
+
166
+ @plugins.nosqldb.Collection(resolveDb, { collectionName: loginAttempts })
167
+ class BoundLoginAttemptDoc extends RelyingPartyLoginAttemptDoc {}
168
+
169
+ return { Session: BoundSessionDoc, LoginAttempt: BoundLoginAttemptDoc };
170
+ };
@@ -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,156 @@
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
+ /** 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
+
23
+ /** What a browser connection is pinned to for its whole life; no cookie and no CSRF token is kept. */
24
+ export interface IRelyingPartyPinnedSession {
25
+ sessionId: string;
26
+ principalId: string;
27
+ /** SHA-256 of the CSRF token the registration proved */
28
+ csrfTokenHash: string;
29
+ }
30
+
31
+ export interface IRelyingPartySocketRefusal {
32
+ code: 'invalid_input' | 'forbidden' | 'unauthorized';
33
+ status: number;
34
+ }
35
+
36
+ export type TRelyingPartySocketRegistration =
37
+ | { registered: true; pinned: IRelyingPartyPinnedSession; expiresAt: number }
38
+ | { registered: false; refusal: IRelyingPartySocketRefusal };
39
+
40
+ export interface IRelyingPartySocketBindingOptions<TPrincipal extends object | undefined = undefined> {
41
+ sessions: RelyingPartySessions<TPrincipal>;
42
+ /** the transport typedserver created; `undefined` before the server runs */
43
+ transport: () => TRelyingPartySocketTransport | undefined;
44
+ /** the server-owned tag prefix that finds the connections of a session */
45
+ tagPrefix?: string;
46
+ }
47
+
48
+ /**
49
+ * Binds typed socket connections to relying-party browser sessions. A
50
+ * connection registers once with the CSRF token of the page; the proof is the
51
+ * upgrade's own `Origin` and `Cookie` — which only a browser on this origin
52
+ * sends — plus the token, and the connection is pinned to the session. Every
53
+ * later request re-validates the session with the same strength as an HTTP
54
+ * request. Connection limits, rate limits and sweeps belong to the
55
+ * application's socket gate.
56
+ */
57
+ export class RelyingPartySocketBinding<TPrincipal extends object | undefined = undefined> {
58
+ readonly #sessions: RelyingPartySessions<TPrincipal>;
59
+ readonly #transport: () => TRelyingPartySocketTransport | undefined;
60
+ public readonly tagPrefix: string;
61
+
62
+ constructor(optionsArg: IRelyingPartySocketBindingOptions<TPrincipal>) {
63
+ this.#sessions = optionsArg.sessions;
64
+ this.#transport = optionsArg.transport;
65
+ this.tagPrefix = optionsArg.tagPrefix ?? 'idp_rp_session:';
66
+ }
67
+
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;
71
+ return { origin: headers.get('origin'), cookie: headers.get('cookie') };
72
+ }
73
+
74
+ /**
75
+ * What a registration payload `{ csrfToken }` proves, given the upgrade it
76
+ * came on. No connection state is touched, so the application decides about
77
+ * its limits between this answer and `tagConnection`.
78
+ */
79
+ public async resolveBrowserRegistration(
80
+ payloadArg: unknown,
81
+ upgradeArg: { origin: string | null; cookie: string | null },
82
+ ): Promise<TRelyingPartySocketRegistration> {
83
+ if (!payloadArg || typeof payloadArg !== 'object' || !Reflect.has(payloadArg, 'csrfToken')) {
84
+ return { registered: false, refusal: { code: 'invalid_input', status: 400 } };
85
+ }
86
+ if (upgradeArg.origin !== this.#sessions.publicOrigin) {
87
+ return { registered: false, refusal: { code: 'forbidden', status: 403 } };
88
+ }
89
+ const resolved = await this.#sessions.resolveCookieSession(upgradeArg.cookie);
90
+ if (!resolved) {
91
+ return { registered: false, refusal: { code: 'unauthorized', status: 401 } };
92
+ }
93
+ if (!this.#sessions.validateCsrf(resolved, Reflect.get(payloadArg, 'csrfToken'))) {
94
+ return { registered: false, refusal: { code: 'forbidden', status: 403 } };
95
+ }
96
+ return {
97
+ registered: true,
98
+ pinned: {
99
+ sessionId: resolved.session.id,
100
+ principalId: resolved.session.principalId,
101
+ csrfTokenHash: sha256(resolved.csrfToken),
102
+ },
103
+ expiresAt: resolved.session.expiresAt,
104
+ };
105
+ }
106
+
107
+ public tagOf(sessionIdArg: string): string {
108
+ return this.tagPrefix + sessionIdArg;
109
+ }
110
+
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 {
116
+ const transport = this.#transport();
117
+ if (!transport) {
118
+ throw new Error('The socket transport is not running');
119
+ }
120
+ transport.setServerTag(targetArg, this.tagOf(sessionIdArg));
121
+ }
122
+
123
+ /**
124
+ * The pinned session as it stands right now: re-read, live, its principal
125
+ * active and unchanged (and resolved, with `principals.resolve`), touched
126
+ * under the liveness it was read with. The
127
+ * browser keeps proving itself with its CSRF token, compared against the
128
+ * hash the registration kept. Nothing of this is cached.
129
+ */
130
+ public async revalidate(
131
+ pinnedArg: IRelyingPartyPinnedSession,
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) {
135
+ return null;
136
+ }
137
+ const expectedHash = pinnedArg.csrfTokenHash;
138
+ return {
139
+ session: live.session,
140
+ principal: live.principal,
141
+ verifyCsrf: (candidateArg) =>
142
+ typeof candidateArg === 'string'
143
+ && expectedHash.length > 0
144
+ && timingSafeEqualString(expectedHash, sha256(candidateArg)),
145
+ };
146
+ }
147
+
148
+ /** The live connections of one session, for closing them when it ends. */
149
+ public async connectionsOfSession(sessionIdArg: string): Promise<readonly TRelyingPartySocketConnection[]> {
150
+ const transport = this.#transport();
151
+ if (!transport) {
152
+ return [];
153
+ }
154
+ return transport.findAllTargetConnectionsByTag(this.tagOf(sessionIdArg));
155
+ }
156
+ }
@@ -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';