commit b3892e25d78216389b6b06933789f4c34b21dad7
parent 1fb2e00ec0d6a24763d828d7465876c3d03a5603
Author: finwo <finwo@pm.me>
Date: Tue, 20 Oct 2026 20:02:53 +0200
Basic global-user setup, becoming an auth entity instead of keycloak competitor
Diffstat:
26 files changed, 1977 insertions(+), 177 deletions(-)
diff --git a/server/migrations/sqlite/realm/001_init.sql b/server/migrations/sqlite/realm/001_init.sql
@@ -5,35 +5,18 @@ CREATE TABLE realm_settings (
kind TEXT NOT NULL DEFAULT 'realm' CHECK (kind IN ('realm', 'system')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
branding TEXT NOT NULL DEFAULT '{}',
+ allow_session_duration_choice INTEGER NOT NULL DEFAULT 1 CHECK (allow_session_duration_choice IN (0, 1)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-CREATE TABLE users (
- id TEXT PRIMARY KEY,
- username TEXT NOT NULL,
- username_normalized TEXT NOT NULL UNIQUE,
- email TEXT,
- email_normalized TEXT UNIQUE,
- display_name TEXT,
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
- profile TEXT NOT NULL DEFAULT '{}',
+CREATE TABLE realm_memberships (
+ user_id TEXT PRIMARY KEY,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'invited')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-CREATE TABLE credentials (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- type TEXT NOT NULL,
- secret_hash TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- UNIQUE (user_id, type)
-);
-
-CREATE INDEX credentials_user ON credentials(user_id);
-
CREATE TABLE roles (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
@@ -41,13 +24,13 @@ CREATE TABLE roles (
created_at INTEGER NOT NULL
);
-CREATE TABLE user_roles (
- user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+CREATE TABLE membership_roles (
+ user_id TEXT NOT NULL,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-CREATE INDEX user_roles_role ON user_roles(role_id);
+CREATE INDEX membership_roles_role ON membership_roles(role_id);
CREATE TABLE identity_providers (
id TEXT PRIMARY KEY,
@@ -62,13 +45,13 @@ CREATE TABLE identity_providers (
updated_at INTEGER NOT NULL
);
-CREATE TABLE email_domains (
+CREATE TABLE email_domain_providers (
domain TEXT PRIMARY KEY,
provider_id TEXT NOT NULL REFERENCES identity_providers(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL
);
-CREATE INDEX email_domains_provider ON email_domains(provider_id);
+CREATE INDEX email_domain_providers_provider ON email_domain_providers(provider_id);
CREATE TABLE oidc_clients (
id TEXT PRIMARY KEY,
@@ -76,7 +59,28 @@ CREATE TABLE oidc_clients (
name TEXT NOT NULL,
redirect_uris TEXT NOT NULL DEFAULT '[]',
scopes TEXT NOT NULL DEFAULT 'openid profile email',
+ access_mode TEXT NOT NULL DEFAULT 'members' CHECK (access_mode IN ('internal', 'members', 'public')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
+
+CREATE TABLE oidc_client_roles (
+ client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
+ role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
+ PRIMARY KEY (client_id, role_id)
+);
+
+CREATE TABLE sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ ttl INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ last_seen_at INTEGER NOT NULL,
+ user_agent TEXT,
+ ip TEXT
+);
+
+CREATE INDEX sessions_user ON sessions(user_id);
+CREATE INDEX sessions_expires ON sessions(expires_at);
diff --git a/server/migrations/sqlite/system/001_init.sql b/server/migrations/sqlite/system/001_init.sql
@@ -2,13 +2,65 @@ CREATE TABLE realms (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
db_file TEXT,
+ owner_user_id TEXT,
created_at INTEGER NOT NULL
);
-CREATE TABLE realm_domains (
+CREATE TABLE realm_hosts (
host TEXT PRIMARY KEY,
realm_id TEXT NOT NULL REFERENCES realms(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL
);
-CREATE INDEX realm_domains_realm ON realm_domains(realm_id);
+CREATE INDEX realm_hosts_realm ON realm_hosts(realm_id);
+
+CREATE TABLE realm_email_domains (
+ domain TEXT PRIMARY KEY,
+ realm_id TEXT NOT NULL REFERENCES realms(id) ON DELETE CASCADE,
+ verified INTEGER NOT NULL DEFAULT 0 CHECK (verified IN (0, 1)),
+ verification_token TEXT,
+ verified_at INTEGER,
+ created_at INTEGER NOT NULL
+);
+
+CREATE INDEX realm_email_domains_realm ON realm_email_domains(realm_id);
+
+CREATE TABLE users (
+ id TEXT PRIMARY KEY,
+ username TEXT NOT NULL,
+ username_normalized TEXT NOT NULL UNIQUE,
+ display_name TEXT,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
+ home_realm_id TEXT REFERENCES realms(id),
+ profile TEXT NOT NULL DEFAULT '{}',
+ last_login_at INTEGER,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE INDEX users_home_realm ON users(home_realm_id);
+
+CREATE TABLE user_emails (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ email_normalized TEXT NOT NULL UNIQUE,
+ verified INTEGER NOT NULL DEFAULT 0 CHECK (verified IN (0, 1)),
+ is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1)),
+ created_at INTEGER NOT NULL,
+ verified_at INTEGER
+);
+
+CREATE INDEX user_emails_user ON user_emails(user_id);
+
+CREATE TABLE credentials (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ type TEXT NOT NULL,
+ secret_hash TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE (user_id, type)
+);
+
+CREATE INDEX credentials_user ON credentials(user_id);
diff --git a/server/package-lock.json b/server/package-lock.json
@@ -7,6 +7,7 @@
"dependencies": {
"@finwo/scandir": "^0.0.3",
"body-parser": "^2.3.0",
+ "cookie": "^2.0.1",
"cors": "^2.8.6",
"mithril": "^2.3.8",
"morgan": "^1.12.1",
@@ -651,6 +652,19 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/cookie": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
+ "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/copy-file": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/copy-file/-/copy-file-11.1.0.tgz",
diff --git a/server/package.json b/server/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@finwo/scandir": "^0.0.3",
"body-parser": "^2.3.0",
+ "cookie": "^2.0.1",
"cors": "^2.8.6",
"mithril": "^2.3.8",
"morgan": "^1.12.1",
diff --git a/server/scripts/seed-demo.js b/server/scripts/seed-demo.js
@@ -1,6 +1,8 @@
import crypto from 'node:crypto';
import { loadConfig } from '../src/lib/config.js';
+import { hashPassword } from '../src/lib/password.js';
import {
+ addRealmEmailDomain,
closeRealms,
createRealm,
findRealmBySlug,
@@ -8,52 +10,67 @@ import {
initRealms,
openRealmDatabase,
} from '../src/lib/realm.js';
+import {
+ addUserEmail,
+ assignHomeRealmForEmail,
+ assignRole,
+ createUser,
+ ensureMembership,
+ findUserByEmail,
+ findUserByUsername,
+ setPasswordCredential,
+} from '../src/lib/users.js';
+
+const DEMO_PASSWORD = 'demo';
-function normalize(input) {
- return String(input || '').trim().toLowerCase();
+async function ensureRealm({ slug, displayName, hosts, branding, allowSessionDurationChoice = true }) {
+ let realm = await findRealmBySlug(slug);
+ if (!realm) {
+ realm = await createRealm({
+ slug,
+ displayName,
+ hosts,
+ branding,
+ allowSessionDurationChoice,
+ });
+ }
+ return realm;
}
-async function ensureUser(db, { username, email, displayName }) {
- const normalizedEmail = email ? normalize(email) : null;
- const existing = await db.get(
- 'SELECT id FROM users WHERE username_normalized = {username}',
- { username: normalize(username) }
+async function ensureRealmDomain(realmId, domain) {
+ const systemDb = getSystemDatabase();
+ const existing = await systemDb.get(
+ 'SELECT realm_id FROM realm_email_domains WHERE domain = {domain}',
+ { domain }
);
- if (existing) return existing.id;
+ if (existing) return;
+ await addRealmEmailDomain(realmId, domain, { verified: true });
+}
- const now = Date.now();
- const id = crypto.randomUUID();
- await db.run(
- `INSERT INTO users
- (id, username, username_normalized, email, email_normalized, display_name, status, profile, created_at, updated_at)
- VALUES
- ({id}, {username}, {usernameNormalized}, {email}, {emailNormalized}, {displayName}, 'active', '{}', {createdAt}, {updatedAt})`,
- {
- id,
- username,
- usernameNormalized: normalize(username),
- email,
- emailNormalized: normalizedEmail,
- displayName: displayName || username,
- createdAt: now,
- updatedAt: now,
- }
- );
- return id;
+async function ensureRealmUser(realmSlug, userId, roles = []) {
+ const realm = await findRealmBySlug(realmSlug);
+ if (!realm) throw new Error(`unknown realm: ${realmSlug}`);
+ const db = await openRealmDatabase(realm);
+ await ensureMembership(db, userId, 'active');
+ for (const role of roles) {
+ await assignRole(db, userId, role);
+ }
}
-async function grantRole(db, userId, slug) {
- const role = await db.get('SELECT id FROM roles WHERE slug = {slug}', { slug });
- if (!role) throw new Error(`missing role: ${slug}`);
- const existing = await db.get(
- 'SELECT user_id FROM user_roles WHERE user_id = {userId} AND role_id = {roleId}',
- { userId, roleId: role.id }
- );
- if (existing) return;
- await db.run(
- 'INSERT INTO user_roles (user_id, role_id) VALUES ({userId}, {roleId})',
- { userId, roleId: role.id }
- );
+async function ensureUser({ username, email, displayName, password = DEMO_PASSWORD }) {
+ const systemDb = getSystemDatabase();
+ const existing =
+ (await findUserByEmail(systemDb, email)) || (await findUserByUsername(systemDb, username));
+ if (existing) {
+ await setPasswordCredential(systemDb, existing.id, await hashPassword(password));
+ return existing.id;
+ }
+
+ const id = await createUser(systemDb, { username, displayName });
+ await addUserEmail(systemDb, id, email, { verified: true, primary: true });
+ await assignHomeRealmForEmail(systemDb, id, email);
+ await setPasswordCredential(systemDb, id, await hashPassword(password));
+ return id;
}
async function ensureProvider(db, { slug, displayName, issuer, clientId, clientSecret }) {
@@ -80,43 +97,109 @@ async function ensureProvider(db, { slug, displayName, issuer, clientId, clientS
return id;
}
-async function ensureDomain(db, domain, providerId) {
- const existing = await db.get('SELECT domain FROM email_domains WHERE domain = {domain}', {
- domain: normalize(domain),
+async function ensureProviderDomain(db, domain, providerId) {
+ const existing = await db.get('SELECT domain FROM email_domain_providers WHERE domain = {domain}', {
+ domain,
});
if (existing) return;
await db.run(
- 'INSERT INTO email_domains (domain, provider_id, created_at) VALUES ({domain}, {providerId}, {createdAt})',
- { domain: normalize(domain), providerId, createdAt: Date.now() }
+ 'INSERT INTO email_domain_providers (domain, provider_id, created_at) VALUES ({domain}, {providerId}, {createdAt})',
+ { domain, providerId, createdAt: Date.now() }
);
}
+async function ensureClient(db, { clientId, name, accessMode, roles = [] }) {
+ let client = await db.get('SELECT id FROM oidc_clients WHERE client_id = {clientId}', { clientId });
+ if (!client) {
+ const now = Date.now();
+ client = { id: crypto.randomUUID() };
+ await db.run(
+ `INSERT INTO oidc_clients
+ (id, client_id, name, redirect_uris, scopes, access_mode, enabled, created_at, updated_at)
+ VALUES
+ ({id}, {clientId}, {name}, '[]', 'openid profile email', {accessMode}, 1, {createdAt}, {updatedAt})`,
+ {
+ id: client.id,
+ clientId,
+ name,
+ accessMode,
+ createdAt: now,
+ updatedAt: now,
+ }
+ );
+ }
+
+ for (const slug of roles) {
+ const role = await db.get('SELECT id FROM roles WHERE slug = {slug}', { slug });
+ if (!role) continue;
+ const existing = await db.get(
+ 'SELECT client_id FROM oidc_client_roles WHERE client_id = {clientId} AND role_id = {roleId}',
+ { clientId: client.id, roleId: role.id }
+ );
+ if (existing) continue;
+ await db.run(
+ 'INSERT INTO oidc_client_roles (client_id, role_id) VALUES ({clientId}, {roleId})',
+ { clientId: client.id, roleId: role.id }
+ );
+ }
+}
+
const cfg = loadConfig();
await initRealms(cfg);
-let acme = await findRealmBySlug('acme');
-if (!acme) {
- acme = await createRealm({
- slug: 'acme',
- displayName: 'Acme',
- branding: { primary_color: '#25A' },
- hosts: ['acme.localhost'],
- });
-}
+const acme = await ensureRealm({
+ slug: 'acme',
+ displayName: 'Acme',
+ hosts: ['acme.localhost'],
+ branding: { primary_color: '#25A' },
+});
+const globex = await ensureRealm({
+ slug: 'globex',
+ displayName: 'Globex',
+ hosts: ['globex.localhost'],
+ branding: { primary_color: '#25A' },
+});
+const system = await findRealmBySlug('system');
-const acmeDb = await openRealmDatabase(acme);
-const alice = await ensureUser(acmeDb, {
+await ensureRealmDomain(acme.id, 'acme.test');
+await ensureRealmDomain(acme.id, 'sso.acme.test');
+await ensureRealmDomain(globex.id, 'globex.test');
+await ensureRealmDomain(system.id, 'system.test');
+
+const alice = await ensureUser({
username: 'alice',
- email: 'alice@local.test',
+ email: 'alice@acme.test',
displayName: 'Alice',
});
-const acmeAdmin = await ensureUser(acmeDb, {
+const acmeAdmin = await ensureUser({
username: 'acme-it',
- email: 'it@local.test',
+ email: 'it@acme.test',
displayName: 'Acme IT',
});
-await grantRole(acmeDb, acmeAdmin, 'realm-admin');
+const guest = await ensureUser({
+ username: 'guest',
+ email: 'guest@personal.test',
+ displayName: 'Guest User',
+});
+const contractor = await ensureUser({
+ username: 'contractor',
+ email: 'contractor@personal.test',
+ displayName: 'Contractor',
+});
+const systemAdmin = await ensureUser({
+ username: 'admin',
+ email: 'admin@system.test',
+ displayName: 'System Admin',
+});
+
+await ensureRealmUser('acme', alice);
+await ensureRealmUser('globex', alice);
+await ensureRealmUser('acme', acmeAdmin, ['realm-admin']);
+await ensureRealmUser('acme', guest);
+await ensureRealmUser('globex', contractor, ['realm-admin']);
+await ensureRealmUser('system', systemAdmin, ['system-admin']);
+const acmeDb = await openRealmDatabase(acme);
const providerId = await ensureProvider(acmeDb, {
slug: 'example-sso',
displayName: 'Example SSO',
@@ -124,21 +207,17 @@ const providerId = await ensureProvider(acmeDb, {
clientId: 'demo',
clientSecret: 'demo-secret',
});
-await ensureDomain(acmeDb, 'example.com', providerId);
+await ensureProviderDomain(acmeDb, 'sso.acme.test', providerId);
-const systemDb = getSystemDatabase();
-const systemAdmin = await ensureUser(systemDb, {
- username: 'admin',
- email: 'admin@system.test',
- displayName: 'System Admin',
-});
-await grantRole(systemDb, systemAdmin, 'system-admin');
-
-console.log('seeded realm acme (host acme.localhost)');
-console.log('local user: alice@local.test');
-console.log('upstream mapping: example.com -> Example SSO');
-console.log('realm admin: it@local.test');
-console.log('system admin: admin@system.test');
-console.log(`alice id: ${alice}`);
+await ensureClient(acmeDb, { clientId: 'internal-app', name: 'Internal App', accessMode: 'internal' });
+await ensureClient(acmeDb, { clientId: 'members-app', name: 'Members App', accessMode: 'members' });
+await ensureClient(acmeDb, { clientId: 'public-app', name: 'Public App', accessMode: 'public' });
+
+console.log('seeded realms acme, globex and system');
+console.log('password for all demo users: demo');
+console.log('home users: alice@acme.test, it@acme.test, admin@system.test');
+console.log('guest users: guest@personal.test (acme), contractor@personal.test (globex realm-admin)');
+console.log('upstream mapping: sso.acme.test -> Example SSO');
+console.log('clients: admin-ui, internal-app, members-app, public-app');
await closeRealms();
diff --git a/server/src/controller/login/logout.js b/server/src/controller/login/logout.js
@@ -0,0 +1,23 @@
+import { app } from '../../app.js';
+import { openRealmDatabase, resolveRealm } from '../../lib/realm.js';
+import { clearSessionCookie, destroySession, requestToken } from '../../lib/session.js';
+
+export default {
+ method: 'post',
+ path: '/api/v1/logout',
+ name: 'login.logout',
+ async handler(req, res) {
+ const body = req.body || {};
+ const realm = await resolveRealm({
+ host: req.headers.host,
+ slug: body.realm || undefined,
+ });
+ const realmDb = await openRealmDatabase(realm);
+ const token = requestToken(req, realm);
+
+ await destroySession(realmDb, token);
+ clearSessionCookie(res, req, realm);
+
+ return new app.HttpOk({ ok: true });
+ },
+};
diff --git a/server/src/controller/login/password.js b/server/src/controller/login/password.js
@@ -1,10 +1,112 @@
+import { app } from '../../app.js';
import { HttpError } from '../../lib/http/http-error.js';
+import { findManagedProviderForUser, resolveLogin } from '../../lib/login.js';
+import { verifyDummyPassword, verifyPassword } from '../../lib/password.js';
+import { getSystemDatabase, openRealmDatabase, realmMetadata, resolveRealm } from '../../lib/realm.js';
+import {
+ LONG_SESSION_TTL,
+ SHORT_SESSION_TTL,
+ createSession,
+ purgeExpiredSessions,
+ requestIp,
+ setSessionCookie,
+} from '../../lib/session.js';
+import {
+ findUserByIdentifier,
+ getMembership,
+ getPasswordCredential,
+ updateLastLogin,
+} from '../../lib/users.js';
export default {
method: 'post',
path: '/api/v1/login/password',
name: 'login.password',
- async handler() {
- throw new HttpError(501, 'not_implemented', 'Password sign-in is not implemented');
+ async handler(req, res) {
+ const body = req.body || {};
+ if (typeof body.identifier !== 'string') {
+ throw new HttpError(400, 'invalid_request', 'identifier is required');
+ }
+ if (typeof body.password !== 'string') {
+ throw new HttpError(400, 'invalid_request', 'password is required');
+ }
+ if (body.password.length > 1024) {
+ throw new HttpError(400, 'invalid_request', 'password is too long');
+ }
+ if (body.realm != null && typeof body.realm !== 'string') {
+ throw new HttpError(400, 'invalid_request', 'realm must be a string');
+ }
+ if (body.long_session != null && typeof body.long_session !== 'boolean') {
+ throw new HttpError(400, 'invalid_request', 'long_session must be a boolean');
+ }
+
+ const realm = await resolveRealm({
+ host: req.headers.host,
+ slug: body.realm || undefined,
+ });
+ const metadata = await realmMetadata(realm);
+ const realmDb = await openRealmDatabase(realm);
+ const systemDb = getSystemDatabase();
+
+ if (body.identifier.includes('@')) {
+ const resolution = await resolveLogin({ identifier: body.identifier });
+ if (resolution.next !== 'password') {
+ throw new HttpError(403, 'provider_required', 'This account must sign in with its identity provider');
+ }
+ }
+
+ const user = await findUserByIdentifier(systemDb, body.identifier);
+ const credential = user ? await getPasswordCredential(systemDb, user.id) : null;
+ const membership = user ? await getMembership(realmDb, user.id) : null;
+
+ const canCheckPassword =
+ user &&
+ user.status === 'active' &&
+ credential &&
+ membership &&
+ membership.status === 'active';
+
+ if (!canCheckPassword) {
+ await verifyDummyPassword(body.password);
+ throw new HttpError(401, 'invalid_credentials', 'Invalid credentials');
+ }
+
+ const valid = await verifyPassword(body.password, credential.secret_hash);
+ if (!valid) {
+ throw new HttpError(401, 'invalid_credentials', 'Invalid credentials');
+ }
+
+ if (!body.identifier.includes('@')) {
+ const provider = await findManagedProviderForUser(systemDb, user);
+ if (provider && !provider.enabled) {
+ throw new HttpError(409, 'provider_disabled', 'The identity provider for this account is unavailable');
+ }
+ if (provider) {
+ throw new HttpError(403, 'provider_required', 'This account must sign in with its identity provider');
+ }
+ }
+
+ const longSession = body.long_session === true && metadata.allow_session_duration_choice;
+ const ttl = longSession ? LONG_SESSION_TTL : SHORT_SESSION_TTL;
+
+ await purgeExpiredSessions(realmDb);
+ const { token, session } = await createSession(realmDb, {
+ userId: user.id,
+ ttl,
+ userAgent: req.headers['user-agent'] || null,
+ ip: requestIp(req),
+ });
+ setSessionCookie(res, req, realm, token, ttl);
+ await updateLastLogin(systemDb, user.id);
+
+ return new app.HttpOk({
+ realm: metadata,
+ user: {
+ id: user.id,
+ username: user.username,
+ display_name: user.display_name,
+ },
+ expires_at: session.expires_at,
+ });
},
};
diff --git a/server/src/controller/login/resolve.js b/server/src/controller/login/resolve.js
@@ -1,7 +1,7 @@
import { app } from '../../app.js';
import { resolveLogin } from '../../lib/login.js';
import { HttpError } from '../../lib/http/http-error.js';
-import { openRealmDatabase, realmMetadata, resolveRealm } from '../../lib/realm.js';
+import { realmMetadata, resolveRealm } from '../../lib/realm.js';
export default {
method: 'post',
@@ -21,8 +21,7 @@ export default {
slug: body.realm || undefined,
});
const metadata = await realmMetadata(realm);
- const db = await openRealmDatabase(realm);
- const result = await resolveLogin(db, body.identifier);
+ const result = await resolveLogin({ identifier: body.identifier });
return new app.HttpOk({
realm: metadata,
diff --git a/server/src/controller/session/show.js b/server/src/controller/session/show.js
@@ -0,0 +1,41 @@
+import { app } from '../../app.js';
+import { HttpError } from '../../lib/http/http-error.js';
+import { getSystemDatabase, openRealmDatabase, realmMetadata, resolveRealm } from '../../lib/realm.js';
+import { authenticateRequest } from '../../lib/session.js';
+
+export default {
+ method: 'get',
+ path: '/api/v1/session',
+ name: 'session.show',
+ async handler(req, res) {
+ const url = new URL(req.url, 'http://localhost');
+ const realm = await resolveRealm({
+ host: req.headers.host,
+ slug: url.searchParams.get('realm') || undefined,
+ });
+ const metadata = await realmMetadata(realm);
+ const realmDb = await openRealmDatabase(realm);
+ const auth = await authenticateRequest({
+ systemDb: getSystemDatabase(),
+ realmDb,
+ req,
+ res,
+ realm,
+ });
+
+ if (!auth) {
+ throw new HttpError(401, 'not_authenticated', 'Not signed in');
+ }
+
+ return new app.HttpOk({
+ realm: metadata,
+ user: {
+ id: auth.user.id,
+ username: auth.user.username,
+ display_name: auth.user.display_name,
+ },
+ roles: auth.roles,
+ expires_at: auth.session.expires_at,
+ });
+ },
+};
diff --git a/server/src/lib/db/migrate.js b/server/src/lib/db/migrate.js
@@ -141,7 +141,8 @@ function checksum(sql) {
export async function runMigrations(db, options = {}) {
const dir = options.dir || path.join(MIGRATIONS_ROOT, db.dialect);
const namespace = options.namespace || path.basename(dir);
- const log = options.logger || ((m) => console.log(`[migrate] ${m}`));
+ const label = options.label ? `${options.label}: ` : '';
+ const log = options.logger || ((m) => console.log(`[migrate] ${label}${m}`));
await db.exec(DDL[db.dialect]);
@@ -187,13 +188,13 @@ export async function runMigrations(db, options = {}) {
}
if (pending.length === 0) {
- log(`up to date (${files.length} applied)`);
+ log(`up to date (${files.length} applied in ${namespace})`);
return { applied: [], total: files.length };
}
const done = [];
for (const { file, sql, sum } of pending) {
- log(`applying ${file}`);
+ log(`applying ${namespace}/${file}`);
const statements = splitStatements(sql, db.dialect);
if (statements.length === 0) throw new Error(`migration ${file} contains no statements`);
await db.transaction(async (tx) => {
@@ -203,7 +204,7 @@ export async function runMigrations(db, options = {}) {
{ file: `${namespace}/${file}`, sum, at: Date.now() }
);
});
- done.push(file);
+ done.push(`${namespace}/${file}`);
}
log(`applied ${done.length} migration(s)`);
diff --git a/server/src/lib/login.js b/server/src/lib/login.js
@@ -1,4 +1,6 @@
import { HttpError } from './http/http-error.js';
+import { findRealmByEmailDomain, openRealmDatabase } from './realm.js';
+import { getUserEmails, emailDomain } from './users.js';
export function normalizeIdentifier(identifier) {
const value = String(identifier || '').trim();
@@ -21,21 +23,30 @@ export function normalizeIdentifier(identifier) {
};
}
-export async function resolveLogin(db, identifier) {
- const parsed = normalizeIdentifier(identifier);
- if (!parsed.isEmail) {
- return { next: 'password' };
- }
-
- const provider = await db.get(
+export async function getProviderForDomain(realm, domain) {
+ const db = await openRealmDatabase(realm);
+ return db.get(
`SELECT p.slug, p.display_name, p.enabled
- FROM email_domains d
+ FROM email_domain_providers d
JOIN identity_providers p ON p.id = d.provider_id
WHERE d.domain = {domain}
LIMIT 1`,
- { domain: parsed.domain }
+ { domain }
);
+}
+export async function resolveLogin({ identifier }) {
+ const parsed = normalizeIdentifier(identifier);
+ if (!parsed.isEmail) {
+ return { next: 'password' };
+ }
+
+ const owner = await findRealmByEmailDomain(parsed.domain);
+ if (!owner) {
+ return { next: 'password' };
+ }
+
+ const provider = await getProviderForDomain(owner, parsed.domain);
if (!provider) {
return { next: 'password' };
}
@@ -52,3 +63,33 @@ export async function resolveLogin(db, identifier) {
},
};
}
+
+export async function findManagedProviderForUser(systemDb, user) {
+ if (!user || !user.home_realm_id) return null;
+
+ const homeRealm = await systemDb.get(
+ 'SELECT id, slug, db_file, owner_user_id FROM realms WHERE id = {id}',
+ { id: user.home_realm_id }
+ );
+ if (!homeRealm) return null;
+
+ const emails = await getUserEmails(systemDb, user.id);
+ for (const entry of emails) {
+ if (!entry.verified) continue;
+ const domain = emailDomain(entry.email);
+ if (!domain) continue;
+
+ const owned = await systemDb.get(
+ `SELECT domain
+ FROM realm_email_domains
+ WHERE domain = {domain} AND realm_id = {realmId} AND verified = 1`,
+ { domain, realmId: homeRealm.id }
+ );
+ if (!owned) continue;
+
+ const provider = await getProviderForDomain(homeRealm, domain);
+ if (provider) return provider;
+ }
+
+ return null;
+}
diff --git a/server/src/lib/password.js b/server/src/lib/password.js
@@ -0,0 +1,97 @@
+import crypto from 'node:crypto';
+
+const SCRYPT_N = 32768;
+const SCRYPT_R = 8;
+const SCRYPT_P = 1;
+const KEY_LENGTH = 64;
+const SALT_LENGTH = 16;
+const MAX_MEMORY = 64 * 1024 * 1024;
+const MAX_PASSWORD_LENGTH = 1024;
+
+function derive(password, salt, params) {
+ return new Promise((resolve, reject) => {
+ crypto.scrypt(
+ password,
+ salt,
+ KEY_LENGTH,
+ {
+ N: params.N,
+ r: params.r,
+ p: params.p,
+ maxmem: MAX_MEMORY,
+ },
+ (error, key) => {
+ if (error) reject(error);
+ else resolve(key);
+ }
+ );
+ });
+}
+
+export async function hashPassword(password) {
+ if (typeof password !== 'string') throw new Error('password must be a string');
+ if (password.length > MAX_PASSWORD_LENGTH) throw new Error('password is too long');
+
+ const salt = crypto.randomBytes(SALT_LENGTH);
+ const key = await derive(password, salt, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
+ return [
+ 'scrypt',
+ SCRYPT_N,
+ SCRYPT_R,
+ SCRYPT_P,
+ salt.toString('base64url'),
+ key.toString('base64url'),
+ ].join('$');
+}
+
+export async function verifyPassword(password, encoded) {
+ if (typeof password !== 'string' || typeof encoded !== 'string') return false;
+ if (password.length > MAX_PASSWORD_LENGTH) return false;
+
+ const parts = encoded.split('$');
+ if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
+
+ const params = {
+ N: Number(parts[1]),
+ r: Number(parts[2]),
+ p: Number(parts[3]),
+ };
+ if (
+ !Number.isInteger(params.N) ||
+ !Number.isInteger(params.r) ||
+ !Number.isInteger(params.p) ||
+ params.N <= 1 ||
+ params.r <= 0 ||
+ params.p <= 0
+ ) {
+ return false;
+ }
+
+ let salt;
+ let expected;
+ try {
+ salt = Buffer.from(parts[4], 'base64url');
+ expected = Buffer.from(parts[5], 'base64url');
+ } catch {
+ return false;
+ }
+ if (salt.length === 0 || expected.length !== KEY_LENGTH) return false;
+
+ let actual;
+ try {
+ actual = await derive(password, salt, params);
+ } catch {
+ return false;
+ }
+
+ return crypto.timingSafeEqual(actual, expected);
+}
+
+let dummyHash = null;
+
+export async function verifyDummyPassword(password) {
+ if (!dummyHash) {
+ dummyHash = hashPassword(crypto.randomBytes(24).toString('base64url'));
+ }
+ return verifyPassword(password, await dummyHash);
+}
diff --git a/server/src/lib/policy.js b/server/src/lib/policy.js
@@ -0,0 +1,37 @@
+export function isRealmAdmin(roles) {
+ return Array.isArray(roles) && roles.includes('realm-admin');
+}
+
+export function isSystemAdmin(roles) {
+ return Array.isArray(roles) && roles.includes('system-admin');
+}
+
+export function canUseClient({ user, membership, roles, client, realm }) {
+ if (!user || user.status !== 'active') return false;
+ if (!client || !client.enabled) return false;
+
+ const requiredRoles = Array.isArray(client.required_roles) ? client.required_roles : [];
+ const roleOk = requiredRoles.length === 0 || requiredRoles.some((role) => roles.includes(role));
+
+ if (client.access_mode === 'public') return true;
+
+ if (!membership || membership.status !== 'active') return false;
+
+ if (client.access_mode === 'internal') {
+ return user.home_realm_id === realm.id && roleOk;
+ }
+
+ if (client.access_mode === 'members') {
+ return roleOk;
+ }
+
+ return false;
+}
+
+export function canManageUser({ actorRoles, actorIsSystemAdmin, target, realmId }) {
+ if (actorIsSystemAdmin) return 'full';
+ if (!isRealmAdmin(actorRoles)) return null;
+ if (!target) return null;
+ if (target.home_realm_id === realmId) return 'full';
+ return 'membership';
+}
diff --git a/server/src/lib/realm.js b/server/src/lib/realm.js
@@ -14,18 +14,20 @@ function migrationDir(dbDialect, type) {
return path.join(MIGRATIONS_ROOT, dbDialect, type);
}
-async function migrateRealm(db) {
+async function migrateRealm(db, label) {
return runMigrations(db, {
dir: migrationDir(db.dialect, 'realm'),
namespace: 'realm',
+ label,
});
}
async function migrateSystem(db) {
- await migrateRealm(db);
+ await migrateRealm(db, 'system');
return runMigrations(db, {
dir: migrationDir(db.dialect, 'system'),
namespace: 'system',
+ label: 'system',
});
}
@@ -40,35 +42,56 @@ function parseBranding(raw) {
async function ensureRole(db, slug, name, now) {
const existing = await db.get('SELECT id FROM roles WHERE slug = {slug}', { slug });
- if (existing) return;
+ if (existing) return existing.id;
+ const id = crypto.randomUUID();
await db.run(
'INSERT INTO roles (id, slug, name, created_at) VALUES ({id}, {slug}, {name}, {createdAt})',
- { id: crypto.randomUUID(), slug, name, createdAt: now }
+ { id, slug, name, createdAt: now }
);
+ return id;
}
-async function ensureAdminClient(db, now) {
- const existing = await db.get('SELECT id FROM oidc_clients WHERE client_id = {clientId}', {
- clientId: 'admin-ui',
- });
+async function ensureClientRole(db, clientId, roleSlug) {
+ const role = await db.get('SELECT id FROM roles WHERE slug = {slug}', { slug: roleSlug });
+ if (!role) return;
+ const existing = await db.get(
+ 'SELECT client_id FROM oidc_client_roles WHERE client_id = {clientId} AND role_id = {roleId}',
+ { clientId, roleId: role.id }
+ );
if (existing) return;
await db.run(
- `INSERT INTO oidc_clients
- (id, client_id, name, redirect_uris, scopes, enabled, created_at, updated_at)
- VALUES
- ({id}, {clientId}, {name}, {redirectUris}, {scopes}, 1, {createdAt}, {updatedAt})`,
- {
- id: crypto.randomUUID(),
- clientId: 'admin-ui',
- name: 'Admin UI',
- redirectUris: '[]',
- scopes: 'openid profile email',
- createdAt: now,
- updatedAt: now,
- }
+ 'INSERT INTO oidc_client_roles (client_id, role_id) VALUES ({clientId}, {roleId})',
+ { clientId, roleId: role.id }
);
}
+async function ensureAdminClient(db, now) {
+ let client = await db.get('SELECT id FROM oidc_clients WHERE client_id = {clientId}', {
+ clientId: 'admin-ui',
+ });
+ if (!client) {
+ client = { id: crypto.randomUUID() };
+ await db.run(
+ `INSERT INTO oidc_clients
+ (id, client_id, name, redirect_uris, scopes, access_mode, enabled, created_at, updated_at)
+ VALUES
+ ({id}, {clientId}, {name}, {redirectUris}, {scopes}, 'members', 1, {createdAt}, {updatedAt})`,
+ {
+ id: client.id,
+ clientId: 'admin-ui',
+ name: 'Admin UI',
+ redirectUris: '[]',
+ scopes: 'openid profile email',
+ createdAt: now,
+ updatedAt: now,
+ }
+ );
+ }
+
+ await ensureClientRole(db, client.id, 'realm-admin');
+ await ensureClientRole(db, client.id, 'system-admin');
+}
+
async function seedRealmDefaults(db, kind) {
const now = Date.now();
await ensureRole(db, 'realm-admin', 'Realm administrator', now);
@@ -82,14 +105,15 @@ async function writeRealmSettings(db, settings) {
const now = Date.now();
await db.run(
`INSERT INTO realm_settings
- (id, slug, display_name, kind, status, branding, created_at, updated_at)
+ (id, slug, display_name, kind, status, branding, allow_session_duration_choice, created_at, updated_at)
VALUES
- (1, {slug}, {displayName}, {kind}, 'active', {branding}, {createdAt}, {updatedAt})`,
+ (1, {slug}, {displayName}, {kind}, 'active', {branding}, {allowChoice}, {createdAt}, {updatedAt})`,
{
slug: settings.slug,
displayName: settings.displayName,
kind: settings.kind || 'realm',
branding: JSON.stringify(settings.branding || {}),
+ allowChoice: settings.allowSessionDurationChoice === false ? 0 : 1,
createdAt: now,
updatedAt: now,
}
@@ -109,19 +133,20 @@ async function ensureSystemRealm() {
const registry = await systemDb.get('SELECT id FROM realms WHERE id = {id}', { id: 'system' });
if (!registry) {
await systemDb.run(
- 'INSERT INTO realms (id, slug, db_file, created_at) VALUES ({id}, {slug}, NULL, {createdAt})',
+ `INSERT INTO realms (id, slug, db_file, owner_user_id, created_at)
+ VALUES ({id}, {slug}, NULL, NULL, {createdAt})`,
{ id: 'system', slug: 'system', createdAt: Date.now() }
);
}
await seedRealmDefaults(systemDb, 'system');
- const domains = await systemDb.get('SELECT COUNT(*) AS count FROM realm_domains');
- if (Number(domains.count) === 0) {
+ const hosts = await systemDb.get('SELECT COUNT(*) AS count FROM realm_hosts');
+ if (Number(hosts.count) === 0) {
const now = Date.now();
for (const host of ['localhost', '127.0.0.1', '[::1]']) {
await systemDb.run(
- 'INSERT INTO realm_domains (host, realm_id, created_at) VALUES ({host}, {realmId}, {createdAt})',
+ 'INSERT INTO realm_hosts (host, realm_id, created_at) VALUES ({host}, {realmId}, {createdAt})',
{ host, realmId: 'system', createdAt: now }
);
}
@@ -141,6 +166,14 @@ export function normalizeHost(host) {
return value.replace(/\.$/, '');
}
+export function normalizeEmailDomain(domain) {
+ const value = String(domain || '').trim().toLowerCase().replace(/\.$/, '');
+ if (!value || value.includes('@')) {
+ throw new Error(`invalid email domain: ${JSON.stringify(domain)}`);
+ }
+ return value;
+}
+
export async function initRealms(cfg) {
await closeRealms();
@@ -191,7 +224,7 @@ export async function openRealmDatabase(realm) {
}
const db = await openDatabase({ dialect, path: fullPath });
- await migrateRealm(db);
+ await migrateRealm(db, `realm ${realm.slug}`);
await seedRealmDefaults(db, 'realm');
realmDbs.set(realm.id, db);
return db;
@@ -200,7 +233,9 @@ export async function openRealmDatabase(realm) {
export async function realmMetadata(realm) {
const db = await openRealmDatabase(realm);
const settings = await db.get(
- 'SELECT slug, display_name, status, branding FROM realm_settings WHERE id = 1'
+ `SELECT slug, display_name, status, branding, allow_session_duration_choice
+ FROM realm_settings
+ WHERE id = 1`
);
if (!settings || settings.status !== 'active') {
@@ -211,6 +246,7 @@ export async function realmMetadata(realm) {
slug: settings.slug,
display_name: settings.display_name,
branding: parseBranding(settings.branding),
+ allow_session_duration_choice: Boolean(settings.allow_session_duration_choice),
};
}
@@ -220,10 +256,10 @@ export async function resolveRealm({ host, slug } = {}) {
const normalizedHost = normalizeHost(host);
if (normalizedHost) {
const byHost = await systemDb.get(
- `SELECT r.id, r.slug, r.db_file
- FROM realm_domains d
- JOIN realms r ON r.id = d.realm_id
- WHERE d.host = {host}`,
+ `SELECT r.id, r.slug, r.db_file, r.owner_user_id
+ FROM realm_hosts h
+ JOIN realms r ON r.id = h.realm_id
+ WHERE h.host = {host}`,
{ host: normalizedHost }
);
if (byHost) {
@@ -236,7 +272,7 @@ export async function resolveRealm({ host, slug } = {}) {
if (slug) {
const bySlug = await systemDb.get(
- 'SELECT id, slug, db_file FROM realms WHERE slug = {slug}',
+ 'SELECT id, slug, db_file, owner_user_id FROM realms WHERE slug = {slug}',
{ slug }
);
if (bySlug) return bySlug;
@@ -245,7 +281,14 @@ export async function resolveRealm({ host, slug } = {}) {
throw new HttpError(404, 'unknown_realm', 'Unknown realm');
}
-export async function createRealm({ slug, displayName, branding = {}, hosts = [] }) {
+export async function createRealm({
+ slug,
+ displayName,
+ branding = {},
+ hosts = [],
+ ownerUserId = null,
+ allowSessionDurationChoice = true,
+}) {
if (!systemDb) throw new Error('realm registry is not initialized');
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug || '')) {
throw new Error(`invalid realm slug: ${JSON.stringify(slug)}`);
@@ -260,8 +303,14 @@ export async function createRealm({ slug, displayName, branding = {}, hosts = []
const db = await openDatabase({ dialect, path: fullPath });
try {
- await migrateRealm(db);
- await writeRealmSettings(db, { slug, displayName, kind: 'realm', branding });
+ await migrateRealm(db, `realm ${slug}`);
+ await writeRealmSettings(db, {
+ slug,
+ displayName,
+ kind: 'realm',
+ branding,
+ allowSessionDurationChoice,
+ });
await seedRealmDefaults(db, 'realm');
} catch (e) {
await db.close();
@@ -271,8 +320,9 @@ export async function createRealm({ slug, displayName, branding = {}, hosts = []
try {
await systemDb.run(
- 'INSERT INTO realms (id, slug, db_file, created_at) VALUES ({id}, {slug}, {dbFile}, {createdAt})',
- { id, slug, dbFile, createdAt: Date.now() }
+ `INSERT INTO realms (id, slug, db_file, owner_user_id, created_at)
+ VALUES ({id}, {slug}, {dbFile}, {ownerUserId}, {createdAt})`,
+ { id, slug, dbFile, ownerUserId, createdAt: Date.now() }
);
for (const host of hosts) {
@@ -286,7 +336,7 @@ export async function createRealm({ slug, displayName, branding = {}, hosts = []
}
realmDbs.set(id, db);
- return { id, slug, db_file: dbFile };
+ return { id, slug, db_file: dbFile, owner_user_id: ownerUserId };
}
export async function addRealmHost(realmId, host) {
@@ -296,12 +346,60 @@ export async function addRealmHost(realmId, host) {
throw new Error(`invalid realm hostname: ${JSON.stringify(host)}`);
}
await systemDb.run(
- 'INSERT INTO realm_domains (host, realm_id, created_at) VALUES ({host}, {realmId}, {createdAt})',
+ 'INSERT INTO realm_hosts (host, realm_id, created_at) VALUES ({host}, {realmId}, {createdAt})',
{ host: normalized, realmId, createdAt: Date.now() }
);
}
+export async function addRealmEmailDomain(realmId, domain, { verified = true } = {}) {
+ if (!systemDb) throw new Error('realm registry is not initialized');
+ const normalized = normalizeEmailDomain(domain);
+ const existing = await systemDb.get(
+ 'SELECT domain FROM realm_email_domains WHERE domain = {domain}',
+ { domain: normalized }
+ );
+ if (existing) throw new Error(`email domain is already assigned: ${normalized}`);
+ const now = Date.now();
+ await systemDb.run(
+ `INSERT INTO realm_email_domains
+ (domain, realm_id, verified, verification_token, verified_at, created_at)
+ VALUES
+ ({domain}, {realmId}, {verified}, {token}, {verifiedAt}, {createdAt})`,
+ {
+ domain: normalized,
+ realmId,
+ verified: verified ? 1 : 0,
+ token: verified ? null : crypto.randomBytes(24).toString('base64url'),
+ verifiedAt: verified ? now : null,
+ createdAt: now,
+ }
+ );
+}
+
+export async function findRealmByEmailDomain(domain) {
+ if (!systemDb) throw new Error('realm registry is not initialized');
+ const normalized = normalizeEmailDomain(domain);
+ return systemDb.get(
+ `SELECT r.id, r.slug, r.db_file, r.owner_user_id
+ FROM realm_email_domains d
+ JOIN realms r ON r.id = d.realm_id
+ WHERE d.domain = {domain} AND d.verified = 1`,
+ { domain: normalized }
+ );
+}
+
export async function findRealmBySlug(slug) {
if (!systemDb) throw new Error('realm registry is not initialized');
- return systemDb.get('SELECT id, slug, db_file FROM realms WHERE slug = {slug}', { slug });
+ return systemDb.get(
+ 'SELECT id, slug, db_file, owner_user_id FROM realms WHERE slug = {slug}',
+ { slug }
+ );
+}
+
+export async function findRealmById(id) {
+ if (!systemDb) throw new Error('realm registry is not initialized');
+ return systemDb.get(
+ 'SELECT id, slug, db_file, owner_user_id FROM realms WHERE id = {id}',
+ { id }
+ );
}
diff --git a/server/src/lib/session.js b/server/src/lib/session.js
@@ -0,0 +1,176 @@
+import crypto from 'node:crypto';
+import { parseCookie, stringifySetCookie } from 'cookie';
+import { findUserById, getMembership, getUserRoles } from './users.js';
+
+export const SHORT_SESSION_TTL = 12 * 60 * 60;
+export const LONG_SESSION_TTL = 7 * 24 * 60 * 60;
+const TOUCH_INTERVAL_MS = 60 * 1000;
+
+export function sessionCookieName(realm) {
+ return `auth_session_${realm.slug}`;
+}
+
+function isSecureRequest(req) {
+ if (req.socket?.encrypted) return true;
+ const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
+ return proto === 'https';
+}
+
+function appendSetCookie(res, value) {
+ const existing = res.getHeader('Set-Cookie');
+ if (!existing) {
+ res.setHeader('Set-Cookie', value);
+ return;
+ }
+ const values = Array.isArray(existing) ? existing : [existing];
+ res.setHeader('Set-Cookie', [...values, value]);
+}
+
+export function setSessionCookie(res, req, realm, token, ttl) {
+ appendSetCookie(
+ res,
+ stringifySetCookie({
+ name: sessionCookieName(realm),
+ value: token,
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ maxAge: ttl,
+ secure: isSecureRequest(req),
+ })
+ );
+}
+
+export function clearSessionCookie(res, req, realm) {
+ appendSetCookie(
+ res,
+ stringifySetCookie({
+ name: sessionCookieName(realm),
+ value: '',
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ maxAge: 0,
+ secure: isSecureRequest(req),
+ })
+ );
+}
+
+export function requestToken(req, realm) {
+ const cookies = parseCookie(req.headers.cookie || '');
+ return cookies[sessionCookieName(realm)] || null;
+}
+
+export function requestIp(req) {
+ const forwarded = String(req.headers['x-forwarded-for'] || '')
+ .split(',')[0]
+ .trim();
+ return forwarded || req.socket?.remoteAddress || null;
+}
+
+function hashToken(token) {
+ return crypto.createHash('sha256').update(token).digest('hex');
+}
+
+export async function purgeExpiredSessions(db, now = Date.now()) {
+ await db.run('DELETE FROM sessions WHERE expires_at <= {now}', { now });
+}
+
+export async function createSession(db, { userId, ttl, userAgent = null, ip = null }, now = Date.now()) {
+ const token = crypto.randomBytes(32).toString('base64url');
+ const session = {
+ id: hashToken(token),
+ user_id: userId,
+ ttl,
+ created_at: now,
+ expires_at: now + ttl * 1000,
+ last_seen_at: now,
+ user_agent: userAgent,
+ ip,
+ };
+
+ await db.run(
+ `INSERT INTO sessions
+ (id, user_id, ttl, created_at, expires_at, last_seen_at, user_agent, ip)
+ VALUES
+ ({id}, {userId}, {ttl}, {createdAt}, {expiresAt}, {lastSeenAt}, {userAgent}, {ip})`,
+ {
+ id: session.id,
+ userId: session.user_id,
+ ttl: session.ttl,
+ createdAt: session.created_at,
+ expiresAt: session.expires_at,
+ lastSeenAt: session.last_seen_at,
+ userAgent: session.user_agent,
+ ip: session.ip,
+ }
+ );
+
+ return { token, session };
+}
+
+export async function getSessionByToken(db, token, now = Date.now()) {
+ if (!token) return null;
+ const session = await db.get(
+ `SELECT id, user_id, ttl, created_at, expires_at, last_seen_at, user_agent, ip
+ FROM sessions
+ WHERE id = {id}`,
+ { id: hashToken(token) }
+ );
+ if (!session) return null;
+ if (session.expires_at <= now) {
+ await db.run('DELETE FROM sessions WHERE id = {id}', { id: session.id });
+ return null;
+ }
+ return session;
+}
+
+export async function destroySession(db, token) {
+ if (!token) return;
+ await db.run('DELETE FROM sessions WHERE id = {id}', { id: hashToken(token) });
+}
+
+export async function authenticateRequest({
+ systemDb,
+ realmDb,
+ req,
+ res,
+ realm,
+ now = Date.now(),
+}) {
+ const token = requestToken(req, realm);
+ if (!token) return null;
+
+ const session = await getSessionByToken(realmDb, token, now);
+ if (!session) return null;
+
+ const user = await findUserById(systemDb, session.user_id);
+ if (!user || user.status !== 'active') {
+ await destroySession(realmDb, token);
+ clearSessionCookie(res, req, realm);
+ return null;
+ }
+
+ const membership = await getMembership(realmDb, user.id);
+ if (!membership || membership.status !== 'active') {
+ await destroySession(realmDb, token);
+ clearSessionCookie(res, req, realm);
+ return null;
+ }
+
+ if (now - session.last_seen_at >= TOUCH_INTERVAL_MS) {
+ const expiresAt = now + session.ttl * 1000;
+ await realmDb.run(
+ `UPDATE sessions
+ SET last_seen_at = {now}, expires_at = {expiresAt}
+ WHERE id = {id}`,
+ { now, expiresAt, id: session.id }
+ );
+ session.last_seen_at = now;
+ session.expires_at = expiresAt;
+ setSessionCookie(res, req, realm, token, session.ttl);
+ }
+
+ const roles = await getUserRoles(realmDb, user.id);
+ return { session, user, membership, roles };
+}
diff --git a/server/src/lib/users.js b/server/src/lib/users.js
@@ -0,0 +1,263 @@
+import crypto from 'node:crypto';
+import { HttpError } from './http/http-error.js';
+
+export function normalizeEmail(email) {
+ return String(email || '').trim().toLowerCase();
+}
+
+export function normalizeUsername(username) {
+ return String(username || '').trim().toLowerCase();
+}
+
+export function emailDomain(email) {
+ const normalized = normalizeEmail(email);
+ const at = normalized.lastIndexOf('@');
+ return at > 0 && at < normalized.length - 1 ? normalized.slice(at + 1) : null;
+}
+
+export async function createUser(db, { username, displayName = null, homeRealmId = null }) {
+ const normalized = normalizeUsername(username);
+ if (!normalized) throw new Error('username is required');
+ const existing = await db.get(
+ 'SELECT id FROM users WHERE username_normalized = {username}',
+ { username: normalized }
+ );
+ if (existing) throw new Error(`username is already taken: ${username}`);
+
+ const id = crypto.randomUUID();
+ const now = Date.now();
+ await db.run(
+ `INSERT INTO users
+ (id, username, username_normalized, display_name, status, home_realm_id, profile, created_at, updated_at)
+ VALUES
+ ({id}, {username}, {normalized}, {displayName}, 'active', {homeRealmId}, '{}', {createdAt}, {updatedAt})`,
+ {
+ id,
+ username,
+ normalized,
+ displayName: displayName || username,
+ homeRealmId,
+ createdAt: now,
+ updatedAt: now,
+ }
+ );
+ return id;
+}
+
+export async function findUserById(db, id) {
+ return db.get(
+ `SELECT id, username, username_normalized, display_name, status, home_realm_id,
+ profile, last_login_at, created_at, updated_at
+ FROM users
+ WHERE id = {id}`,
+ { id }
+ );
+}
+
+export async function findUserByEmail(db, email) {
+ const normalized = normalizeEmail(email);
+ return db.get(
+ `SELECT u.id, u.username, u.username_normalized, u.display_name, u.status,
+ u.home_realm_id, u.profile, u.last_login_at,
+ e.email, e.email_normalized
+ FROM users u
+ JOIN user_emails e ON e.user_id = u.id
+ WHERE e.email_normalized = {email} AND e.verified = 1`,
+ { email: normalized }
+ );
+}
+
+export async function findUserByUsername(db, username) {
+ const normalized = normalizeUsername(username);
+ return db.get(
+ `SELECT id, username, username_normalized, display_name, status,
+ home_realm_id, profile, last_login_at
+ FROM users
+ WHERE username_normalized = {username}`,
+ { username: normalized }
+ );
+}
+
+export async function findUserByIdentifier(db, identifier) {
+ const value = String(identifier || '').trim();
+ return value.includes('@')
+ ? findUserByEmail(db, value)
+ : findUserByUsername(db, value);
+}
+
+export async function getUserEmails(db, userId) {
+ return db.all(
+ `SELECT id, email, email_normalized, verified, is_primary, created_at, verified_at
+ FROM user_emails
+ WHERE user_id = {userId}
+ ORDER BY is_primary DESC, created_at ASC`,
+ { userId }
+ );
+}
+
+export async function getPrimaryEmail(db, userId) {
+ return db.get(
+ `SELECT email, email_normalized, verified
+ FROM user_emails
+ WHERE user_id = {userId}
+ ORDER BY is_primary DESC, created_at ASC
+ LIMIT 1`,
+ { userId }
+ );
+}
+
+export async function addUserEmail(db, userId, email, { verified = false, primary = false } = {}) {
+ const normalized = normalizeEmail(email);
+ const existing = await db.get(
+ 'SELECT user_id FROM user_emails WHERE email_normalized = {email}',
+ { email: normalized }
+ );
+ if (existing) throw new Error(`email is already in use: ${normalized}`);
+
+ const id = crypto.randomUUID();
+ const now = Date.now();
+ await db.transaction(async (tx) => {
+ if (primary) {
+ await tx.run('UPDATE user_emails SET is_primary = 0 WHERE user_id = {userId}', { userId });
+ }
+ await tx.run(
+ `INSERT INTO user_emails
+ (id, user_id, email, email_normalized, verified, is_primary, created_at, verified_at)
+ VALUES
+ ({id}, {userId}, {email}, {normalized}, {verified}, {primary}, {createdAt}, {verifiedAt})`,
+ {
+ id,
+ userId,
+ email,
+ normalized,
+ verified: verified ? 1 : 0,
+ primary: primary ? 1 : 0,
+ createdAt: now,
+ verifiedAt: verified ? now : null,
+ }
+ );
+ });
+ return id;
+}
+
+export async function setPasswordCredential(db, userId, secretHash) {
+ const existing = await db.get(
+ 'SELECT id FROM credentials WHERE user_id = {userId} AND type = {type}',
+ { userId, type: 'password' }
+ );
+ const now = Date.now();
+ if (existing) {
+ await db.run(
+ `UPDATE credentials
+ SET secret_hash = {secretHash}, updated_at = {now}
+ WHERE id = {id}`,
+ { id: existing.id, secretHash, now }
+ );
+ return existing.id;
+ }
+
+ const id = crypto.randomUUID();
+ await db.run(
+ `INSERT INTO credentials
+ (id, user_id, type, secret_hash, created_at, updated_at)
+ VALUES
+ ({id}, {userId}, 'password', {secretHash}, {createdAt}, {updatedAt})`,
+ { id, userId, secretHash, createdAt: now, updatedAt: now }
+ );
+ return id;
+}
+
+export async function getPasswordCredential(db, userId) {
+ return db.get(
+ `SELECT id, secret_hash
+ FROM credentials
+ WHERE user_id = {userId} AND type = 'password'`,
+ { userId }
+ );
+}
+
+export async function updateLastLogin(db, userId, now = Date.now()) {
+ await db.run(
+ 'UPDATE users SET last_login_at = {now}, updated_at = {now} WHERE id = {userId}',
+ { now, userId }
+ );
+}
+
+export async function assignHomeRealmForEmail(db, userId, email) {
+ const domain = emailDomain(email);
+ if (!domain) return null;
+
+ const owner = await db.get(
+ `SELECT realm_id
+ FROM realm_email_domains
+ WHERE domain = {domain} AND verified = 1`,
+ { domain }
+ );
+ if (!owner) return null;
+
+ const user = await findUserById(db, userId);
+ if (!user) throw new Error(`unknown user: ${userId}`);
+ if (!user.home_realm_id) {
+ await db.run(
+ 'UPDATE users SET home_realm_id = {realmId}, updated_at = {now} WHERE id = {userId}',
+ { realmId: owner.realm_id, now: Date.now(), userId }
+ );
+ return owner.realm_id;
+ }
+ if (user.home_realm_id !== owner.realm_id) {
+ throw new HttpError(409, 'home_realm_conflict', 'This email belongs to a different realm');
+ }
+ return user.home_realm_id;
+}
+
+export async function getMembership(db, userId) {
+ return db.get(
+ 'SELECT user_id, status, created_at, updated_at FROM realm_memberships WHERE user_id = {userId}',
+ { userId }
+ );
+}
+
+export async function ensureMembership(db, userId, status = 'active') {
+ const existing = await getMembership(db, userId);
+ const now = Date.now();
+ if (!existing) {
+ await db.run(
+ `INSERT INTO realm_memberships (user_id, status, created_at, updated_at)
+ VALUES ({userId}, {status}, {createdAt}, {updatedAt})`,
+ { userId, status, createdAt: now, updatedAt: now }
+ );
+ return;
+ }
+ if (existing.status !== status) {
+ await db.run(
+ 'UPDATE realm_memberships SET status = {status}, updated_at = {now} WHERE user_id = {userId}',
+ { status, now, userId }
+ );
+ }
+}
+
+export async function getUserRoles(db, userId) {
+ const rows = await db.all(
+ `SELECT r.slug
+ FROM membership_roles mr
+ JOIN roles r ON r.id = mr.role_id
+ WHERE mr.user_id = {userId}
+ ORDER BY r.slug ASC`,
+ { userId }
+ );
+ return rows.map((row) => row.slug);
+}
+
+export async function assignRole(db, userId, roleSlug) {
+ const role = await db.get('SELECT id FROM roles WHERE slug = {slug}', { slug: roleSlug });
+ if (!role) throw new Error(`unknown role: ${roleSlug}`);
+ const existing = await db.get(
+ 'SELECT user_id FROM membership_roles WHERE user_id = {userId} AND role_id = {roleId}',
+ { userId, roleId: role.id }
+ );
+ if (existing) return;
+ await db.run(
+ 'INSERT INTO membership_roles (user_id, role_id) VALUES ({userId}, {roleId})',
+ { userId, roleId: role.id }
+ );
+}
diff --git a/server/test/http.test.js b/server/test/http.test.js
@@ -0,0 +1,301 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import http from 'node:http';
+import net from 'node:net';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const serverDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+function freePort() {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', () => {
+ const { port } = server.address();
+ server.close(() => resolve(port));
+ });
+ });
+}
+
+function runNode(script, env) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [script], {
+ cwd: serverDir,
+ env: { ...process.env, ...env },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk;
+ });
+ child.once('error', reject);
+ child.once('exit', (code) => {
+ if (code === 0) resolve({ stdout, stderr });
+ else reject(new Error(`${script} exited ${code}\n${stdout}\n${stderr}`));
+ });
+ });
+}
+
+function startServer(env) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, ['src/index.js'], {
+ cwd: serverDir,
+ env: { ...process.env, ...env },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk;
+ });
+ child.once('error', reject);
+ child.once('exit', (code) => {
+ if (code !== 0 && !stdout.includes('Listening on')) {
+ reject(new Error(`server exited ${code}\n${stdout}\n${stderr}`));
+ }
+ });
+ const deadline = Date.now() + 15_000;
+ const timer = setInterval(() => {
+ if (stdout.includes('Listening on')) {
+ clearInterval(timer);
+ resolve(child);
+ return;
+ }
+ if (Date.now() > deadline) {
+ clearInterval(timer);
+ child.kill('SIGTERM');
+ reject(new Error(`server did not start\n${stdout}\n${stderr}`));
+ }
+ }, 50);
+ });
+}
+
+function request(port, { method = 'GET', path: requestPath = '/', host, body, cookie } = {}) {
+ return new Promise((resolve, reject) => {
+ const payload = body === undefined ? null : JSON.stringify(body);
+ const headers = { host };
+ if (payload !== null) {
+ headers['content-type'] = 'application/json';
+ headers['content-length'] = Buffer.byteLength(payload);
+ }
+ if (cookie) headers.cookie = cookie;
+
+ const req = http.request(
+ { host: '127.0.0.1', port, method, path: requestPath, headers },
+ (res) => {
+ const chunks = [];
+ res.on('data', (chunk) => chunks.push(chunk));
+ res.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf8');
+ let json = null;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ json = null;
+ }
+ resolve({ status: res.statusCode, headers: res.headers, text, json });
+ });
+ }
+ );
+ req.once('error', reject);
+ if (payload !== null) req.write(payload);
+ req.end();
+ });
+}
+
+function sessionCookie(response) {
+ const values = response.headers['set-cookie'];
+ if (!values || values.length === 0) return null;
+ return values[0].split(';')[0];
+}
+
+function maxAge(response) {
+ const values = response.headers['set-cookie'];
+ if (!values || values.length === 0) return null;
+ const match = /Max-Age=(\d+)/.exec(values[0]);
+ return match ? Number(match[1]) : null;
+}
+
+test('HTTP login, session, provider and logout flows', async (t) => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-http-'));
+ const port = await freePort();
+ const env = {
+ AUTH_DATABASE_PATH: path.join(dir, 'system.sqlite'),
+ AUTH_REALMS_PATH: path.join(dir, 'realms'),
+ AUTH_PORT: String(port),
+ };
+
+ let server = null;
+ try {
+ await runNode('scripts/seed-demo.js', env);
+ server = await startServer(env);
+
+ const acme = (options = {}) =>
+ request(port, { host: 'acme.localhost', ...options });
+ const globex = (options = {}) =>
+ request(port, { host: 'globex.localhost', ...options });
+
+ await t.test('realm metadata is served for a realm hostname', async () => {
+ const response = await acme({ path: '/api/v1/realm' });
+ assert.equal(response.status, 200);
+ assert.equal(response.json.slug, 'acme');
+ assert.equal(response.json.allow_session_duration_choice, true);
+ });
+
+ await t.test('hostname wins over a conflicting path slug', async () => {
+ const response = await acme({ path: '/api/v1/realm?realm=system' });
+ assert.equal(response.status, 404);
+ assert.equal(response.json.error.code, 'unknown_realm');
+ });
+
+ await t.test('identifier resolution selects password or upstream OIDC', async () => {
+ const password = await acme({
+ method: 'POST',
+ path: '/api/v1/login/resolve',
+ body: { identifier: 'alice@acme.test' },
+ });
+ assert.equal(password.status, 200);
+ assert.equal(password.json.next, 'password');
+
+ const upstream = await acme({
+ method: 'POST',
+ path: '/api/v1/login/resolve',
+ body: { identifier: 'someone@sso.acme.test' },
+ });
+ assert.equal(upstream.status, 200);
+ assert.equal(upstream.json.next, 'oidc');
+ assert.equal(upstream.json.provider.display_name, 'Example SSO');
+ });
+
+ await t.test('password login creates a realm session with the short cookie', async () => {
+ const response = await acme({
+ method: 'POST',
+ path: '/api/v1/login/password',
+ body: { identifier: 'alice@acme.test', password: 'demo' },
+ });
+ assert.equal(response.status, 200);
+ assert.equal(response.json.user.username, 'alice');
+ assert.equal(maxAge(response), 12 * 60 * 60);
+
+ const cookie = sessionCookie(response);
+ const session = await acme({ path: '/api/v1/session', cookie });
+ assert.equal(session.status, 200);
+ assert.equal(session.json.user.username, 'alice');
+ assert.deepEqual(session.json.roles, []);
+ });
+
+ await t.test('long sessions are only issued when requested', async () => {
+ const response = await acme({
+ method: 'POST',
+ path: '/api/v1/login/password',
+ body: { identifier: 'alice@acme.test', password: 'demo', long_session: true },
+ });
+ assert.equal(response.status, 200);
+ assert.equal(maxAge(response), 7 * 24 * 60 * 60);
+ });
+
+ await t.test('bad credentials are generic', async () => {
+ const response = await acme({
+ method: 'POST',
+ path: '/api/v1/login/password',
+ body: { identifier: 'alice@acme.test', password: 'wrong' },
+ });
+ assert.equal(response.status, 401);
+ assert.equal(response.json.error.code, 'invalid_credentials');
+ });
+
+ await t.test('membership is required for a realm', async () => {
+ const denied = await globex({
+ method: 'POST',
+ path: '/api/v1/login/password',
+ body: { identifier: 'guest@personal.test', password: 'demo' },
+ });
+ assert.equal(denied.status, 401);
+
+ const allowed = await acme({
+ method: 'POST',
+ path: '/api/v1/login/password',
+ body: { identifier: 'guest@personal.test', password: 'demo' },
+ });
+ assert.equal(allowed.status, 200);
+ });
+
+ await t.test('password login is rejected for upstream-mapped domains', async () => {
+ const response = await acme({
+ method: 'POST',
+ path: '/api/v1/login/password',
+ body: { identifier: 'someone@sso.acme.test', password: 'demo' },
+ });
+ assert.equal(response.status, 403);
+ assert.equal(response.json.error.code, 'provider_required');
+ });
+
+ await t.test('guests can hold realm-admin roles', async () => {
+ const response = await globex({
+ method: 'POST',
+ path: '/api/v1/login/password',
+ body: { identifier: 'contractor@personal.test', password: 'demo' },
+ });
+ assert.equal(response.status, 200);
+ const session = await globex({
+ path: '/api/v1/session',
+ cookie: sessionCookie(response),
+ });
+ assert.equal(session.status, 200);
+ assert.ok(session.json.roles.includes('realm-admin'));
+ });
+
+ await t.test('logout removes only the current realm session', async () => {
+ const acmeLogin = await acme({
+ method: 'POST',
+ path: '/api/v1/login/password',
+ body: { identifier: 'alice@acme.test', password: 'demo' },
+ });
+ const acmeCookie = sessionCookie(acmeLogin);
+
+ const globexLogin = await globex({
+ method: 'POST',
+ path: '/api/v1/login/password',
+ body: { identifier: 'contractor@personal.test', password: 'demo' },
+ });
+ const globexCookie = sessionCookie(globexLogin);
+
+ const logout = await acme({
+ method: 'POST',
+ path: '/api/v1/logout',
+ body: {},
+ cookie: acmeCookie,
+ });
+ assert.equal(logout.status, 200);
+
+ const acmeSession = await acme({ path: '/api/v1/session', cookie: acmeCookie });
+ assert.equal(acmeSession.status, 401);
+
+ const globexSession = await globex({ path: '/api/v1/session', cookie: globexCookie });
+ assert.equal(globexSession.status, 200);
+ });
+
+ await t.test('sessions require a cookie', async () => {
+ const response = await acme({ path: '/api/v1/session' });
+ assert.equal(response.status, 401);
+ assert.equal(response.json.error.code, 'not_authenticated');
+ });
+ } finally {
+ if (server) {
+ server.kill('SIGTERM');
+ await new Promise((resolve) => server.once('exit', resolve));
+ }
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/server/test/password.test.js b/server/test/password.test.js
@@ -0,0 +1,28 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { hashPassword, verifyDummyPassword, verifyPassword } from '../src/lib/password.js';
+
+test('password hashes verify and reject the wrong password', async () => {
+ const encoded = await hashPassword('correct horse battery staple');
+ assert.match(encoded, /^scrypt\$/);
+ assert.equal(await verifyPassword('correct horse battery staple', encoded), true);
+ assert.equal(await verifyPassword('wrong', encoded), false);
+});
+
+test('the same password produces different hashes', async () => {
+ const first = await hashPassword('same password');
+ const second = await hashPassword('same password');
+ assert.notEqual(first, second);
+ assert.equal(await verifyPassword('same password', first), true);
+ assert.equal(await verifyPassword('same password', second), true);
+});
+
+test('malformed hashes and oversized passwords fail closed', async () => {
+ assert.equal(await verifyPassword('password', 'not-a-hash'), false);
+ assert.equal(await verifyPassword('password', 'scrypt$1$2$3$bad$bad'), false);
+ assert.equal(await verifyPassword('x'.repeat(1025), await hashPassword('password')), false);
+});
+
+test('the dummy hash rejects arbitrary passwords', async () => {
+ assert.equal(await verifyDummyPassword('anything'), false);
+});
diff --git a/server/test/policy.test.js b/server/test/policy.test.js
@@ -0,0 +1,77 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { canManageUser, canUseClient, isRealmAdmin } from '../src/lib/policy.js';
+
+const realm = { id: 'realm-a' };
+const user = { id: 'u1', status: 'active', home_realm_id: 'realm-a' };
+const guest = { id: 'u2', status: 'active', home_realm_id: 'realm-b' };
+const activeMembership = { status: 'active' };
+const suspendedMembership = { status: 'suspended' };
+
+function client(access_mode, required_roles = []) {
+ return { access_mode, enabled: 1, required_roles };
+}
+
+test('internal clients accept only home-realm users', () => {
+ const c = client('internal');
+ assert.equal(canUseClient({ user, membership: activeMembership, roles: [], client: c, realm }), true);
+ assert.equal(canUseClient({ user: guest, membership: activeMembership, roles: [], client: c, realm }), false);
+});
+
+test('members clients accept home and guest members', () => {
+ const c = client('members');
+ assert.equal(canUseClient({ user, membership: activeMembership, roles: [], client: c, realm }), true);
+ assert.equal(canUseClient({ user: guest, membership: activeMembership, roles: [], client: c, realm }), true);
+ assert.equal(canUseClient({ user: guest, membership: suspendedMembership, roles: [], client: c, realm }), false);
+});
+
+test('public clients accept non-members and ignore role requirements', () => {
+ const c = client('public', ['realm-admin']);
+ assert.equal(canUseClient({ user: guest, membership: null, roles: [], client: c, realm }), true);
+});
+
+test('required roles are any-of', () => {
+ const c = client('members', ['realm-admin', 'auditor']);
+ assert.equal(
+ canUseClient({ user: guest, membership: activeMembership, roles: ['auditor'], client: c, realm }),
+ true
+ );
+ assert.equal(
+ canUseClient({ user: guest, membership: activeMembership, roles: ['member'], client: c, realm }),
+ false
+ );
+});
+
+test('disabled users and clients are denied', () => {
+ const disabledUser = { ...user, status: 'disabled' };
+ assert.equal(
+ canUseClient({ user: disabledUser, membership: activeMembership, roles: [], client: client('public'), realm }),
+ false
+ );
+ assert.equal(
+ canUseClient({
+ user,
+ membership: activeMembership,
+ roles: [],
+ client: { ...client('members'), enabled: 0 },
+ realm,
+ }),
+ false
+ );
+});
+
+test('realm admins manage home users fully and guests by membership only', () => {
+ assert.equal(isRealmAdmin(['realm-admin']), true);
+ assert.equal(
+ canManageUser({ actorRoles: ['realm-admin'], actorIsSystemAdmin: false, target: user, realmId: 'realm-a' }),
+ 'full'
+ );
+ assert.equal(
+ canManageUser({ actorRoles: ['realm-admin'], actorIsSystemAdmin: false, target: guest, realmId: 'realm-a' }),
+ 'membership'
+ );
+ assert.equal(
+ canManageUser({ actorRoles: [], actorIsSystemAdmin: false, target: guest, realmId: 'realm-a' }),
+ null
+ );
+});
diff --git a/server/test/realm.test.js b/server/test/realm.test.js
@@ -4,15 +4,24 @@ import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
-import { resolveLogin } from '../src/lib/login.js';
+import { findManagedProviderForUser, resolveLogin } from '../src/lib/login.js';
import {
+ addRealmEmailDomain,
closeRealms,
createRealm,
+ findRealmByEmailDomain,
+ getSystemDatabase,
initRealms,
openRealmDatabase,
realmMetadata,
resolveRealm,
} from '../src/lib/realm.js';
+import {
+ addUserEmail,
+ assignHomeRealmForEmail,
+ createUser,
+ findUserById,
+} from '../src/lib/users.js';
async function tempConfig() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-realm-'));
@@ -44,11 +53,11 @@ test('realm resolution prefers hostname and rejects a mismatched slug', async ()
() => resolveRealm({ host: 'acme.localhost', slug: 'system' }),
/Unknown realm/
);
-
await assert.rejects(() => resolveRealm({ host: 'main.test:3000', slug: 'nope' }), /Unknown realm/);
const metadata = await realmMetadata(byHost);
assert.equal(metadata.display_name, 'Acme');
+ assert.equal(metadata.allow_session_duration_choice, true);
assert.deepEqual(metadata.branding, {});
} finally {
await closeRealms();
@@ -56,16 +65,17 @@ test('realm resolution prefers hostname and rejects a mismatched slug', async ()
}
});
-test('identifier resolution selects password, upstream provider or a disabled provider', async () => {
+test('owned email domains select their realm provider mapping', async () => {
const { dir, cfg } = await tempConfig();
try {
await initRealms(cfg);
const realm = await createRealm({ slug: 'acme', displayName: 'Acme' });
+ await addRealmEmailDomain(realm.id, 'example.com', { verified: true });
const db = await openRealmDatabase(realm);
- const local = await resolveLogin(db, 'alice@local.test');
+ const local = await resolveLogin({ identifier: 'alice@local.test' });
assert.equal(local.next, 'password');
- const username = await resolveLogin(db, 'alice');
+ const username = await resolveLogin({ identifier: 'alice' });
assert.equal(username.next, 'password');
const providerId = crypto.randomUUID();
@@ -78,16 +88,46 @@ test('identifier resolution selects password, upstream provider or a disabled pr
{ id: providerId, now }
);
await db.run(
- 'INSERT INTO email_domains (domain, provider_id, created_at) VALUES ({domain}, {providerId}, {now})',
+ 'INSERT INTO email_domain_providers (domain, provider_id, created_at) VALUES ({domain}, {providerId}, {now})',
{ domain: 'example.com', providerId, now }
);
- const upstream = await resolveLogin(db, 'someone@example.com');
+ const owner = await findRealmByEmailDomain('example.com');
+ assert.equal(owner.id, realm.id);
+
+ const upstream = await resolveLogin({ identifier: 'someone@example.com' });
assert.equal(upstream.next, 'oidc');
assert.equal(upstream.provider.display_name, 'Example SSO');
+ const systemDb = getSystemDatabase();
+ const userId = await createUser(systemDb, { username: 'someone' });
+ await addUserEmail(systemDb, userId, 'someone@example.com', { verified: true, primary: true });
+ await assignHomeRealmForEmail(systemDb, userId, 'someone@example.com');
+ const user = await findUserById(systemDb, userId);
+ assert.equal(user.home_realm_id, realm.id);
+
+ const managed = await findManagedProviderForUser(systemDb, user);
+ assert.equal(managed.display_name, 'Example SSO');
+
await db.run('UPDATE identity_providers SET enabled = 0 WHERE id = {id}', { id: providerId });
- await assert.rejects(() => resolveLogin(db, 'someone@example.com'), /unavailable/);
+ await assert.rejects(() => resolveLogin({ identifier: 'someone@example.com' }), /unavailable/);
+ } finally {
+ await closeRealms();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('a realm can disable the long-session choice', async () => {
+ const { dir, cfg } = await tempConfig();
+ try {
+ await initRealms(cfg);
+ const realm = await createRealm({
+ slug: 'acme',
+ displayName: 'Acme',
+ allowSessionDurationChoice: false,
+ });
+ const metadata = await realmMetadata(realm);
+ assert.equal(metadata.allow_session_duration_choice, false);
} finally {
await closeRealms();
await fs.rm(dir, { recursive: true, force: true });
diff --git a/server/test/session.test.js b/server/test/session.test.js
@@ -0,0 +1,175 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import {
+ closeRealms,
+ createRealm,
+ getSystemDatabase,
+ initRealms,
+ openRealmDatabase,
+} from '../src/lib/realm.js';
+import {
+ SHORT_SESSION_TTL,
+ authenticateRequest,
+ createSession,
+ destroySession,
+ getSessionByToken,
+ sessionCookieName,
+} from '../src/lib/session.js';
+import { createUser, ensureMembership } from '../src/lib/users.js';
+
+async function setup() {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-session-'));
+ const cfg = {
+ database: {
+ dialect: 'sqlite',
+ path: path.join(dir, 'system.sqlite'),
+ realms: path.join(dir, 'realms'),
+ },
+ };
+ await initRealms(cfg);
+ const realm = await createRealm({ slug: 'acme', displayName: 'Acme' });
+ const realmDb = await openRealmDatabase(realm);
+ const systemDb = getSystemDatabase();
+ const userId = await createUser(systemDb, { username: 'alice', displayName: 'Alice' });
+ await ensureMembership(realmDb, userId, 'active');
+ return { dir, realm, realmDb, systemDb, userId };
+}
+
+function mockRequest(cookie = '') {
+ return {
+ headers: { cookie },
+ socket: { remoteAddress: '127.0.0.1' },
+ };
+}
+
+function mockResponse() {
+ const headers = {};
+ return {
+ headers,
+ getHeader(name) {
+ return headers[name.toLowerCase()];
+ },
+ setHeader(name, value) {
+ headers[name.toLowerCase()] = value;
+ },
+ };
+}
+
+test('sessions store only the token hash and authenticate the user', async () => {
+ const { dir, realm, realmDb, systemDb, userId } = await setup();
+ try {
+ const { token } = await createSession(realmDb, {
+ userId,
+ ttl: SHORT_SESSION_TTL,
+ userAgent: 'test',
+ ip: '127.0.0.1',
+ });
+ const id = crypto.createHash('sha256').update(token).digest('hex');
+
+ const session = await getSessionByToken(realmDb, token);
+ assert.equal(session.id, id);
+ assert.notEqual(session.id, token);
+
+ const req = mockRequest(`${sessionCookieName(realm)}=${token}`);
+ const res = mockResponse();
+ const auth = await authenticateRequest({ systemDb, realmDb, req, res, realm });
+ assert.equal(auth.user.id, userId);
+ assert.equal(auth.membership.status, 'active');
+ } finally {
+ await closeRealms();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('sliding sessions extend expiry and reissue the cookie', async () => {
+ const { dir, realm, realmDb, systemDb, userId } = await setup();
+ try {
+ const { token, session } = await createSession(
+ realmDb,
+ { userId, ttl: SHORT_SESSION_TTL },
+ 1000
+ );
+ const req = mockRequest(`${sessionCookieName(realm)}=${token}`);
+ const res = mockResponse();
+
+ await authenticateRequest({
+ systemDb,
+ realmDb,
+ req,
+ res,
+ realm,
+ now: 1000 + 61_000,
+ });
+
+ const updated = await realmDb.get('SELECT expires_at, last_seen_at FROM sessions WHERE id = {id}', {
+ id: session.id,
+ });
+ assert.equal(updated.expires_at, 1000 + 61_000 + SHORT_SESSION_TTL * 1000);
+ assert.ok(res.getHeader('set-cookie'));
+ } finally {
+ await closeRealms();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('expired sessions are rejected and removed', async () => {
+ const { dir, realmDb, userId } = await setup();
+ try {
+ const { token, session } = await createSession(realmDb, { userId, ttl: 1 }, 1000);
+ assert.equal(await getSessionByToken(realmDb, token, 3000), null);
+ const row = await realmDb.get('SELECT id FROM sessions WHERE id = {id}', { id: session.id });
+ assert.equal(row, undefined);
+ } finally {
+ await closeRealms();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('disabled users and suspended memberships lose access', async () => {
+ const { dir, realm, realmDb, systemDb, userId } = await setup();
+ try {
+ const { token } = await createSession(realmDb, { userId, ttl: SHORT_SESSION_TTL });
+ const req = mockRequest(`${sessionCookieName(realm)}=${token}`);
+
+ await systemDb.run('UPDATE users SET status = {status} WHERE id = {id}', {
+ status: 'disabled',
+ id: userId,
+ });
+ assert.equal(
+ await authenticateRequest({ systemDb, realmDb, req, res: mockResponse(), realm }),
+ null
+ );
+
+ await systemDb.run('UPDATE users SET status = {status} WHERE id = {id}', {
+ status: 'active',
+ id: userId,
+ });
+ await realmDb.run('UPDATE realm_memberships SET status = {status} WHERE user_id = {id}', {
+ status: 'suspended',
+ id: userId,
+ });
+ assert.equal(
+ await authenticateRequest({ systemDb, realmDb, req, res: mockResponse(), realm }),
+ null
+ );
+ } finally {
+ await closeRealms();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('logout deletes the session', async () => {
+ const { dir, realmDb, userId } = await setup();
+ try {
+ const { token } = await createSession(realmDb, { userId, ttl: SHORT_SESSION_TTL });
+ await destroySession(realmDb, token);
+ assert.equal(await getSessionByToken(realmDb, token), null);
+ } finally {
+ await closeRealms();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/server/web/src/assets/global.css b/server/web/src/assets/global.css
@@ -99,6 +99,22 @@ body {
line-height: 1.5;
}
+.auth-choice {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 1rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ cursor: pointer;
+}
+
+.auth-choice input {
+ width: 1rem;
+ height: 1rem;
+ accent-color: var(--realm-primary);
+}
+
.auth-error {
margin: 1rem 0 0;
color: #d93025;
diff --git a/server/web/src/main.ts b/server/web/src/main.ts
@@ -1,13 +1,20 @@
import m from 'mithril';
+import { Admin } from './screen/admin';
import { Login } from './screen/login';
const routes: m.RouteDefs = {
'/realms/:realm/login': {
render: (vnode) => m(Login, { realm: (vnode.attrs as any).realm }),
},
+ '/realms/:realm/admin': {
+ render: (vnode) => m(Admin, { realm: (vnode.attrs as any).realm }),
+ },
'/login': {
render: () => m(Login, {}),
},
+ '/admin': {
+ render: () => m(Admin, {}),
+ },
'/': {
onmatch: () => m.route.set('/login'),
},
diff --git a/server/web/src/screen/admin.tsx b/server/web/src/screen/admin.tsx
@@ -0,0 +1,81 @@
+import m from 'mithril';
+import { getSession, signOut, type ApiError, type SessionInfo } from '../util/api';
+
+export const Admin: m.Component<{ realm?: string }> = {
+ realmSlug: null as string | null,
+ session: null as SessionInfo | null,
+ error: null as Error | null,
+ pending: false,
+
+ oninit(this: any, vnode: any) {
+ this.realmSlug = vnode.attrs.realm ?? null;
+ this.load();
+ },
+
+ async load(this: any) {
+ try {
+ this.session = await getSession(this.realmSlug);
+ document.title = this.session.realm.display_name;
+ } catch (e) {
+ const error = e as ApiError;
+ if (error.status === 401) {
+ m.route.set(this.realmSlug ? `/realms/${this.realmSlug}/login` : '/login');
+ return;
+ }
+ this.error = error;
+ }
+ m.redraw();
+ },
+
+ async signOut(this: any) {
+ this.pending = true;
+ this.error = null;
+ try {
+ await signOut(this.realmSlug);
+ m.route.set(this.realmSlug ? `/realms/${this.realmSlug}/login` : '/login');
+ } catch (e) {
+ this.error = e as Error;
+ this.pending = false;
+ m.redraw();
+ }
+ },
+
+ view(this: any) {
+ const session = this.session;
+ const branding = session?.realm.branding || {};
+ const primary = typeof branding.primary_color === 'string' ? branding.primary_color : '#25A';
+ const style = { '--realm-primary': primary } as any;
+
+ if (!session) {
+ return (
+ <div class="auth-page" style={style}>
+ <div class="auth-card">
+ <h1>Admin</h1>
+ {this.error ? <p class="auth-error">{this.error.message}</p> : <p class="auth-note">Loading</p>}
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div class="auth-page" style={style}>
+ <div class="auth-card">
+ <h1>{session.realm.display_name}</h1>
+ <p class="auth-identifier">
+ Signed in as {session.user.display_name || session.user.username}
+ </p>
+ <p class="auth-note">{session.user.username}</p>
+ <p class="auth-note">
+ {session.roles.length > 0 ? `Roles: ${session.roles.join(', ')}` : 'No realm roles'}
+ </p>
+ {this.error ? <p class="auth-error">{this.error.message}</p> : null}
+ <div class="auth-actions">
+ <button class="auth-button" type="button" disabled={this.pending} onclick={() => this.signOut()}>
+ {this.pending ? 'Signing out' : 'Sign out'}
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+ },
+};
diff --git a/server/web/src/screen/login.tsx b/server/web/src/screen/login.tsx
@@ -41,6 +41,7 @@ export const Login: m.Component<{ realm?: string }> = {
step: 'identifier' as Step,
identifier: '',
password: '',
+ longSession: false,
provider: null as LoginResolution['provider'] | null,
error: null as Error | null,
pending: false,
@@ -86,17 +87,19 @@ export const Login: m.Component<{ realm?: string }> = {
this.pending = true;
this.error = null;
try {
- await signInPassword(this.realmSlug, this.identifier, this.password);
+ await signInPassword(this.realmSlug, this.identifier, this.password, this.longSession);
+ m.route.set(this.realmSlug ? `/realms/${this.realmSlug}/admin` : '/admin');
} catch (e) {
this.error = e as Error;
+ this.pending = false;
+ m.redraw();
}
- this.pending = false;
- m.redraw();
},
back(this: any) {
this.step = 'identifier';
this.password = '';
+ this.longSession = false;
this.provider = null;
this.error = null;
m.redraw();
@@ -186,6 +189,19 @@ export const Login: m.Component<{ realm?: string }> = {
this.password = value;
}}
/>
+ {this.realm?.allow_session_duration_choice ? (
+ <label class="auth-choice">
+ <input
+ type="checkbox"
+ checked={this.longSession}
+ disabled={this.pending}
+ onchange={(e: Event) => {
+ this.longSession = (e.target as HTMLInputElement).checked;
+ }}
+ />
+ <span>Keep me signed in</span>
+ </label>
+ ) : null}
{this.error ? <p class="auth-error">{this.error.message}</p> : null}
<div class="auth-actions">
<button class="auth-link" type="button" onclick={() => this.back()}>
diff --git a/server/web/src/util/api.ts b/server/web/src/util/api.ts
@@ -2,6 +2,7 @@ export interface RealmInfo {
slug: string;
display_name: string;
branding: Record<string, string>;
+ allow_session_duration_choice: boolean;
}
export interface LoginResolution {
@@ -13,6 +14,17 @@ export interface LoginResolution {
};
}
+export interface SessionInfo {
+ realm: RealmInfo;
+ user: {
+ id: string;
+ username: string;
+ display_name: string | null;
+ };
+ roles: string[];
+ expires_at: number;
+}
+
export interface ApiError extends Error {
code?: string;
status?: number;
@@ -20,6 +32,7 @@ export interface ApiError extends Error {
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
+ credentials: 'same-origin',
headers: {
'content-type': 'application/json',
},
@@ -58,10 +71,28 @@ export function resolveLogin(realm: string | null, identifier: string): Promise<
export function signInPassword(
realm: string | null,
identifier: string,
- password: string
-): Promise<Record<string, unknown>> {
- return request('/api/v1/login/password', {
+ password: string,
+ longSession: boolean
+): Promise<SessionInfo> {
+ return request<SessionInfo>('/api/v1/login/password', {
+ method: 'POST',
+ body: JSON.stringify({
+ realm: realm || undefined,
+ identifier,
+ password,
+ long_session: longSession,
+ }),
+ });
+}
+
+export function getSession(realm?: string | null): Promise<SessionInfo> {
+ const query = realm ? `?realm=${encodeURIComponent(realm)}` : '';
+ return request<SessionInfo>(`/api/v1/session${query}`);
+}
+
+export function signOut(realm?: string | null): Promise<{ ok: boolean }> {
+ return request('/api/v1/logout', {
method: 'POST',
- body: JSON.stringify({ realm: realm || undefined, identifier, password }),
+ body: JSON.stringify({ realm: realm || undefined }),
});
}