Phase 19: Local Auth (No-OIDC Mode) #23

Merged
luckberg merged 78 commits from gsd/phase-19-local-auth-no-oidc-mode into main 2026-06-18 06:25:00 -04:00
7 changed files with 1218 additions and 2 deletions
Showing only changes of commit 96f0991605 - Show all commits
+3 -1
View File
@@ -2,7 +2,9 @@
.env
.env.*
!.env.example
apps/api/scripts/seed-credential.mjs
# Phase 19 (D-15 / IMG-02): exclude the entire break-glass scripts directory so
# reset-admin.ts and any future dev-only scripts never ship in the production image.
apps/api/scripts/
# === VCS (large and unnecessary) ===
.git
@@ -0,0 +1,14 @@
CREATE TABLE `local_credentials` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` int NOT NULL,
`username` varchar(128) NOT NULL,
`password_hash` varchar(256) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`),
CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`),
CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`)
);
--> statement-breakpoint
ALTER TABLE `local_credentials` ADD CONSTRAINT `local_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_local_credentials_user_id` ON `local_credentials` (`user_id`);
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,13 @@
"when": 1781545048917,
"tag": "0002_lethal_millenium_guard",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1781727317172,
"tag": "0003_warm_deathstrike",
"breakpoints": true
}
]
}
+41
View File
@@ -309,6 +309,47 @@ export const appConfig = mysqlTable('app_config', {
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
});
/**
* Local authentication credentials per member (Phase 19 — D-09).
*
* Stores username + PHC-encoded scrypt password hash for members who authenticate
* via local username/password rather than (or before) OIDC.
*
* Design decisions:
* - Separate table from `users` to keep the users row identity-method-agnostic (D-09).
* - A user has a local login iff a `local_credentials` row exists (UNIQUE on user_id).
* - OIDC-link flow (D-12): when a local user links OIDC, their `local_credentials`
* row is deleted — they become OIDC-only.
* - CASCADE DELETE on users.id keeps credentials clean when a member is removed.
* - username is globally unique (login identifier, separate from displayName).
* - password_hash is PHC-encoded: scrypt$N$r$p$<salt_b64url>$<hash_b64url> (varchar 256).
*
* PROHIBITION: LOCAL_SESSION_SECRET (the signing key for this table's sessions) is an
* env-only secret and must NEVER be stored in this table or app_config (SC-3).
*/
export const localCredentials = mysqlTable(
'local_credentials',
{
id: int().primaryKey().autoincrement(),
userId: int('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
username: varchar('username', { length: 128 }).notNull(),
// PHC-encoded: scrypt$N$r$p$<salt_base64url>$<hash_base64url> — max ~83 chars
passwordHash: varchar('password_hash', { length: 256 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [
// One local credential per user — user_id is unique (D-09: auth method is per-user property)
unique('uniq_local_cred_user').on(t.userId),
// Username is globally unique (login identifier; case-sensitive per MariaDB default)
unique('uniq_local_cred_username').on(t.username),
// Index for fast lookup by user_id (e.g., on middleware / self-change-password)
index('idx_local_credentials_user_id').on(t.userId),
],
);
/**
* Items within a list.
*
+4 -1
View File
@@ -24,7 +24,7 @@
import { afterEach } from 'vitest';
import { db } from '../src/db/client.js';
import { lists, listItems, listShares, pushSubscriptions } from '../src/db/schema.js';
import { lists, listItems, listShares, pushSubscriptions, localCredentials } from '../src/db/schema.js';
/**
* Truncate list and push tables in FK-safe order after each test.
@@ -39,6 +39,9 @@ afterEach(async () => {
await db.delete(listShares);
await db.delete(pushSubscriptions);
await db.delete(lists);
// Phase 19: local_credentials has FK to users (cascade delete via users); truncate here
// so each test starts with a clean credential slate. users intentionally left intact.
await db.delete(localCredentials);
} catch {
// DB may not be available in pure-unit test runs (no DB_HOST configured).
// Swallow the error — pure-logic tests do not need cleanup.
+5
View File
@@ -27,6 +27,9 @@ import { randomBytes, createECDH } from 'node:crypto';
const sessionSecret = randomBytes(32).toString('hex');
const encKey = randomBytes(32).toString('hex');
// Phase 19 (D-05): LOCAL_SESSION_SECRET signs the local-auth JWT session cookie.
// Must be >= 32 chars. 32 random bytes encoded as base64 = 44 chars (safe, distinct from hex keys).
const localSessionSecret = randomBytes(32).toString('base64');
// VAPID key generation (P-256 / prime256v1 — same curve as web-push)
const ecdhCurve = createECDH('prime256v1');
@@ -55,4 +58,6 @@ SESSION_SECRET=${sessionSecret}
APP_PASSWORD_ENCRYPTION_KEY=${encKey}
VAPID_PUBLIC_KEY=${vapid.publicKey}
VAPID_PRIVATE_KEY=${vapid.privateKey}
# Phase 19 (D-05): Signs local-auth JWT session cookies. Required when not using DEV_AUTH_BYPASS.
LOCAL_SESSION_SECRET=${localSessionSecret}
`);