@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.
- package/changelog.md +28 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_relyingparty/classes.actorscope.d.ts +23 -0
- package/dist_ts_relyingparty/classes.actorscope.js +22 -0
- package/dist_ts_relyingparty/classes.authorityobserver.d.ts +81 -0
- package/dist_ts_relyingparty/classes.authorityobserver.js +185 -0
- package/dist_ts_relyingparty/classes.errors.d.ts +37 -0
- package/dist_ts_relyingparty/classes.errors.js +60 -0
- package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +142 -0
- package/dist_ts_relyingparty/classes.idpoidcprovider.js +255 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +102 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +248 -0
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +223 -0
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +825 -0
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +87 -0
- package/dist_ts_relyingparty/classes.rpmodels.js +302 -0
- package/dist_ts_relyingparty/classes.secretbox.d.ts +14 -0
- package/dist_ts_relyingparty/classes.secretbox.js +45 -0
- package/dist_ts_relyingparty/classes.socketbinding.d.ts +84 -0
- package/dist_ts_relyingparty/classes.socketbinding.js +100 -0
- package/dist_ts_relyingparty/helpers.cookies.d.ts +11 -0
- package/dist_ts_relyingparty/helpers.cookies.js +62 -0
- package/dist_ts_relyingparty/helpers.crypto.d.ts +12 -0
- package/dist_ts_relyingparty/helpers.crypto.js +40 -0
- package/dist_ts_relyingparty/helpers.returnpath.d.ts +9 -0
- package/dist_ts_relyingparty/helpers.returnpath.js +39 -0
- package/dist_ts_relyingparty/index.d.ts +13 -0
- package/dist_ts_relyingparty/index.js +14 -0
- package/dist_ts_relyingparty/interfaces.d.ts +142 -0
- package/dist_ts_relyingparty/interfaces.js +2 -0
- package/dist_ts_relyingparty/plugins.d.ts +10 -0
- package/dist_ts_relyingparty/plugins.js +11 -0
- package/package.json +13 -10
- package/readme.md +215 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.actorscope.ts +39 -0
- package/ts_relyingparty/classes.authorityobserver.ts +253 -0
- package/ts_relyingparty/classes.errors.ts +77 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +386 -0
- package/ts_relyingparty/classes.relyingpartyroutes.ts +319 -0
- package/ts_relyingparty/classes.relyingpartysessions.ts +1017 -0
- package/ts_relyingparty/classes.rpmodels.ts +170 -0
- package/ts_relyingparty/classes.secretbox.ts +49 -0
- package/ts_relyingparty/classes.socketbinding.ts +156 -0
- package/ts_relyingparty/helpers.cookies.ts +79 -0
- package/ts_relyingparty/helpers.crypto.ts +49 -0
- package/ts_relyingparty/helpers.returnpath.ts +44 -0
- package/ts_relyingparty/index.ts +13 -0
- package/ts_relyingparty/interfaces.ts +165 -0
- 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';
|