feat(auth): add per-instructor accounts and local password login

Replaces the single shared ADMIN_PASSWORD (Phase 2B) with real accounts: instructors table
(email/display_name/role/status), scrypt password hashing with a fresh random salt PER
INSTRUCTOR (Phase 2B used one fixed salt because there was exactly one shared password —
that assumption is gone), auth_identities table so a future OIDC login can map to the same
instructor_id without a schema change.

New POST /api/auth/instructor/login, POST /api/auth/logout, GET /api/auth/session,
POST /api/auth/change-password (old password required, revokes other sessions). No account
enumeration: identical response whether the email doesn't exist or the password is wrong.

bootstrap-owner.js is the only supported way to create the first account — no manual
database editing to stand up a fresh deployment. Refuses to run if an active owner already
exists unless the target email is an existing instructor (zero-owners recovery path).
This commit is contained in:
2026-08-18 13:35:54 -05:00
parent 52c17c1907
commit 85fa920f96
8 changed files with 323 additions and 30 deletions
@@ -0,0 +1,18 @@
-- auth_identities — lets a local-password identity and a future OIDC identity map to the same
-- instructor record without a schema change when OIDC lands. Every instructor gets a 'local'
-- row today (provider_subject = their own instructor_id as text); a Google/OIDC login later
-- just adds a second row for the same instructor_id. See
-- docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for the adapter interface this backs.
CREATE TABLE IF NOT EXISTS auth_identities (
auth_identity_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instructor_id UUID NOT NULL REFERENCES instructors(instructor_id) ON DELETE CASCADE,
provider TEXT NOT NULL, -- 'local' | 'google' | 'oidc:<name>'
provider_subject TEXT NOT NULL,
provider_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ,
UNIQUE (provider, provider_subject)
);
CREATE INDEX IF NOT EXISTS idx_auth_identities_instructor ON auth_identities(instructor_id);