-- Single sign-on through an OpenID Connect provider (Authentik, and anything -- else that speaks OIDC). -- -- Four things change, and none of them touches a password user: every new column -- has a default that says "this is how it has always worked". -- -- 1. user_identities says which provider account a user is. It is keyed on -- (issuer, subject), never on email or username: those are mutable at the -- provider, and a recycled address must not inherit somebody's account. A -- user can have several identities (a second provider later), and none at all -- (a local, password-only user), which is why this is a table and not two -- columns on users. -- -- 2. team_members.source and users.admin_source record who granted a role. 'oidc' -- rows are owned by the group sync: it adds them when a group grants access -- and removes them when it stops, and nothing else may edit them. 'manual' rows -- are everything that existed before this migration, and are never touched by -- the sync. Without the marker the sync could not tell a membership it created -- from one an owner added by hand, and would have to either leave stale access -- behind or delete people it had no business deleting. -- -- 3. sessions.max_expires_at is a hard ceiling on a session's life. Ordinary -- sessions slide for as long as they are used; a session made by an SSO login -- must not, because the login is the only moment the groups are re-read. -- Capping the session is what makes "removed from the group in the provider" -- take effect within a bounded time. NULL means no ceiling. -- -- 4. oidc_logins holds a login that has been started and not yet finished: the -- state, nonce and PKCE verifier the callback must see again. A row rather -- than a signed cookie, so it survives a restart and needs no signing key. -- Only the hash of the state is stored, like every other token here; the -- nonce and verifier are useless without the state that names the row. CREATE TABLE user_identities ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, issuer TEXT NOT NULL, subject TEXT NOT NULL, created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint, last_login_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint, UNIQUE (issuer, subject) ); CREATE INDEX user_identities_user_idx ON user_identities (user_id); ALTER TABLE team_members ADD COLUMN source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc')); ALTER TABLE users ADD COLUMN admin_source TEXT NOT NULL DEFAULT 'manual' CHECK (admin_source IN ('manual', 'oidc')); ALTER TABLE sessions ADD COLUMN max_expires_at BIGINT; CREATE TABLE oidc_logins ( state_hash TEXT PRIMARY KEY, nonce TEXT NOT NULL, pkce_verifier TEXT NOT NULL, expires_at BIGINT NOT NULL ); CREATE INDEX oidc_logins_expires_idx ON oidc_logins (expires_at);