Files
Niklas Ye a27ff49171 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.
2026-09-26 21:37:40 +02:00

348 lines
11 KiB
Go

package api_test
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
)
type deviceStart struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"`
}
// startDevice is the terminal asking for a login.
func startDevice(t *testing.T, s *ts) deviceStart {
t.Helper()
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil)
var d deviceStart
decode(t, resp, &d)
if d.DeviceCode == "" || d.UserCode == "" {
t.Fatalf("device start returned %+v", d)
}
return d
}
// pollDevice is the terminal polling. It returns the status, and the session
// cookie the response set, if any.
func pollDevice(t *testing.T, s *ts, code string) (int, *http.Cookie, string) {
t.Helper()
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device/token", map[string]string{"device_code": code})
defer resp.Body.Close()
var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
var cookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == "terdut_session" {
cookie = c
}
}
msg, _ := body["error"].(string)
if msg == "" {
msg, _ = body["status"].(string)
}
return resp.StatusCode, cookie, msg
}
// readyToPoll lets the next poll through: the server holds a client to the
// interval it was given, which a test has no wish to wait out.
func (s *ts) readyToPoll(t *testing.T) {
t.Helper()
s.exec(t, "UPDATE device_logins SET last_polled_at = 0")
}
func decide(t *testing.T, b *browser, what, code string) int {
t.Helper()
resp := b.do(t, http.MethodPost, "/api/oidc/device/"+what, map[string]string{"user_code": code})
resp.Body.Close()
return resp.StatusCode
}
func TestDevice_FullFlow(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
d := startDevice(t, s)
if !strings.HasPrefix(d.VerificationURL, "http://terdut.test/device?code=") ||
!strings.Contains(d.VerificationURL, url.QueryEscape(d.UserCode)) {
t.Errorf("verification url %q", d.VerificationURL)
}
if len(d.UserCode) != 9 || d.UserCode[4] != '-' || d.Interval != 5 || d.ExpiresIn != 600 {
t.Errorf("start: %+v", d)
}
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || cookie != nil || msg != "pending" {
t.Fatalf("first poll: %d %v %q, want 202 pending and no cookie", status, cookie, msg)
}
// The person signs in through the provider in some browser and approves.
person := ssoBrowser(t, s)
signInSSO(t, idp, person, alice)
if got := decide(t, person, "approve", d.UserCode); got != http.StatusNoContent {
t.Fatalf("approve: %d", got)
}
s.readyToPoll(t)
status, cookie, _ := pollDevice(t, s, d.DeviceCode)
if status != http.StatusOK || cookie == nil {
t.Fatalf("poll after approval: %d, cookie %v", status, cookie)
}
// The cookie is a working session for the person who approved.
term := newBrowser(t, s.URL)
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
req.AddCookie(cookie)
resp, err := term.Do(req)
if err != nil {
t.Fatal(err)
}
var me struct {
User struct {
Username string `json:"username"`
} `json:"user"`
}
decode(t, resp, &me)
if me.User.Username != "alice" {
t.Errorf("session belongs to %q, want alice", me.User.Username)
}
// Single use.
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "expired" {
t.Errorf("second redemption: %d %v %q, want 410 expired", status, cookie, msg)
}
// The session was made for an SSO user, so it carries the ceiling.
var ceiling *int64
s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling)
if ceiling == nil {
t.Error("a device session for an SSO user must carry the SSO session ceiling")
}
}
func TestDevice_PasswordUserGetsNoCeiling(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
admin := signedIn(t, s) // password sign-in as the bootstrap admin
d := startDevice(t, s)
if got := decide(t, admin, "approve", d.UserCode); got != http.StatusNoContent {
t.Fatalf("approve: %d", got)
}
s.readyToPoll(t)
if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusOK || cookie == nil {
t.Fatalf("poll: %d %v", status, cookie)
}
var ceiling *int64
s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling)
if ceiling != nil {
t.Errorf("a password user's device session has a ceiling %d, want none", *ceiling)
}
}
func TestDevice_Denied(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
person := ssoBrowser(t, s)
signInSSO(t, idp, person, alice)
d := startDevice(t, s)
if got := decide(t, person, "deny", d.UserCode); got != http.StatusNoContent {
t.Fatalf("deny: %d", got)
}
s.readyToPoll(t)
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "denied" {
t.Errorf("poll: %d %v %q, want 410 denied", status, cookie, msg)
}
}
func TestDevice_DecisionNeedsABrowserSession(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
d := startDevice(t, s)
// Nobody signed in.
if got := decide(t, newBrowser(t, s.URL), "approve", d.UserCode); got != http.StatusUnauthorized {
t.Errorf("anonymous approve: %d, want 401", got)
}
// An API key is a credential for scripts, not for approving a terminal.
resp := s.req(t, http.MethodPost, "/api/oidc/device/approve", map[string]string{"user_code": d.UserCode})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("approve with an API key: %d, want 403", resp.StatusCode)
}
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || msg != "pending" {
t.Errorf("the login must still be pending: %d %q", status, msg)
}
}
func TestDevice_ApprovalIsFinal(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
first, second := ssoBrowser(t, s), ssoBrowser(t, s)
signInSSO(t, idp, first, alice)
signInSSO(t, idp, second, idpUser{sub: "sub-mallory", username: "mallory", email: "mallory@example.com", groups: []string{"terdut-users"}})
d := startDevice(t, s)
if got := decide(t, first, "approve", d.UserCode); got != http.StatusNoContent {
t.Fatalf("approve: %d", got)
}
// A second browser cannot take the login over, nor refuse it.
for _, what := range []string{"approve", "deny"} {
if got := decide(t, second, what, d.UserCode); got != http.StatusNotFound {
t.Errorf("%s after approval: %d, want 404", what, got)
}
}
s.readyToPoll(t)
_, cookie, _ := pollDevice(t, s, d.DeviceCode)
if cookie == nil {
t.Fatal("no session")
}
var name string
s.db.QueryRow("SELECT u.username FROM sessions ss JOIN users u ON u.id = ss.user_id ORDER BY ss.id DESC LIMIT 1").Scan(&name)
if name != "alice" {
t.Errorf("session for %q, want alice", name)
}
}
func TestDevice_CodeIsForgivingAboutHowItWasTyped(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
person := ssoBrowser(t, s)
signInSSO(t, idp, person, alice)
d := startDevice(t, s)
typed := strings.ToLower(strings.ReplaceAll(d.UserCode, "-", " "))
if got := decide(t, person, "approve", typed); got != http.StatusNoContent {
t.Errorf("approve %q: %d, want 204", typed, got)
}
if got := decide(t, person, "approve", "nonsense"); got != http.StatusBadRequest {
t.Errorf("approve nonsense: %d, want 400", got)
}
}
func TestDevice_ExpiredAndUnknown(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
person := ssoBrowser(t, s)
signInSSO(t, idp, person, alice)
d := startDevice(t, s)
s.exec(t, "UPDATE device_logins SET expires_at = 1")
if got := decide(t, person, "approve", d.UserCode); got != http.StatusNotFound {
t.Errorf("approve expired: %d, want 404", got)
}
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || msg != "expired" {
t.Errorf("poll expired: %d %q, want 410 expired", status, msg)
}
if status, _, msg := pollDevice(t, s, "not-a-device-code"); status != http.StatusGone || msg != "expired" {
t.Errorf("poll unknown: %d %q, want 410 expired", status, msg)
}
}
func TestDevice_PollingTooFastIsRefused(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
d := startDevice(t, s)
if status, _, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted {
t.Fatalf("first poll: %d", status)
}
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusTooManyRequests || msg != "slow_down" {
t.Errorf("immediate second poll: %d %q, want 429 slow_down", status, msg)
}
}
func TestDevice_DisabledUserGetsNoSession(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
person := ssoBrowser(t, s)
signInSSO(t, idp, person, alice)
d := startDevice(t, s)
decide(t, person, "approve", d.UserCode)
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'")
s.readyToPoll(t)
var before int
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&before)
if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil {
t.Errorf("poll: %d %v, want 410 and no cookie", status, cookie)
}
var after int
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&after)
if after != before {
t.Error("a session was created for a disabled user")
}
}
func TestDevice_OnlyExistsWithSSOConfigured(t *testing.T) {
s := newTS(t) // no SSO
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("device start with SSO off: %d, want 404", resp.StatusCode)
}
idp := newFakeIdP(t)
for _, c := range []struct {
name string
s *ts
want bool
}{{"off", s, false}, {"on", newSSOTS(t, idp), true}} {
var cfg struct {
DeviceLogin bool `json:"device_login"`
}
decode(t, newBrowser(t, c.s.URL).do(t, http.MethodGet, "/api/auth/config", nil), &cfg)
if cfg.DeviceLogin != c.want {
t.Errorf("auth config device_login with SSO %s: %v, want %v", c.name, cfg.DeviceLogin, c.want)
}
}
}
func TestDevice_StartIsRateLimited(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
b := newBrowser(t, s.URL)
var last int
for range 32 {
resp := b.do(t, http.MethodPost, "/api/oidc/device", nil)
resp.Body.Close()
last = resp.StatusCode
}
if last != http.StatusTooManyRequests {
t.Errorf("32nd start: %d, want 429", last)
}
}
// After signing in the browser is sent on to where the person was going, which
// is how somebody without a session gets from /device?code=... through the
// provider and back to it. Only paths on this server are honoured.
func TestSSO_NextIsHonouredOnlyForPathsOnThisServer(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
for _, c := range []struct{ next, want string }{
{"/device?code=BCDF-GHJK", "/device?code=BCDF-GHJK"},
{"/team/members", "/team/members"},
{"", "/"},
{"//evil.example/x", "/"},
{"/\\evil.example", "/"},
{"https://evil.example/", "/"},
{"evil.example", "/"},
{"/api/users", "/"},
{"/ok\r\nSet-Cookie: x=y", "/"},
{"/" + strings.Repeat("a", 600), "/"},
} {
b := ssoBrowser(t, s)
resp := b.do(t, http.MethodGet, "/api/oidc/login?next="+url.QueryEscape(c.next), nil)
resp.Body.Close()
loc, _ := url.Parse(resp.Header.Get("Location"))
q := loc.Query()
got := callback(t, b, idp.issueCode(alice, q.Get("nonce"), q.Get("code_challenge")), q.Get("state"))
if got != c.want {
t.Errorf("next %q: redirected to %q, want %q", c.next, got, c.want)
}
}
}