serverless-ircd 0.9.0 → 0.11.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/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* D1-backed SASL account store primitives for the Cloudflare adapter.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors the AWS `dynamo-account-store.ts` / `account-store.ts` pair:
|
|
5
|
-
* credentials are stored as scrypt hashes (never plaintext) in a D1
|
|
6
|
-
* `accounts` table and pre-loaded at {@link ConnectionDO} construction
|
|
7
|
-
* into the shared {@link HashedAccountStore}.
|
|
8
|
-
*
|
|
9
|
-
* Table-then-config precedence is implemented by {@link resolveAccountStore}:
|
|
10
|
-
* when the D1 table has rows it is authoritative; when it is empty (or
|
|
11
|
-
* unreachable, or the binding is absent) the code falls back to the
|
|
12
|
-
* `SASL_ACCOUNTS` env-var seed — preserving the config-seed behaviour for
|
|
13
|
-
* deployments that have not migrated to D1.
|
|
14
|
-
*
|
|
15
|
-
* The adapters diverge only at the scan/put boundary; the hashing helpers
|
|
16
|
-
* and the synchronous {@link HashedAccountStore} are shared via `irc-core`.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
type AccountStore,
|
|
21
|
-
type HashedAccountCredential,
|
|
22
|
-
HashedAccountStore,
|
|
23
|
-
InMemoryAccountStore,
|
|
24
|
-
hashAccountCredential,
|
|
25
|
-
} from '@serverless-ircd/irc-core';
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* SQL to create the `accounts` table. Exported so the seed tool and
|
|
29
|
-
* provisioning docs reference one schema definition.
|
|
30
|
-
*/
|
|
31
|
-
export const CREATE_ACCOUNTS_TABLE_SQL =
|
|
32
|
-
'CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* A raw D1 row — the untyped shape returned by `prepare().all()`. Each
|
|
36
|
-
* column is `unknown` until validated into a {@link HashedAccountCredential}.
|
|
37
|
-
*/
|
|
38
|
-
type AccountsRow = Record<string, unknown>;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Coerces a raw D1 row into a {@link HashedAccountCredential}, or returns
|
|
42
|
-
* `undefined` when the row is missing required columns or has the wrong
|
|
43
|
-
* types. Mirrors the silent-skip behaviour of the DynamoDB loader.
|
|
44
|
-
*/
|
|
45
|
-
function coerceRow(row: AccountsRow): HashedAccountCredential | undefined {
|
|
46
|
-
const { account, algorithm, salt, hash } = row;
|
|
47
|
-
if (
|
|
48
|
-
typeof account !== 'string' ||
|
|
49
|
-
typeof algorithm !== 'string' ||
|
|
50
|
-
typeof salt !== 'string' ||
|
|
51
|
-
typeof hash !== 'string'
|
|
52
|
-
) {
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
return {
|
|
56
|
-
account,
|
|
57
|
-
algorithm: algorithm as HashedAccountCredential['algorithm'],
|
|
58
|
-
salt,
|
|
59
|
-
hash,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Queries the D1 `accounts` table and returns a fully-populated
|
|
65
|
-
* {@link HashedAccountStore}, or `undefined` when the table has no rows.
|
|
66
|
-
*
|
|
67
|
-
* Rows missing required attributes are silently skipped. Callers that need
|
|
68
|
-
* the table-then-config fallback should use {@link resolveAccountStore}
|
|
69
|
-
* rather than catching `undefined` themselves.
|
|
70
|
-
*
|
|
71
|
-
* @param db The D1 binding (may be `undefined` when D1 is not configured).
|
|
72
|
-
*/
|
|
73
|
-
export async function loadD1AccountStore(
|
|
74
|
-
db: D1Database | undefined,
|
|
75
|
-
): Promise<HashedAccountStore | undefined> {
|
|
76
|
-
if (db === undefined) return undefined;
|
|
77
|
-
const result = await db
|
|
78
|
-
.prepare('SELECT account, algorithm, salt, hash FROM accounts')
|
|
79
|
-
.all<AccountsRow>();
|
|
80
|
-
const entries: HashedAccountCredential[] = [];
|
|
81
|
-
for (const row of result.results ?? []) {
|
|
82
|
-
const entry = coerceRow(row);
|
|
83
|
-
if (entry !== undefined) entries.push(entry);
|
|
84
|
-
}
|
|
85
|
-
if (entries.length === 0) return undefined;
|
|
86
|
-
return new HashedAccountStore(entries);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Writes (or re-seeds) a SASL PLAIN account into the D1 `accounts` table.
|
|
91
|
-
*
|
|
92
|
-
* The password is hashed via {@link hashAccountCredential} (scrypt, random
|
|
93
|
-
* salt) and stored as a {@link HashedAccountCredential} row — plaintext is
|
|
94
|
-
* never written. A repeat call for the same `username` overwrites the prior
|
|
95
|
-
* row (the PK is `account`), so this is the canonical CRUD/seed entry point
|
|
96
|
-
* for oper and SASL account provisioning.
|
|
97
|
-
*
|
|
98
|
-
* @returns The stored {@link HashedAccountCredential} (for tooling/tests).
|
|
99
|
-
*/
|
|
100
|
-
export async function putAccountCredential(
|
|
101
|
-
db: D1Database,
|
|
102
|
-
username: string,
|
|
103
|
-
password: string,
|
|
104
|
-
): Promise<HashedAccountCredential> {
|
|
105
|
-
const entry = hashAccountCredential(username, password);
|
|
106
|
-
await db
|
|
107
|
-
.prepare('INSERT OR REPLACE INTO accounts (account, algorithm, salt, hash) VALUES (?, ?, ?, ?)')
|
|
108
|
-
.bind(entry.account, entry.algorithm, entry.salt, entry.hash)
|
|
109
|
-
.run();
|
|
110
|
-
return entry;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Builds a self-contained `INSERT OR REPLACE` SQL statement that writes a
|
|
115
|
-
* scrypt-hashed account row, suitable for `wrangler d1 execute --command`.
|
|
116
|
-
*
|
|
117
|
-
* The password is hashed via {@link hashAccountCredential}; the plaintext
|
|
118
|
-
* never appears in the SQL. The values are single-quoted with embedded
|
|
119
|
-
* single quotes doubled (`'` → `''`) to avoid injection through the
|
|
120
|
-
* username or the base64 output.
|
|
121
|
-
*
|
|
122
|
-
* This is the pure (non-IO) half of {@link putAccountCredential}; the seed
|
|
123
|
-
* CLI (`tools/seed-cf-accounts.ts`) calls this and pipes the result to
|
|
124
|
-
* `wrangler d1 execute`, while the runtime `ConnectionDO` path uses the
|
|
125
|
-
* bound-parameter {@link putAccountCredential}.
|
|
126
|
-
*
|
|
127
|
-
* @returns The hashed credential and the executable SQL string.
|
|
128
|
-
*/
|
|
129
|
-
export function buildAccountPutStatement(
|
|
130
|
-
username: string,
|
|
131
|
-
password: string,
|
|
132
|
-
): { entry: HashedAccountCredential; sql: string } {
|
|
133
|
-
const entry = hashAccountCredential(username, password);
|
|
134
|
-
const sql = `INSERT OR REPLACE INTO accounts (account, algorithm, salt, hash) VALUES ('${sqlEscape(entry.account)}', '${sqlEscape(entry.algorithm)}', '${sqlEscape(entry.salt)}', '${sqlEscape(entry.hash)}')`;
|
|
135
|
-
return { entry, sql };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Doubles single quotes for safe embedding in a D1 SQL string literal. */
|
|
139
|
-
function sqlEscape(value: string): string {
|
|
140
|
-
return value.replace(/'/g, "''");
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Resolves the SASL account store for a CF deployment, applying the
|
|
145
|
-
* table-then-config precedence.
|
|
146
|
-
*
|
|
147
|
-
* 1. Queries the D1 `accounts` table (pre-loading every hashed credential
|
|
148
|
-
* into a {@link HashedAccountStore}). When the table has one or more
|
|
149
|
-
* rows it is authoritative — the table wins and the config seed is
|
|
150
|
-
* ignored.
|
|
151
|
-
* 2. When the table is empty (or the binding is absent), falls back to
|
|
152
|
-
* the `SASL_ACCOUNTS` env-var seed (an {@link InMemoryAccountStore})
|
|
153
|
-
* so existing deployments that have not migrated to D1 keep working.
|
|
154
|
-
* 3. Returns `undefined` when neither source has accounts so the actor's
|
|
155
|
-
* `ctx.accounts` stays unset (`AUTHENTICATE PLAIN` → `904`).
|
|
156
|
-
*
|
|
157
|
-
* Query failures are swallowed and treated as an empty table, falling
|
|
158
|
-
* through to the config seed — mirroring `resolveAccountStore` on AWS.
|
|
159
|
-
*
|
|
160
|
-
* @param db The D1 binding (`undefined` when D1 is not configured).
|
|
161
|
-
* @param saslAccountsRaw The raw `SASL_ACCOUNTS` env var (newline-delimited
|
|
162
|
-
* `username:password` pairs), or `undefined` when unset.
|
|
163
|
-
*/
|
|
164
|
-
export async function resolveAccountStore(
|
|
165
|
-
db: D1Database | undefined,
|
|
166
|
-
saslAccountsRaw: string | undefined,
|
|
167
|
-
): Promise<AccountStore | undefined> {
|
|
168
|
-
try {
|
|
169
|
-
const store = await loadD1AccountStore(db);
|
|
170
|
-
if (store !== undefined) return store;
|
|
171
|
-
} catch {
|
|
172
|
-
// Table might not exist yet or be unreachable; fall through to seed.
|
|
173
|
-
}
|
|
174
|
-
const seed = parseSeed(saslAccountsRaw);
|
|
175
|
-
if (seed.length === 0) return undefined;
|
|
176
|
-
return new InMemoryAccountStore(seed);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Parses the `SASL_ACCOUNTS` env var into credential entries for the
|
|
181
|
-
* config-seed fallback. Re-exported from the in-memory account-store module
|
|
182
|
-
* so the precedence resolver is self-contained.
|
|
183
|
-
*/
|
|
184
|
-
function parseSeed(raw: string | undefined): Array<{ username: string; password: string }> {
|
|
185
|
-
if (raw === undefined || raw.length === 0) return [];
|
|
186
|
-
const out: Array<{ username: string; password: string }> = [];
|
|
187
|
-
for (const line of raw.split('\n')) {
|
|
188
|
-
const trimmed = line.trim();
|
|
189
|
-
if (trimmed.length === 0) continue;
|
|
190
|
-
const sep = trimmed.indexOf(':');
|
|
191
|
-
if (sep <= 0) continue;
|
|
192
|
-
const username = trimmed.slice(0, sep);
|
|
193
|
-
const password = trimmed.slice(sep + 1);
|
|
194
|
-
if (username.length === 0 || password.length === 0) continue;
|
|
195
|
-
out.push({ username, password });
|
|
196
|
-
}
|
|
197
|
-
return out;
|
|
198
|
-
}
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* D1-backed SASL account store + precedence resolver.
|
|
3
|
-
*
|
|
4
|
-
* Exercises the real D1 round-trip via miniflare: `putAccountCredential`
|
|
5
|
-
* writes a hashed row, `loadD1AccountStore` reads it back, and
|
|
6
|
-
* `resolveAccountStore` applies the table-then-config precedence.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { env } from 'cloudflare:test';
|
|
10
|
-
import { hashAccountCredential } from '@serverless-ircd/irc-core';
|
|
11
|
-
import { beforeEach, describe, expect, it } from 'vitest';
|
|
12
|
-
import {
|
|
13
|
-
buildAccountPutStatement,
|
|
14
|
-
loadD1AccountStore,
|
|
15
|
-
putAccountCredential,
|
|
16
|
-
resolveAccountStore,
|
|
17
|
-
} from '../src/d1-account-store';
|
|
18
|
-
|
|
19
|
-
declare global {
|
|
20
|
-
namespace Cloudflare {
|
|
21
|
-
interface Env {
|
|
22
|
-
ACCOUNTS_DB: D1Database;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const CREATE_TABLE =
|
|
28
|
-
'CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
|
|
29
|
-
|
|
30
|
-
async function resetDb(): Promise<void> {
|
|
31
|
-
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
|
|
32
|
-
await env.ACCOUNTS_DB.exec(CREATE_TABLE);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
describe('loadD1AccountStore', () => {
|
|
36
|
-
beforeEach(async () => {
|
|
37
|
-
await resetDb();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('returns undefined when the table has no rows', async () => {
|
|
41
|
-
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
42
|
-
expect(store).toBeUndefined();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('verifies an account after a row is written via putAccountCredential', async () => {
|
|
46
|
-
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 's3cret');
|
|
47
|
-
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
48
|
-
expect(store).toBeDefined();
|
|
49
|
-
expect(
|
|
50
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' }),
|
|
51
|
-
).toEqual({ ok: true, account: 'alice' });
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('rejects a wrong password after round-trip', async () => {
|
|
55
|
-
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'right');
|
|
56
|
-
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
57
|
-
expect(store).toBeDefined();
|
|
58
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
|
|
59
|
-
false,
|
|
60
|
-
);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('loads multiple distinct accounts', async () => {
|
|
64
|
-
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'a1');
|
|
65
|
-
await putAccountCredential(env.ACCOUNTS_DB, 'bob', 'b2');
|
|
66
|
-
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
67
|
-
expect(store?.size).toBe(2);
|
|
68
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
|
|
69
|
-
true,
|
|
70
|
-
);
|
|
71
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(
|
|
72
|
-
true,
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('overwrites an existing account on re-seed', async () => {
|
|
77
|
-
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'old');
|
|
78
|
-
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'new');
|
|
79
|
-
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
80
|
-
expect(store?.size).toBe(1);
|
|
81
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
|
|
82
|
-
false,
|
|
83
|
-
);
|
|
84
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
|
|
85
|
-
true,
|
|
86
|
-
);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('skips rows missing required columns', async () => {
|
|
90
|
-
// Use a permissive table (no NOT NULL) to simulate a row with a missing
|
|
91
|
-
// salt — the production schema's NOT NULL prevents this, but the loader
|
|
92
|
-
// stays defensive against schema drift / manual edits.
|
|
93
|
-
await env.ACCOUNTS_DB.exec('DROP TABLE accounts');
|
|
94
|
-
await env.ACCOUNTS_DB.exec(
|
|
95
|
-
'CREATE TABLE accounts (account TEXT PRIMARY KEY, algorithm TEXT, salt TEXT, hash TEXT)',
|
|
96
|
-
);
|
|
97
|
-
await env.ACCOUNTS_DB.prepare(
|
|
98
|
-
"INSERT INTO accounts (account, algorithm, hash) VALUES ('bad', 'scrypt', 'bbbb')",
|
|
99
|
-
).run();
|
|
100
|
-
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
101
|
-
expect(store).toBeUndefined();
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe('putAccountCredential', () => {
|
|
106
|
-
beforeEach(async () => {
|
|
107
|
-
await resetDb();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('writes a hashed row that round-trips through the store', async () => {
|
|
111
|
-
const entry = await putAccountCredential(env.ACCOUNTS_DB, 'carol', 'p@ss');
|
|
112
|
-
expect(entry.account).toBe('carol');
|
|
113
|
-
expect(entry.algorithm).toBe('scrypt');
|
|
114
|
-
|
|
115
|
-
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
116
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'carol', password: 'p@ss' })).toEqual({
|
|
117
|
-
ok: true,
|
|
118
|
-
account: 'carol',
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('never writes the plaintext password to the row', async () => {
|
|
123
|
-
const entry = await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'hunter2-secret');
|
|
124
|
-
expect(entry.account).not.toContain('hunter2');
|
|
125
|
-
expect(entry.salt).not.toContain('hunter2');
|
|
126
|
-
expect(entry.hash).not.toContain('hunter2');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('matches a manually-hashed entry written via raw D1 insert', async () => {
|
|
130
|
-
const hashed = hashAccountCredential('dave', 'pw');
|
|
131
|
-
await env.ACCOUNTS_DB.prepare(
|
|
132
|
-
'INSERT INTO accounts (account, algorithm, salt, hash) VALUES (?, ?, ?, ?)',
|
|
133
|
-
)
|
|
134
|
-
.bind(hashed.account, hashed.algorithm, hashed.salt, hashed.hash)
|
|
135
|
-
.run();
|
|
136
|
-
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
137
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'dave', password: 'pw' }).ok).toBe(
|
|
138
|
-
true,
|
|
139
|
-
);
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe('buildAccountPutStatement (seed SQL builder)', () => {
|
|
144
|
-
it('produces an INSERT OR REPLACE statement with scrypt-hashed values', () => {
|
|
145
|
-
const { entry, sql } = buildAccountPutStatement('alice', 's3cret');
|
|
146
|
-
expect(entry.account).toBe('alice');
|
|
147
|
-
expect(entry.algorithm).toBe('scrypt');
|
|
148
|
-
expect(sql).toContain('INSERT OR REPLACE INTO accounts');
|
|
149
|
-
// Plaintext must never appear in the SQL.
|
|
150
|
-
expect(sql).not.toContain('s3cret');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('the generated SQL round-trips through loadD1AccountStore', async () => {
|
|
154
|
-
await resetDb();
|
|
155
|
-
const { sql } = buildAccountPutStatement('carol', 'p@ss');
|
|
156
|
-
await env.ACCOUNTS_DB.exec(sql);
|
|
157
|
-
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
158
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'carol', password: 'p@ss' })).toEqual({
|
|
159
|
-
ok: true,
|
|
160
|
-
account: 'carol',
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('escapes single quotes in the username to avoid breaking the SQL', async () => {
|
|
165
|
-
await resetDb();
|
|
166
|
-
const { sql } = buildAccountPutStatement("o'brien", 'pw');
|
|
167
|
-
await env.ACCOUNTS_DB.exec(sql);
|
|
168
|
-
const store = await loadD1AccountStore(env.ACCOUNTS_DB);
|
|
169
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: "o'brien", password: 'pw' }).ok).toBe(
|
|
170
|
-
true,
|
|
171
|
-
);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it('escapes single quotes in the base64 hash output', () => {
|
|
175
|
-
// The hash is base64 so it won't contain quotes, but the escaper is
|
|
176
|
-
// still exercised — verify the SQL is well-formed.
|
|
177
|
-
const { sql } = buildAccountPutStatement('alice', 'secret');
|
|
178
|
-
// A valid INSERT with four quoted values.
|
|
179
|
-
expect(sql.match(/'/g)?.length).toBe(8);
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
describe('resolveAccountStore precedence', () => {
|
|
184
|
-
beforeEach(async () => {
|
|
185
|
-
await resetDb();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('returns the D1 store when the table has rows (table wins)', async () => {
|
|
189
|
-
await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'table-secret');
|
|
190
|
-
const store = await resolveAccountStore(env.ACCOUNTS_DB, 'envuser:env-secret');
|
|
191
|
-
expect(store).toBeDefined();
|
|
192
|
-
expect(
|
|
193
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
|
|
194
|
-
).toBe(true);
|
|
195
|
-
// Config account does NOT verify (table is authoritative).
|
|
196
|
-
expect(
|
|
197
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
198
|
-
).toBe(false);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('falls back to the SASL_ACCOUNTS seed when the table is empty', async () => {
|
|
202
|
-
const store = await resolveAccountStore(env.ACCOUNTS_DB, 'envuser:env-secret');
|
|
203
|
-
expect(store).toBeDefined();
|
|
204
|
-
expect(
|
|
205
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
206
|
-
).toBe(true);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('returns undefined when the table is empty and no seed is set', async () => {
|
|
210
|
-
const store = await resolveAccountStore(env.ACCOUNTS_DB, undefined);
|
|
211
|
-
expect(store).toBeUndefined();
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it('returns undefined when the table is empty and the seed is blank', async () => {
|
|
215
|
-
const store = await resolveAccountStore(env.ACCOUNTS_DB, '');
|
|
216
|
-
expect(store).toBeUndefined();
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it('falls back to the seed when the D1 binding is undefined', async () => {
|
|
220
|
-
const store = await resolveAccountStore(undefined, 'envuser:env-secret');
|
|
221
|
-
expect(store).toBeDefined();
|
|
222
|
-
expect(
|
|
223
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
224
|
-
).toBe(true);
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
// resolveAccountStore seed parsing — malformed entries are skipped so a
|
|
230
|
-
// trailing newline or partial edit does not break boot.
|
|
231
|
-
// ---------------------------------------------------------------------------
|
|
232
|
-
|
|
233
|
-
describe('resolveAccountStore — SASL_ACCOUNTS seed edge cases', () => {
|
|
234
|
-
it('skips blank lines within the seed', async () => {
|
|
235
|
-
const store = await resolveAccountStore(undefined, 'alice:secret\n\nbob:pw\n \n');
|
|
236
|
-
expect(store).toBeDefined();
|
|
237
|
-
expect(
|
|
238
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' }).ok,
|
|
239
|
-
).toBe(true);
|
|
240
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'pw' }).ok).toBe(
|
|
241
|
-
true,
|
|
242
|
-
);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it('skips lines with no colon separator', async () => {
|
|
246
|
-
const store = await resolveAccountStore(undefined, 'alice:secret\nnocolon\nbob:pw');
|
|
247
|
-
// Only the two well-formed entries are loaded.
|
|
248
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'nocolon', password: '' }).ok).toBe(
|
|
249
|
-
false,
|
|
250
|
-
);
|
|
251
|
-
expect(
|
|
252
|
-
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' }).ok,
|
|
253
|
-
).toBe(true);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('skips lines where the colon is at position 0', async () => {
|
|
257
|
-
const store = await resolveAccountStore(undefined, ':secret\nalice:valid');
|
|
258
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'valid' }).ok).toBe(
|
|
259
|
-
true,
|
|
260
|
-
);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it('skips lines with an empty password', async () => {
|
|
264
|
-
const store = await resolveAccountStore(undefined, 'alice:\nbob:valid');
|
|
265
|
-
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'valid' }).ok).toBe(
|
|
266
|
-
true,
|
|
267
|
-
);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('returns undefined when every line is malformed', async () => {
|
|
271
|
-
const store = await resolveAccountStore(undefined, '\n \n:no-user\nuser:');
|
|
272
|
-
expect(store).toBeUndefined();
|
|
273
|
-
});
|
|
274
|
-
});
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
type AccountStore,
|
|
4
|
-
InMemoryAccountStore,
|
|
5
|
-
type SaslPayload,
|
|
6
|
-
type SaslResult,
|
|
7
|
-
} from '../src/ports';
|
|
8
|
-
|
|
9
|
-
/** Convenience: a PLAIN payload. */
|
|
10
|
-
function plain(username: string, password: string): SaslPayload {
|
|
11
|
-
return { kind: 'PLAIN', username, password };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe('InMemoryAccountStore — PLAIN', () => {
|
|
15
|
-
it('implements the AccountStore port', () => {
|
|
16
|
-
const store: AccountStore = new InMemoryAccountStore([
|
|
17
|
-
{ username: 'alice', password: 'secret' },
|
|
18
|
-
]);
|
|
19
|
-
expect(store).toBeDefined();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('accepts valid PLAIN credentials and returns the account name', () => {
|
|
23
|
-
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
24
|
-
const out = store.verify('PLAIN', plain('alice', 'secret'));
|
|
25
|
-
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('rejects a wrong password', () => {
|
|
29
|
-
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
30
|
-
const out = store.verify('PLAIN', plain('alice', 'wrong'));
|
|
31
|
-
expect(out.ok).toBe(false);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('rejects an unknown username', () => {
|
|
35
|
-
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
36
|
-
const out = store.verify('PLAIN', plain('bob', 'secret'));
|
|
37
|
-
expect(out.ok).toBe(false);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('matches the first of several configured accounts', () => {
|
|
41
|
-
const store = new InMemoryAccountStore([
|
|
42
|
-
{ username: 'alice', password: 'a-secret' },
|
|
43
|
-
{ username: 'bob', password: 'b-secret' },
|
|
44
|
-
]);
|
|
45
|
-
expect(store.verify('PLAIN', plain('bob', 'b-secret'))).toEqual<SaslResult>({
|
|
46
|
-
ok: true,
|
|
47
|
-
account: 'bob',
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('rejects when constructed with an empty account list', () => {
|
|
52
|
-
const store = new InMemoryAccountStore([]);
|
|
53
|
-
const out = store.verify('PLAIN', plain('alice', 'secret'));
|
|
54
|
-
expect(out.ok).toBe(false);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('does not confuse credentials across accounts (no password cross-match)', () => {
|
|
58
|
-
const store = new InMemoryAccountStore([
|
|
59
|
-
{ username: 'alice', password: 'a-secret' },
|
|
60
|
-
{ username: 'bob', password: 'b-secret' },
|
|
61
|
-
]);
|
|
62
|
-
// bob's password with alice's username must fail.
|
|
63
|
-
expect(store.verify('PLAIN', plain('alice', 'b-secret')).ok).toBe(false);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe('InMemoryAccountStore — mechanism handling', () => {
|
|
68
|
-
it('rejects an unsupported mechanism with a failure result', () => {
|
|
69
|
-
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
70
|
-
const out = store.verify('CRAM-MD5', { kind: 'RAW', data: 'whatever' });
|
|
71
|
-
expect(out.ok).toBe(false);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('treats the mechanism name case-insensitively for PLAIN', () => {
|
|
75
|
-
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
76
|
-
const out = store.verify('plain', plain('alice', 'secret'));
|
|
77
|
-
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe('InMemoryAccountStore — EXTERNAL (mTLS)', () => {
|
|
82
|
-
it('maps a known cert subject to its account', () => {
|
|
83
|
-
const store = new InMemoryAccountStore([
|
|
84
|
-
{ username: 'alice', password: 'secret', certSubject: 'CN=alice' },
|
|
85
|
-
]);
|
|
86
|
-
const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=alice' });
|
|
87
|
-
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('rejects an unknown cert subject', () => {
|
|
91
|
-
const store = new InMemoryAccountStore([
|
|
92
|
-
{ username: 'alice', password: 'secret', certSubject: 'CN=alice' },
|
|
93
|
-
]);
|
|
94
|
-
const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=evil' });
|
|
95
|
-
expect(out.ok).toBe(false);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('rejects EXTERNAL when no cert subjects are configured', () => {
|
|
99
|
-
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
100
|
-
const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=alice' });
|
|
101
|
-
expect(out.ok).toBe(false);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('treats the mechanism name case-insensitively for EXTERNAL', () => {
|
|
105
|
-
const store = new InMemoryAccountStore([
|
|
106
|
-
{ username: 'alice', password: 'secret', certSubject: 'CN=alice' },
|
|
107
|
-
]);
|
|
108
|
-
const out = store.verify('external', { kind: 'EXTERNAL', identity: 'CN=alice' });
|
|
109
|
-
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('maps multiple accounts with distinct cert subjects', () => {
|
|
113
|
-
const store = new InMemoryAccountStore([
|
|
114
|
-
{ username: 'alice', password: 'a', certSubject: 'CN=alice' },
|
|
115
|
-
{ username: 'bob', password: 'b', certSubject: 'CN=bob' },
|
|
116
|
-
]);
|
|
117
|
-
expect(store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=bob' })).toEqual<SaslResult>({
|
|
118
|
-
ok: true,
|
|
119
|
-
account: 'bob',
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe('InMemoryAccountStore — failure reason', () => {
|
|
125
|
-
it('returns a human-readable reason on failure', () => {
|
|
126
|
-
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
127
|
-
const out = store.verify('PLAIN', plain('alice', 'wrong'));
|
|
128
|
-
if (out.ok) throw new Error('expected failure');
|
|
129
|
-
expect(out.reason.length).toBeGreaterThan(0);
|
|
130
|
-
});
|
|
131
|
-
});
|