Sign in through an OpenID Connect provider, and from a terminal

terdut can now sign people in through any OIDC provider (written against
Authentik), and let groups at the provider decide who may sign in, which
teams they belong to and whether they administer the install. Password
login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off,
and is refused at startup unless SSO is configured. With no TERDUT_OIDC_*
setting nothing changes, so every existing install behaves as before.

Identity is (issuer, subject), never email or username: those are mutable
at the provider and a recycled address must not inherit an account. An
existing user is linked by email only when the provider marks it verified,
or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs.

Group grants are marked source='oidc' on team_members and users, and the
sync changes only those rows. Hand-made memberships and administrators
are left alone, and the sync bypasses the last-owner and last-admin guards
because the provider is the source of truth for what it grants. Editing
managed access by hand is refused with 409, since the next sign-in would
undo it. The web UI badges it as SSO and disables the controls.

Groups are read only at sign-in, so an SSO session carries a hard ceiling
(sessions.max_expires_at, 12h by default) that sliding never extends.
There is no refresh token, which means API keys of somebody removed at the
provider stay valid until an administrator disables the user. That is
accepted and documented, not fixed.

A client with no browser, the TUI over SSH, signs in with a device code
run by terdut itself (POST /api/oidc/device and /device/token), so the
terminal never talks to the provider and ends up with the ordinary
terdut_session cookie. Only a browser session can approve a code; an API
key cannot. /device?code= sends a signed-out visitor through sign-in and
back, which is what oidc_logins.next is for.

oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile
builds on 1.25.

Migrations 011 and 012 add tables and defaulted columns only.
This commit is contained in:
Niklas Ye
2026-09-26 21:37:40 +02:00
parent c5be55dcbc
commit a27ff49171
39 changed files with 3293 additions and 62 deletions
+88
View File
@@ -0,0 +1,88 @@
// Approving a sign-in that a terminal started, at /device?code=XXXX-XXXX.
//
// The terminal (the TUI) shows a code and a link to this page. Whoever opens it
// is already signed in — by the provider or by password, whichever the login
// page offered — and is asked to approve. Approving hands that terminal a
// session for *this* account, so the page names the account and the code, and
// tells anybody who did not start this to refuse.
import * as api from './api.js';
import { h, clear } from './ui.js';
import { state } from './state.js';
import { navigate } from './app.js';
const view = () => document.getElementById('view-device');
// What has been decided for the code on screen, so a re-render does not offer
// to approve it a second time.
let outcome = null; // { code, approved }
export function show() {
render();
}
function render() {
const code = new URLSearchParams(location.search).get('code') || '';
if (!code) return clear(view(), enterCode());
if (outcome && outcome.code === code) return clear(view(), decided(outcome.approved));
return clear(view(), confirmCard(code));
}
// Reached without a code, for somebody who typed the address by hand.
function enterCode() {
const input = h('input', {
name: 'code', autocomplete: 'off', autocapitalize: 'characters', spellcheck: 'false',
placeholder: 'XXXX-XXXX', required: true,
});
const form = h('form', { class: 'card device-card' },
h('h2', { text: 'Sign in a terminal' }),
h('p', { class: 'muted', text: 'Enter the code the terminal is showing.' }),
h('label', {}, h('span', { text: 'Code' }), input),
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Continue' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
navigate(`/device?code=${encodeURIComponent(input.value.trim())}`);
});
return form;
}
function confirmCard(code) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const approve = h('button', { class: 'btn btn-primary', type: 'button', text: 'Approve' });
const refuse = h('button', { class: 'btn', type: 'button', text: 'Refuse' });
const decide = (approved) => async () => {
err.hidden = true;
approve.disabled = refuse.disabled = true;
try {
await (approved ? api.approveDevice(code) : api.denyDevice(code));
outcome = { code, approved };
render();
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
approve.disabled = refuse.disabled = false;
}
};
approve.addEventListener('click', decide(true));
refuse.addEventListener('click', decide(false));
return h('div', { class: 'card device-card' },
h('h2', { text: 'Sign in a terminal?' }),
h('p', {}, 'A terminal is asking to sign in as ', h('strong', { text: state.me.user.username }),
'. Check that this code matches the one it is showing:'),
h('p', { class: 'device-code', text: code }),
h('p', { class: 'muted small',
text: 'Only approve a sign-in you started yourself. Whoever is approved here acts as you.' }),
err,
h('div', { class: 'row-actions' }, approve, refuse));
}
function decided(approved) {
return h('div', { class: 'card device-card' },
h('h2', { text: approved ? 'Approved' : 'Refused' }),
h('p', { class: 'muted', text: approved
? 'You can go back to your terminal. It signs in within a few seconds.'
: 'That terminal will not be signed in.' }),
h('a', { class: 'btn', href: '/', text: 'Go to the queue' }));
}