auth: sign-in ways, sign-in mode, and the admin who keeps a password (S1)

Spec 12 S1: migration 0012 (password_hash nullable, user_identities, sessions.method and the
OpenID token columns), auth.mode (only "password" until S2), GET /api/auth/methods, login body
{login, password} (email still accepted), signIn on users and /api/auth/me, a Signs in with
column on Users. The last active admin with a password can't be deleted, disabled or demoted.
This commit is contained in:
iamdoubz committed 2026-10-25 13:30:58 -05:00
1 parent ba293f8703
commit 3a9423af1f
19 files changed
+4847 -43

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
CREATE TYPE "public"."identity_kind" AS ENUM('oidc', 'ldap');--> statement-breakpoint
CREATE TABLE "user_identities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"kind" "identity_kind" NOT NULL,
"subject" text NOT NULL,
"email" text,
"groups" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_used_at" timestamp with time zone,
CONSTRAINT "user_identities_kind_subject_unique" UNIQUE("kind","subject"),
CONSTRAINT "user_identities_userId_kind_unique" UNIQUE("user_id","kind")
);
--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "method" text DEFAULT 'password' NOT NULL;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idp_refresh_enc" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idp_id_token_enc" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idp_checked_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_method_check" CHECK ("sessions"."method" in ('password', 'ldap', 'oidc'));
File diff suppressed because it is too large. Load diff
@@ -85,6 +85,13 @@
"when": 1792788393381,
"tag": "0011_contrack_call_log",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1792951905226,
"tag": "0012_sso",
"breakpoints": true
}
]
}
+35 -2
View File
@@ -19,6 +19,7 @@ import {
smallint,
text,
timestamp,
unique,
uuid,
} from 'drizzle-orm/pg-core';
@@ -53,7 +54,8 @@ export const users = pgTable('users', {
id: id(),
email: citext().notNull().unique(),
displayName: text().notNull(),
passwordHash: text().notNull(),
/** argon2id; null: no password, signs in with OpenID only (12 §3.3). */
passwordHash: text(),
role: userRole().notNull().default('user'),
isActive: boolean().notNull().default(true),
totpSecretEnc: text(),
@@ -83,9 +85,40 @@ export const sessions = pgTable(
expiresAt: tstz().notNull(),
ip: inet(),
userAgent: text(),
/** How it was made (12 §2): password, ldap or oidc. */
method: text().notNull().default('password'),
/** OpenID sessions (12 §3.5): refresh token for the leaver check, ID token for signing out. */
idpRefreshEnc: text(),
idpIdTokenEnc: text(),
idpCheckedAt: tstz(),
...timestamps,
},
(t) => [index().on(t.userId)],
(t) => [
index().on(t.userId),
check('sessions_method_check', sql`${t.method} in ('password', 'ldap', 'oidc')`),
],
);
// One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
export const identityKind = pgEnum('identity_kind', ['oidc', 'ldap']);
export const userIdentities = pgTable(
'user_identities',
{
id: id(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
kind: identityKind().notNull(),
/** oidc: "<iss> <sub>"; ldap: the entry's UUID attribute. */
subject: text().notNull(),
/** As the provider last said. */
email: text(),
/** oidc: groupsClaim values at the last sign-in (12 §3.4). */
groups: emptyTextArray(),
createdAt: createdAt(),
lastUsedAt: tstz(),
},
(t) => [unique().on(t.kind, t.subject), unique().on(t.userId, t.kind)],
);
export const apiTokens = pgTable('api_tokens', {