@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.
- 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 +33 -0
- package/dist_ts_relyingparty/classes.errors.js +53 -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 +84 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +232 -0
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +210 -0
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +786 -0
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +79 -0
- package/dist_ts_relyingparty/classes.rpmodels.js +283 -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 +75 -0
- package/dist_ts_relyingparty/classes.socketbinding.js +94 -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 +113 -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 +14 -11
- package/readme.md +158 -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 +69 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +386 -0
- package/ts_relyingparty/classes.relyingpartyroutes.ts +286 -0
- package/ts_relyingparty/classes.relyingpartysessions.ts +970 -0
- package/ts_relyingparty/classes.rpmodels.ts +146 -0
- package/ts_relyingparty/classes.secretbox.ts +49 -0
- package/ts_relyingparty/classes.socketbinding.ts +142 -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 +133 -0
- 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 };
|