auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

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:
Mserver/migrations/sqlite/realm/001_init.sql | 56++++++++++++++++++++++++++++++--------------------------
Mserver/migrations/sqlite/system/001_init.sql | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mserver/package-lock.json | 14++++++++++++++
Mserver/package.json | 1+
Mserver/scripts/seed-demo.js | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Aserver/src/controller/login/logout.js | 23+++++++++++++++++++++++
Mserver/src/controller/login/password.js | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mserver/src/controller/login/resolve.js | 5++---
Aserver/src/controller/session/show.js | 41+++++++++++++++++++++++++++++++++++++++++
Mserver/src/lib/db/migrate.js | 9+++++----
Mserver/src/lib/login.js | 59++++++++++++++++++++++++++++++++++++++++++++++++++---------
Aserver/src/lib/password.js | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/src/lib/policy.js | 37+++++++++++++++++++++++++++++++++++++
Mserver/src/lib/realm.js | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Aserver/src/lib/session.js | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/src/lib/users.js | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/test/http.test.js | 301+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/test/password.test.js | 28++++++++++++++++++++++++++++
Aserver/test/policy.test.js | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver/test/realm.test.js | 56++++++++++++++++++++++++++++++++++++++++++++++++--------
Aserver/test/session.test.js | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver/web/src/assets/global.css | 16++++++++++++++++
Mserver/web/src/main.ts | 7+++++++
Aserver/web/src/screen/admin.tsx | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver/web/src/screen/login.tsx | 22+++++++++++++++++++---
Mserver/web/src/util/api.ts | 39+++++++++++++++++++++++++++++++++++----
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 }), }); }