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
+9 -1
View File
@@ -15,6 +15,7 @@ import (
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/config"
)
// ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so
@@ -50,9 +51,16 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
if len(notify) > 0 {
cfg = notify[0]
}
return newTSWith(t, deadman, cfg, testConfig())
}
// newTSWith is newDeadmanTS with the server's own configuration supplied, for
// tests of behaviour that config switches on, such as single sign-on.
func newTSWith(t *testing.T, deadman api.DeadmanConfig, cfg api.NotifyConfig, conf config.Config) *ts {
t.Helper()
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig()))
srv := httptest.NewServer(api.NewRouter(database, cfg, conf))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
+20 -4
View File
@@ -143,15 +143,31 @@ func hashPassword(pw string) (string, error) {
// sign-up: somebody who has just chosen a password is signed in, rather than
// being sent to a form to type the same credential again.
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
return startSessionCapped(w, r, db, userID, publicURL, 0)
}
// startSessionCapped is startSession with a hard ceiling on the session's life,
// which sliding never extends. maxAge zero means no ceiling. A single sign-on
// login uses it: the login is the only moment the provider's groups are read, so
// a session that could outlive it indefinitely would keep access the provider
// has since taken away.
func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string, maxAge time.Duration) error {
raw, tokenHash, err := randomToken()
if err != nil {
return err
}
now := time.Now()
life := sessionTTL
var ceiling *int64
if maxAge > 0 {
c := now.Add(maxAge).Unix()
ceiling = &c
life = min(life, maxAge)
}
if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, max_expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(life).Unix(), ceiling, r.UserAgent()); err != nil {
return err
}
@@ -159,7 +175,7 @@ func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
MaxAge: int(life.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
+282
View File
@@ -0,0 +1,282 @@
package api
import (
"crypto/rand"
"database/sql"
"errors"
"log"
"math/big"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// The device login flow lets a client that cannot open a browser sign in: it
// shows a code, the person approves it in a browser they are signed in to, and
// the client is handed an ordinary session. See migration 012.
const (
// deviceTTL is how long a person has to get from the terminal's prompt to an
// approval.
deviceTTL = 10 * time.Minute
// deviceInterval is how often the client is told to poll. The server holds it
// to that, with a second of slack for clocks and scheduling.
deviceInterval = 5 * time.Second
// deviceStartMaxPerAddr bounds unauthenticated device logins started per
// address, since each writes a row.
deviceStartMaxPerAddr = 30
// userCodeAlphabet has no vowels, so a code cannot spell a word, and none of
// the characters that read alike (0/O, 1/I/L).
userCodeAlphabet = "BCDFGHJKMNPQRSTVWXZ23456789"
userCodeLen = 8
)
// newUserCode returns a code for a person to read, as XXXX-XXXX.
func newUserCode() (string, error) {
max := big.NewInt(int64(len(userCodeAlphabet)))
b := make([]byte, userCodeLen)
for i := range b {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
b[i] = userCodeAlphabet[n.Int64()]
}
return string(b[:4]) + "-" + string(b[4:]), nil
}
// normalizeUserCode reduces whatever a person typed or pasted to the stored
// form, so "bcdf ghjk" and "BCDF-GHJK" name the same login. It returns "" for
// anything that cannot be a code.
func normalizeUserCode(s string) string {
var b strings.Builder
for _, r := range strings.ToUpper(s) {
if strings.ContainsRune(userCodeAlphabet, r) {
b.WriteRune(r)
}
}
code := b.String()
if len(code) != userCodeLen {
return ""
}
return code[:4] + "-" + code[4:]
}
// handleDeviceStart begins a device login: it returns the device code the
// client polls with, and the user code and URL the person is shown.
func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
addrKey := "device:" + clientAddr(r)
if limiter.blocked(addrKey, deviceStartMaxPerAddr) {
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
return
}
limiter.fail(addrKey)
deviceCode, deviceHash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
now := time.Now()
db.ExecContext(r.Context(), "DELETE FROM device_logins WHERE expires_at < $1", now.Unix())
// A collision on the user code is one in 27^8; retrying a few times makes
// it a non-event rather than a 500.
var userCode string
for range 5 {
userCode, err = newUserCode()
if err != nil {
break
}
_, err = db.ExecContext(r.Context(), `
INSERT INTO device_logins (device_hash, user_code, expires_at) VALUES ($1, $2, $3)`,
deviceHash, userCode, now.Add(deviceTTL).Unix())
if err == nil || !isUniqueViolation(err) {
break
}
}
if err != nil {
log.Printf("device login: start: %v", err)
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, map[string]any{
"device_code": deviceCode,
"user_code": userCode,
// The code is in the URL so nobody has to type it; it is shown anyway,
// for the person to check against the terminal before approving.
"verification_url": strings.TrimRight(publicURL, "/") + "/device?code=" + url.QueryEscape(userCode),
"interval": int(deviceInterval.Seconds()),
"expires_in": int(deviceTTL.Seconds()),
})
}
}
// handleDeviceDecision approves or denies a pending device login on behalf of
// the signed-in caller.
//
// It takes a session, not an API key. Approving hands a terminal the caller's
// identity, and the approval must come from a browser the person is looking at:
// the page shows the code and asks. A script with a key has no business
// approving one, and the check keeps it from being a way to mint sessions out of
// keys.
func handleDeviceDecision(db *sql.DB, approve bool) http.HandlerFunc {
status := "denied"
if approve {
status = "approved"
}
return func(w http.ResponseWriter, r *http.Request) {
if _, viaSession := sessionFromContext(r.Context()); !viaSession {
respond(w, http.StatusForbidden, errResp("sign in with the web UI to approve a device"))
return
}
var req struct {
UserCode string `json:"user_code"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
code := normalizeUserCode(req.UserCode)
if code == "" {
respond(w, http.StatusBadRequest, errResp("that is not a sign-in code"))
return
}
caller, _ := userFromContext(r.Context())
// Only a pending login can be decided, and only once: an approval cannot
// be overwritten, so a second browser cannot take a login over.
res, err := db.ExecContext(r.Context(), `
UPDATE device_logins SET status = $1, user_id = $2
WHERE user_code = $3 AND status = 'pending' AND expires_at > $4`,
status, caller.ID, code, time.Now().Unix())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("that sign-in code is unknown, expired or already used"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleDeviceToken is what the client polls. Pending answers 202; an approval
// answers 200 with the session cookie, once; anything else is 410.
func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) http.HandlerFunc {
gone := func(w http.ResponseWriter, why string) {
respond(w, http.StatusGone, map[string]string{"error": why})
}
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
DeviceCode string `json:"device_code"`
}
if err := decodeJSON(r, &req); err != nil || req.DeviceCode == "" {
respond(w, http.StatusBadRequest, errResp("device_code is required"))
return
}
hash := hashToken(req.DeviceCode)
now := time.Now()
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback() //nolint:errcheck
var status string
var userID sql.NullInt64
var expires, lastPolled int64
err = tx.QueryRowContext(r.Context(), `
SELECT status, user_id, expires_at, last_polled_at FROM device_logins
WHERE device_hash = $1 FOR UPDATE`, hash).Scan(&status, &userID, &expires, &lastPolled)
if errors.Is(err, sql.ErrNoRows) || (err == nil && expires <= now.Unix()) {
gone(w, "expired")
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
switch status {
case "denied":
tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash)
tx.Commit() //nolint:errcheck
gone(w, "denied")
return
case "pending":
// Held to the interval it was given, less a second of slack.
if now.Unix()-lastPolled < int64(deviceInterval.Seconds())-1 {
w.Header().Set("Retry-After", strconv.Itoa(int(deviceInterval.Seconds())))
respond(w, http.StatusTooManyRequests, map[string]string{"error": "slow_down"})
return
}
if _, err := tx.ExecContext(r.Context(),
"UPDATE device_logins SET last_polled_at = $1 WHERE device_hash = $2", now.Unix(), hash); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusAccepted, map[string]string{"status": "pending"})
return
}
// Approved. Single use: the row goes before the session is made, so two
// racing polls cannot both be given one.
if _, err := tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
var disabled, sso bool
if err := tx.QueryRowContext(r.Context(), `
SELECT disabled_at IS NOT NULL,
EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1)
FROM users WHERE id = $1`, userID.Int64).Scan(&disabled, &sso); err != nil {
gone(w, "denied")
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if disabled {
gone(w, "denied")
return
}
// A session for somebody who signs in through the provider carries the
// same ceiling as their browser's would, so the terminal is not a way
// round it. Password users have none.
var maxAge time.Duration
if sso {
maxAge = ssoMaxAge
}
if err := startSessionCapped(w, r, db, userID.Int64, publicURL, maxAge); err != nil {
log.Printf("device login: start session: %v", err)
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
user, err := fetchUser(r.Context(), db, userID.Int64)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, meResponse{User: user, HasPassword: false})
}
}
+347
View File
@@ -0,0 +1,347 @@
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)
}
}
}
+7 -2
View File
@@ -132,8 +132,13 @@ func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, user
}
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
db.ExecContext(ctx,
"UPDATE sessions SET last_seen_at = $1, expires_at = $2 WHERE id = $3",
// LEAST keeps a capped session (a single sign-on login) from sliding
// past its ceiling; with no ceiling COALESCE makes it the plain slide.
db.ExecContext(ctx, `
UPDATE sessions
SET last_seen_at = $1,
expires_at = LEAST($2::bigint, COALESCE(max_expires_at, $2::bigint))
WHERE id = $3`,
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
}
return sessionID, userID, true
+458
View File
@@ -0,0 +1,458 @@
package api
import (
"context"
"database/sql"
"errors"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
)
const (
// oidcStateCookie ties an in-flight login to the browser that started it.
// Without it anybody could start a login, and send the callback URL that
// results to somebody else, who would be signed in as the attacker.
oidcStateCookie = "terdut_oidc_state"
// oidcLoginTTL is how long a login may take between the redirect to the
// provider and the callback, which includes the person typing a password
// and a second factor.
oidcLoginTTL = 10 * time.Minute
// oidcStartMaxPerAddr bounds unauthenticated logins started per address.
// Each writes a row, so an unbounded endpoint is a way to grow the table.
oidcStartMaxPerAddr = 30
)
// ssoError is a sign-in refusal the person can be told about. Its value is the
// code the web UI is sent back with, as ?sso_error=<code>; the detail stays in
// the server log, since it can name accounts.
type ssoError string
func (e ssoError) Error() string { return "sso: " + string(e) }
const (
ssoDenied ssoError = "denied" // the provider reported an error, or the person declined
ssoExpired ssoError = "expired" // unknown, used or expired state; start again
ssoFailed ssoError = "failed" // the token exchange or its verification failed
ssoUnavailable ssoError = "unavailable" // the provider could not be reached
ssoNotAllowed ssoError = "not_allowed" // authenticated, but in none of the allowed groups
ssoNoEmail ssoError = "no_email" // the provider sent no email address
ssoEmailConflict ssoError = "email_conflict" // a local account has this email and cannot be linked
ssoDisabled ssoError = "disabled" // the linked account is disabled
)
// handleAuthConfig says how this server can be signed in to, so the login form
// and the TUI can offer the right choices before anybody types anything. It is
// unauthenticated by necessity, and reveals nothing beyond what the login page
// shows anyway.
func handleAuthConfig(cfg config.Config) http.HandlerFunc {
type oidcInfo struct {
Enabled bool `json:"enabled"`
Name string `json:"name,omitempty"`
}
type response struct {
PasswordLogin bool `json:"password_login"`
OIDC oidcInfo `json:"oidc"`
// DeviceLogin is whether a client that cannot open a browser (the TUI)
// can sign in by showing a code, through /api/oidc/device.
DeviceLogin bool `json:"device_login"`
}
return func(w http.ResponseWriter, r *http.Request) {
resp := response{PasswordLogin: !cfg.DisablePasswordLogin}
if cfg.OIDC.Enabled() {
resp.OIDC = oidcInfo{Enabled: true, Name: cfg.OIDC.Name}
resp.DeviceLogin = true
}
respond(w, http.StatusOK, resp)
}
}
// passwordLoginOnly refuses a route when password login is switched off.
func passwordLoginOnly(enabled bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if enabled {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusForbidden, errResp("password login is disabled on this server"))
})
}
}
// ssoRedirect sends the browser back to the web UI with the reason a sign-in
// failed. It is a redirect and not a JSON error because the browser arrived
// here by navigating from the provider: there is no page script to read one.
func ssoRedirect(w http.ResponseWriter, r *http.Request, code ssoError) {
http.Redirect(w, r, "/?sso_error="+url.QueryEscape(string(code)), http.StatusFound)
}
// handleOIDCLogin starts a sign-in: it records the state, nonce and PKCE
// verifier the callback will need and sends the browser to the provider.
func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
addrKey := "oidc:" + clientAddr(r)
if limiter.blocked(addrKey, oidcStartMaxPerAddr) {
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
return
}
limiter.fail(addrKey)
state, stateHash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
nonce, _, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
verifier := oidc.NewVerifier()
next := safeNext(r.URL.Query().Get("next"))
// Abandoned logins are swept here rather than by the sweeper: this is
// the only place they are made, so the table cannot outgrow its writers.
now := time.Now()
db.ExecContext(r.Context(), "DELETE FROM oidc_logins WHERE expires_at < $1", now.Unix())
if _, err := db.ExecContext(r.Context(), `
INSERT INTO oidc_logins (state_hash, nonce, pkce_verifier, next, expires_at)
VALUES ($1, $2, $3, $4, $5)`,
stateHash, nonce, verifier, next, now.Add(oidcLoginTTL).Unix()); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
authURL, err := prov.AuthURL(r.Context(), state, nonce, verifier)
if err != nil {
log.Printf("oidc: start login: %v", err)
ssoRedirect(w, r, ssoUnavailable)
return
}
http.SetCookie(w, &http.Cookie{
Name: oidcStateCookie,
Value: state,
Path: "/api/oidc",
MaxAge: int(oidcLoginTTL.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
// Lax, not Strict: the callback is a top-level navigation from the
// provider's site, which Strict would not send the cookie on.
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, authURL, http.StatusFound)
}
}
// handleOIDCCallback finishes a sign-in: it verifies the provider's answer,
// finds or creates the user, applies their groups and starts a session.
func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.HandlerFunc {
cfg := prov.Config()
return func(w http.ResponseWriter, r *http.Request) {
// The state cookie has done its job once the callback arrives, whatever
// the outcome.
http.SetCookie(w, &http.Cookie{
Name: oidcStateCookie, Value: "", Path: "/api/oidc", MaxAge: -1,
HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode,
})
q := r.URL.Query()
if e := q.Get("error"); e != "" {
log.Printf("oidc: provider returned error %q: %s", e, q.Get("error_description"))
ssoRedirect(w, r, ssoDenied)
return
}
state := q.Get("state")
cookie, err := r.Cookie(oidcStateCookie)
if state == "" || q.Get("code") == "" || err != nil || cookie.Value != state {
ssoRedirect(w, r, ssoExpired)
return
}
// DELETE ... RETURNING makes the state single-use: a replayed callback
// finds nothing.
var nonce, verifier, next string
err = db.QueryRowContext(r.Context(), `
DELETE FROM oidc_logins WHERE state_hash = $1 AND expires_at > $2
RETURNING nonce, pkce_verifier, next`,
hashToken(state), time.Now().Unix()).Scan(&nonce, &verifier, &next)
if errors.Is(err, sql.ErrNoRows) {
ssoRedirect(w, r, ssoExpired)
return
}
if err != nil {
log.Printf("oidc: load login state: %v", err)
ssoRedirect(w, r, ssoFailed)
return
}
identity, err := prov.Exchange(r.Context(), q.Get("code"), verifier, nonce)
if err != nil {
log.Printf("oidc: %v", err)
ssoRedirect(w, r, ssoFailed)
return
}
grants := oidc.ComputeGrants(cfg, identity.Groups)
if !grants.Admitted {
log.Printf("oidc: %q (%s) is in none of the allowed groups", identity.Username, identity.Subject)
ssoRedirect(w, r, ssoNotAllowed)
return
}
userID, err := signInSSO(r.Context(), db, cfg, identity, grants)
if err != nil {
var se ssoError
if errors.As(err, &se) {
log.Printf("oidc: refused %q (%s): %v", identity.Username, identity.Subject, se)
ssoRedirect(w, r, se)
return
}
log.Printf("oidc: sign in %q: %v", identity.Username, err)
ssoRedirect(w, r, ssoFailed)
return
}
if err := startSessionCapped(w, r, db, userID, publicURL, cfg.SessionMaxAge); err != nil {
log.Printf("oidc: start session: %v", err)
ssoRedirect(w, r, ssoFailed)
return
}
http.Redirect(w, r, safeNext(next), http.StatusFound)
}
}
// safeNext returns where to send the browser after a sign-in: the path asked
// for, if it is one on this server, and the front page otherwise. It is the
// only thing standing between a login link and an open redirect, so it accepts
// a single leading slash and nothing that a browser could read as another host
// ("//evil.example", "/\evil.example"), and never an API path, which would
// land somebody on raw JSON.
func safeNext(next string) string {
switch {
case next == "", len(next) > 512,
!strings.HasPrefix(next, "/"),
strings.HasPrefix(next, "//"),
strings.HasPrefix(next, "/api/"),
strings.ContainsAny(next, "\\\r\n"):
return "/"
}
return next
}
// signInSSO resolves the identity to a user and applies its grants, in one
// transaction: a login that fails half way must not leave memberships changed.
func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identity, g oidc.Grants) (int64, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback() //nolint:errcheck
userID, err := resolveSSOUser(ctx, tx, cfg, id)
if err != nil {
return 0, err
}
var disabled bool
if err := tx.QueryRowContext(ctx,
"SELECT disabled_at IS NOT NULL FROM users WHERE id = $1", userID).Scan(&disabled); err != nil {
return 0, err
}
if disabled {
return 0, ssoDisabled
}
if err := syncGrants(ctx, tx, userID, g); err != nil {
return 0, err
}
return userID, tx.Commit()
}
// resolveSSOUser finds the user an identity belongs to, linking or creating one
// when this is its first sign-in.
//
// The order matters. The (issuer, subject) pair is the identity; email is only
// a way to recognise an existing local account the first time. Once linked, a
// changed email at the provider must not move the account to somebody else.
func resolveSSOUser(ctx context.Context, tx *sql.Tx, cfg config.OIDC, id *oidc.Identity) (int64, error) {
now := time.Now().Unix()
var userID int64
err := tx.QueryRowContext(ctx,
"SELECT user_id FROM user_identities WHERE issuer = $1 AND subject = $2",
id.Issuer, id.Subject).Scan(&userID)
if err == nil {
if _, err := tx.ExecContext(ctx,
"UPDATE user_identities SET last_login_at = $1 WHERE issuer = $2 AND subject = $3",
now, id.Issuer, id.Subject); err != nil {
return 0, err
}
return userID, refreshProfile(ctx, tx, userID, id)
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
// First sign-in with this identity.
if id.Email == "" {
return 0, ssoNoEmail
}
err = tx.QueryRowContext(ctx,
"SELECT id FROM users WHERE lower(email) = lower($1)", id.Email).Scan(&userID)
switch {
case err == nil:
if !id.EmailVerified && !cfg.TrustEmail {
return 0, ssoEmailConflict
}
// A local account that already has an identity from this issuer is a
// different person at the provider using a recycled address. Linking
// them would hand one person's account to another.
var linked bool
if err := tx.QueryRowContext(ctx,
"SELECT EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1 AND issuer = $2)",
userID, id.Issuer).Scan(&linked); err != nil {
return 0, err
}
if linked {
return 0, ssoEmailConflict
}
case errors.Is(err, sql.ErrNoRows):
userID, err = createSSOUser(ctx, tx, id)
if err != nil {
return 0, err
}
default:
return 0, err
}
if _, err := tx.ExecContext(ctx,
"INSERT INTO user_identities (user_id, issuer, subject) VALUES ($1, $2, $3)",
userID, id.Issuer, id.Subject); err != nil {
return 0, err
}
return userID, nil
}
// createSSOUser inserts a user with no password. The username is the provider's,
// made unique with a numeric suffix when somebody local already has it.
func createSSOUser(ctx context.Context, tx *sql.Tx, id *oidc.Identity) (int64, error) {
base := strings.TrimSpace(id.Username)
if base == "" {
base, _, _ = strings.Cut(id.Email, "@")
}
if base == "" {
base = "user"
}
for n := 1; n <= 100; n++ {
name := base
if n > 1 {
name = base + "-" + strconv.Itoa(n)
}
var userID int64
err := tx.QueryRowContext(ctx, `
INSERT INTO users (username, email) VALUES ($1, $2)
ON CONFLICT (username) DO NOTHING RETURNING id`,
name, id.Email).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
continue // taken; try the next suffix
}
return userID, err
}
return 0, errors.New("no free username for " + base)
}
// refreshProfile brings a linked user's username and email in line with the
// provider. Each update is skipped, not failed, when another user already holds
// the value: both columns are unique, and a sign-in must not break over a name.
func refreshProfile(ctx context.Context, tx *sql.Tx, userID int64, id *oidc.Identity) error {
if id.Username != "" {
if _, err := tx.ExecContext(ctx, `
UPDATE users SET username = $1
WHERE id = $2 AND username <> $1
AND NOT EXISTS (SELECT 1 FROM users WHERE username = $1)`,
id.Username, userID); err != nil {
return err
}
}
if id.Email != "" {
if _, err := tx.ExecContext(ctx, `
UPDATE users SET email = $1
WHERE id = $2 AND email <> $1
AND NOT EXISTS (SELECT 1 FROM users WHERE lower(email) = lower($1))`,
id.Email, userID); err != nil {
return err
}
}
return nil
}
// syncGrants makes the user's OIDC-sourced access match what their groups grant
// now, and touches nothing else.
//
// Rows the sync owns are marked source 'oidc'. It adds them, changes their role
// and removes them. The last-owner and last-administrator guards do not apply:
// they exist to stop a person's mistake, and the provider is the source of truth
// for the access it grants, so a team or an install can be left without an
// SSO-granted owner. Administrators can always repair a team, and the bootstrap
// administrator is a manual one. Rows added by hand are 'manual', and the sync
// only ever raises them (turning them into 'oidc' rows), never lowers or removes
// them.
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) error {
// Administrator. A manual administrator stays one whatever the groups say.
if g.Admin {
if _, err := tx.ExecContext(ctx,
"UPDATE users SET is_admin = true, admin_source = 'oidc' WHERE id = $1 AND NOT is_admin",
userID); err != nil {
return err
}
} else if _, err := tx.ExecContext(ctx,
"UPDATE users SET is_admin = false, admin_source = 'manual' WHERE id = $1 AND admin_source = 'oidc'",
userID); err != nil {
return err
}
// Teams. The result of the loop is the set of teams the groups grant.
granted := make([]int64, 0, len(g.Teams))
for name, role := range g.Teams {
if _, err := tx.ExecContext(ctx,
"INSERT INTO teams (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", name); err != nil {
return err
}
var teamID int64
if err := tx.QueryRowContext(ctx, "SELECT id FROM teams WHERE name = $1", name).Scan(&teamID); err != nil {
return err
}
granted = append(granted, teamID)
// A row the sync owns follows the groups in both directions. One added by
// hand is only raised: a member the owner made an owner by hand is not
// demoted because the mapping says member.
if _, err := tx.ExecContext(ctx, `
INSERT INTO team_members (team_id, user_id, role, source)
VALUES ($1, $2, $3, 'oidc')
ON CONFLICT (team_id, user_id) DO UPDATE
SET role = excluded.role, source = 'oidc'
WHERE team_members.source = 'oidc'
OR (excluded.role = $4 AND team_members.role = $5)`,
teamID, userID, role, models.RoleOwner, models.RoleMember); err != nil {
return err
}
}
// Access the groups no longer grant. granted is never nil, or the ALL
// comparison would be against NULL and delete nothing.
_, err := tx.ExecContext(ctx,
"DELETE FROM team_members WHERE user_id = $1 AND source = 'oidc' AND team_id <> ALL($2)",
userID, granted)
return err
}
+775
View File
@@ -0,0 +1,775 @@
package api_test
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/config"
)
// fakeIdP is just enough of an OpenID Connect provider for terdut to sign
// somebody in against: discovery, a key set and a token endpoint that checks the
// PKCE verifier. There is no authorize endpoint; the tests read the URL terdut
// redirects to and play the part of the browser and the person themselves.
type fakeIdP struct {
*httptest.Server
key *rsa.PrivateKey
mu sync.Mutex
codes map[string]pendingCode
}
type pendingCode struct {
claims map[string]any
challenge string
}
const (
idpClientID = "terdut"
idpClientSecret = "s3cret"
)
func newFakeIdP(t *testing.T) *fakeIdP {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
f := &fakeIdP{key: key, codes: map[string]pendingCode{}}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"issuer": f.URL,
"authorization_endpoint": f.URL + "/authorize",
"token_endpoint": f.URL + "/token",
"jwks_uri": f.URL + "/jwks",
"id_token_signing_alg_values_supported": []string{"RS256"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
b64 := base64.RawURLEncoding.EncodeToString
json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{
"kty": "RSA", "kid": "k1", "use": "sig", "alg": "RS256",
"n": b64(key.N.Bytes()),
"e": b64(big.NewInt(int64(key.E)).Bytes()),
}}})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
user, pass, basic := r.BasicAuth()
if !basic {
user, pass = r.PostForm.Get("client_id"), r.PostForm.Get("client_secret")
}
if user != idpClientID || pass != idpClientSecret {
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
return
}
f.mu.Lock()
p, ok := f.codes[r.PostForm.Get("code")]
delete(f.codes, r.PostForm.Get("code")) // single use, like a real provider
f.mu.Unlock()
sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier")))
if !ok || base64.RawURLEncoding.EncodeToString(sum[:]) != p.challenge {
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
return
}
// oauth2 picks the parser from the content type; without this it reads
// the body as a form, finds no token and retries, spending the code.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "unused", "token_type": "Bearer", "expires_in": 300,
"id_token": f.sign(t, p.claims),
})
})
f.Server = httptest.NewServer(mux)
t.Cleanup(f.Close)
return f
}
// sign returns claims as an RS256 JWT.
func (f *fakeIdP) sign(t *testing.T, claims map[string]any) string {
t.Helper()
enc := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
signing := enc(map[string]string{"alg": "RS256", "kid": "k1", "typ": "JWT"}) + "." + enc(claims)
sum := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:])
if err != nil {
t.Fatal(err)
}
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// idpUser is who signs in, as the provider describes them.
type idpUser struct {
sub, username, email string
unverified bool
groups []string
badNonce bool
}
// ssoConfig is a terdut configuration wired to idp, with the mapping the tests
// share: terdut-users may sign in, terdut-admins administer, and the sre groups
// grant roles in the SRE team.
func ssoConfig(idp *fakeIdP) config.Config {
c := testConfig()
c.OIDC = config.OIDC{
Issuer: idp.URL,
ClientID: idpClientID,
ClientSecret: idpClientSecret,
Name: "Authentik",
Scopes: []string{"openid", "profile", "email"},
UsernameClaim: "preferred_username",
EmailClaim: "email",
GroupsClaim: "groups",
AllowedGroups: []string{"terdut-users"},
AdminGroup: "terdut-admins",
GroupMappings: []config.GroupMapping{
{Group: "sre", Team: "SRE", Role: "member"},
{Group: "sre-leads", Team: "SRE", Role: "owner"},
{Group: "platform", Team: "Platform", Role: "member"},
},
SessionMaxAge: 12 * time.Hour,
}
return c
}
func newSSOTS(t *testing.T, idp *fakeIdP, tweak ...func(*config.Config)) *ts {
t.Helper()
c := ssoConfig(idp)
for _, f := range tweak {
f(&c)
}
return newTSWith(t, api.DeadmanConfig{}, api.NotifyConfig{PublicURL: "http://terdut.test"}, c)
}
// ssoBrowser is a browser that does not follow redirects, so a test can read
// where each step sends it.
func ssoBrowser(t *testing.T, s *ts) *browser {
t.Helper()
b := newBrowser(t, s.URL)
b.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return b
}
// startLogin visits /api/oidc/login and returns what terdut asked the provider
// for: the state, nonce and PKCE challenge.
func startLogin(t *testing.T, idp *fakeIdP, b *browser) (state, nonce, challenge string) {
t.Helper()
resp := b.do(t, http.MethodGet, "/api/oidc/login", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("login start: %d", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil || !strings.HasPrefix(loc.String(), idp.URL+"/authorize") {
t.Fatalf("login redirected to %q, want the provider", resp.Header.Get("Location"))
}
q := loc.Query()
if q.Get("code_challenge_method") != "S256" || q.Get("client_id") != idpClientID ||
q.Get("redirect_uri") != "http://terdut.test/api/oidc/callback" || q.Get("response_type") != "code" {
t.Fatalf("unexpected authorization request: %v", q)
}
return q.Get("state"), q.Get("nonce"), q.Get("code_challenge")
}
// issueCode has the provider authenticate u and hand back an authorization code.
func (f *fakeIdP) issueCode(u idpUser, nonce, challenge string) string {
if u.badNonce {
nonce = "not-the-nonce"
}
claims := map[string]any{
"iss": f.URL, "sub": u.sub, "aud": idpClientID,
"iat": time.Now().Unix(), "exp": time.Now().Add(5 * time.Minute).Unix(),
"nonce": nonce,
"preferred_username": u.username,
"email": u.email,
"email_verified": !u.unverified,
"groups": u.groups,
}
f.mu.Lock()
defer f.mu.Unlock()
code := fmt.Sprintf("code-%d", len(f.codes)+int(time.Now().UnixNano()%1e6))
f.codes[code] = pendingCode{claims: claims, challenge: challenge}
return code
}
// callback delivers the provider's answer to terdut and returns where terdut
// sends the browser next.
func callback(t *testing.T, b *browser, code, state string) string {
t.Helper()
resp := b.do(t, http.MethodGet, "/api/oidc/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("callback: %d", resp.StatusCode)
}
return resp.Header.Get("Location")
}
// signInSSO runs a whole sign-in and returns the Location the callback ended on.
func signInSSO(t *testing.T, idp *fakeIdP, b *browser, u idpUser) string {
t.Helper()
state, nonce, challenge := startLogin(t, idp, b)
return callback(t, b, idp.issueCode(u, nonce, challenge), state)
}
var alice = idpUser{sub: "sub-alice", username: "alice", email: "alice@example.com", groups: []string{"terdut-users", "sre"}}
func withGroups(u idpUser, groups ...string) idpUser {
u.groups = groups
return u
}
// meOf reads /api/me over the browser's session.
func meOf(t *testing.T, b *browser) (status int, username string, isAdmin, hasPassword bool) {
t.Helper()
resp := b.do(t, http.MethodGet, "/api/me", nil)
defer resp.Body.Close()
var me struct {
User struct {
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
} `json:"user"`
HasPassword bool `json:"has_password"`
}
json.NewDecoder(resp.Body).Decode(&me)
return resp.StatusCode, me.User.Username, me.User.IsAdmin, me.HasPassword
}
// memberships lists a user's teams as name -> "role/source".
func (s *ts) memberships(t *testing.T, username string) map[string]string {
t.Helper()
rows, err := s.db.Query(`
SELECT t.name, m.role, m.source FROM team_members m
JOIN teams t ON t.id = m.team_id JOIN users u ON u.id = m.user_id
WHERE u.username = $1`, username)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var name, role, source string
rows.Scan(&name, &role, &source)
out[name] = role + "/" + source
}
return out
}
func sameMap(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
b := ssoBrowser(t, s)
if loc := signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "platform")); loc != "/" {
t.Fatalf("signed in and was sent to %q, want /", loc)
}
status, name, isAdmin, hasPassword := meOf(t, b)
if status != http.StatusOK || name != "alice" || isAdmin || hasPassword {
t.Fatalf("me: status %d user %q admin %v has_password %v", status, name, isAdmin, hasPassword)
}
want := map[string]string{"SRE": "member/oidc", "Platform": "member/oidc"}
if got := s.memberships(t, "alice"); !sameMap(got, want) {
t.Errorf("memberships %v, want %v", got, want)
}
}
func TestSSO_RefusedOutsideAllowedGroups(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
b := ssoBrowser(t, s)
loc := signInSSO(t, idp, b, withGroups(alice, "sre", "terdut-admins"))
if loc != "/?sso_error=not_allowed" {
t.Fatalf("sent to %q, want the not_allowed error", loc)
}
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
t.Errorf("a refused sign-in must not leave a session: /api/me %d", status)
}
var n int
s.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = 'alice'").Scan(&n)
if n != 0 {
t.Error("a refused sign-in must not create the user")
}
}
func TestSSO_AdminFollowsTheAdminGroup(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "terdut-admins"))
var isAdmin bool
var source string
read := func() {
s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'alice'").Scan(&isAdmin, &source)
}
if read(); !isAdmin || source != "oidc" {
t.Fatalf("after admin sign-in: admin %v source %q", isAdmin, source)
}
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users"))
if read(); isAdmin || source != "manual" {
t.Errorf("after losing the group: admin %v source %q, want revoked and manual", isAdmin, source)
}
}
func TestSSO_ManualAdminIsNeverRevoked(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true })
// The bootstrap administrator is a manual one. Signing in through the
// provider without the admin group must not take that away.
signInSSO(t, idp, ssoBrowser(t, s), idpUser{sub: "sub-admin", username: "admin", email: "admin@test.com", groups: []string{"terdut-users"}})
var isAdmin bool
var source string
s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'admin'").Scan(&isAdmin, &source)
if !isAdmin || source != "manual" {
t.Errorf("admin %v source %q, want still a manual admin", isAdmin, source)
}
}
func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), alice)
// Somebody adds alice to another team by hand.
s.exec(t, "INSERT INTO teams (name) VALUES ('Hand')")
s.exec(t, `INSERT INTO team_members (team_id, user_id, role)
SELECT (SELECT id FROM teams WHERE name = 'Hand'), id, 'member' FROM users WHERE username = 'alice'`)
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users"))
want := map[string]string{"Hand": "member/manual"}
if got := s.memberships(t, "alice"); !sameMap(got, want) {
t.Errorf("memberships %v, want %v: the SRE row is the sync's to remove, Hand is not", got, want)
}
}
func TestSSO_HighestRoleWinsAndRoleChangesFollow(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "sre-leads"))
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/oidc"}) {
t.Errorf("both groups: %v, want owner", got)
}
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre"))
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) {
t.Errorf("lead group dropped: %v, want member", got)
}
}
func TestSSO_ManualMemberIsRaisedNeverLowered(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
// alice exists locally, is a manual owner of SRE, and is linked by email.
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'alice@example.com')")
s.exec(t, "INSERT INTO teams (name) VALUES ('SRE')")
s.exec(t, `INSERT INTO team_members (team_id, user_id, role)
VALUES ((SELECT id FROM teams WHERE name = 'SRE'), (SELECT id FROM users WHERE username = 'alice'), 'owner')`)
signInSSO(t, idp, ssoBrowser(t, s), alice) // the mapping only says member
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/manual"}) {
t.Errorf("%v: a hand-made owner must not be lowered by a member mapping", got)
}
}
func TestSSO_LinksExistingUserByVerifiedEmail(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'Alice@Example.com')")
b := ssoBrowser(t, s)
signInSSO(t, idp, b, alice)
if _, name, _, _ := meOf(t, b); name != "alice-local" {
t.Errorf("signed in as %q, want the existing local user", name)
}
var users, identities int
s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&users)
s.db.QueryRow("SELECT COUNT(*) FROM user_identities").Scan(&identities)
if users != 2 || identities != 1 { // admin + alice-local
t.Errorf("%d users, %d identities: linking must not create a second user", users, identities)
}
}
func TestSSO_UnverifiedEmailIsNotLinkedUnlessTrusted(t *testing.T) {
idp := newFakeIdP(t)
unverified := alice
unverified.unverified = true
s := newSSOTS(t, idp)
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')")
if loc := signInSSO(t, idp, ssoBrowser(t, s), unverified); loc != "/?sso_error=email_conflict" {
t.Errorf("unverified email: sent to %q, want email_conflict", loc)
}
trusting := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true })
trusting.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')")
b := ssoBrowser(t, trusting)
if loc := signInSSO(t, idp, b, unverified); loc != "/" {
t.Fatalf("trusted email: sent to %q, want /", loc)
}
if _, name, _, _ := meOf(t, b); name != "alice-local" {
t.Errorf("signed in as %q, want the existing local user", name)
}
}
func TestSSO_RecycledEmailDoesNotTakeOverALinkedAccount(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), alice)
// A different person at the provider, same address.
other := alice
other.sub = "sub-someone-else"
if loc := signInSSO(t, idp, ssoBrowser(t, s), other); loc != "/?sso_error=email_conflict" {
t.Errorf("sent to %q, want email_conflict", loc)
}
}
func TestSSO_UsernameCollisionGetsASuffix(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'someone-else@example.com')")
b := ssoBrowser(t, s)
signInSSO(t, idp, b, alice)
if _, name, _, _ := meOf(t, b); name != "alice-2" {
t.Errorf("username %q, want alice-2", name)
}
}
func TestSSO_ProfileFollowsTheProvider(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), alice)
renamed := alice
renamed.username, renamed.email = "alice.smith", "alice.smith@example.com"
b := ssoBrowser(t, s)
signInSSO(t, idp, b, renamed)
if _, name, _, _ := meOf(t, b); name != "alice.smith" {
t.Errorf("username %q, want the provider's new one", name)
}
var email string
s.db.QueryRow("SELECT email FROM users WHERE username = 'alice.smith'").Scan(&email)
if email != "alice.smith@example.com" {
t.Errorf("email %q", email)
}
}
func TestSSO_DisabledUserIsRefused(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), alice)
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'")
b := ssoBrowser(t, s)
if loc := signInSSO(t, idp, b, alice); loc != "/?sso_error=disabled" {
t.Errorf("sent to %q, want disabled", loc)
}
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
t.Errorf("/api/me %d, want 401", status)
}
}
func TestSSO_NoEmailIsRefused(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
noEmail := alice
noEmail.email = ""
if loc := signInSSO(t, idp, ssoBrowser(t, s), noEmail); loc != "/?sso_error=no_email" {
t.Errorf("sent to %q, want no_email", loc)
}
}
func TestSSO_SessionIsCappedAndDoesNotSlidePastTheCap(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
b := ssoBrowser(t, s)
signInSSO(t, idp, b, alice)
var expires, ceiling int64
s.db.QueryRow(`SELECT expires_at, max_expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&expires, &ceiling)
inTwelveHours := time.Now().Add(12 * time.Hour).Unix()
if ceiling < inTwelveHours-60 || ceiling > inTwelveHours+60 || expires != ceiling {
t.Fatalf("expires %d ceiling %d, want both about %d", expires, ceiling, inTwelveHours)
}
// Age the session so the next request would slide it, with a ceiling well
// inside the ordinary 30 days.
s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200")
if status, _, _, _ := meOf(t, b); status != http.StatusOK {
t.Fatalf("/api/me %d", status)
}
var after int64
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after)
if after > ceiling {
t.Errorf("expiry slid to %d, past the ceiling %d", after, ceiling)
}
}
func TestSSO_PasswordSessionsStillSlideWithoutACeiling(t *testing.T) {
s := newTS(t)
b := signedIn(t, s)
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 session has a ceiling %d, want none", *ceiling)
}
s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200, expires_at = expires_at - 7200")
var before, after int64
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&before)
meOf(t, b)
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after)
if after <= before {
t.Errorf("expiry %d -> %d, want it to slide forward", before, after)
}
}
func TestSSO_StateIsSingleUseAndBoundToTheBrowser(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
// Replaying a callback finds no state.
b := ssoBrowser(t, s)
state, nonce, challenge := startLogin(t, idp, b)
code := idp.issueCode(alice, nonce, challenge)
if loc := callback(t, b, code, state); loc != "/" {
t.Fatalf("first callback sent to %q", loc)
}
if loc := callback(t, b, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" {
t.Errorf("replayed state: sent to %q, want expired", loc)
}
// A callback from a browser that did not start the login is refused, which
// is what stops a login being planted on somebody else.
victim := ssoBrowser(t, s)
state, nonce, challenge = startLogin(t, idp, ssoBrowser(t, s)) // the attacker's
if loc := callback(t, victim, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" {
t.Errorf("foreign browser: sent to %q, want expired", loc)
}
if status, _, _, _ := meOf(t, victim); status != http.StatusUnauthorized {
t.Errorf("the victim has a session: /api/me %d", status)
}
}
func TestSSO_WrongNonceIsRefused(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
bad := alice
bad.badNonce = true
b := ssoBrowser(t, s)
if loc := signInSSO(t, idp, b, bad); loc != "/?sso_error=failed" {
t.Errorf("sent to %q, want failed", loc)
}
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
t.Errorf("/api/me %d, want 401", status)
}
}
func TestSSO_ProviderErrorGoesBackToTheUI(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
b := ssoBrowser(t, s)
resp := b.do(t, http.MethodGet, "/api/oidc/callback?error=access_denied", nil)
resp.Body.Close()
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=denied" {
t.Errorf("%d to %q, want a redirect to denied", resp.StatusCode, loc)
}
}
func TestSSO_ManagedAccessCannotBeEditedByHand(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "terdut-admins"))
var aliceID, sreID int64
s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID)
s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID)
teamPath := fmt.Sprintf("/api/teams/%d/members", sreID)
// The bootstrap admin is a system administrator, so may manage SRE.
for _, c := range []struct {
name, method, path string
body any
}{
{"role change", http.MethodPost, teamPath, map[string]any{"user_id": aliceID, "role": "owner"}},
{"removal", http.MethodDelete, fmt.Sprintf("%s/%d", teamPath, aliceID), nil},
{"admin revoke", http.MethodPut, fmt.Sprintf("/api/users/%d/admin", aliceID), map[string]any{"is_admin": false}},
} {
resp := s.req(t, c.method, c.path, c.body)
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("%s: %d, want 409", c.name, resp.StatusCode)
}
}
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) {
t.Errorf("memberships changed by a refused edit: %v", got)
}
}
func TestSSO_PasswordLoginCanBeSwitchedOff(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp, func(c *config.Config) { c.DisablePasswordLogin = true })
b := newBrowser(t, s.URL)
resp := b.login(t, "admin", "whatever-password")
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("login: %d, want 403", resp.StatusCode)
}
resp = b.do(t, http.MethodPost, "/api/signup", map[string]string{"username": "x", "email": "x@example.com", "password": "correct horse battery"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("signup: %d, want 403", resp.StatusCode)
}
var cfg struct {
PasswordLogin bool `json:"password_login"`
OIDC struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
} `json:"oidc"`
}
resp = b.do(t, http.MethodGet, "/api/auth/config", nil)
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&cfg)
if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" {
t.Errorf("auth config: %+v", cfg)
}
}
func TestAuthConfig_DefaultsToPasswordOnly(t *testing.T) {
s := newTS(t)
var cfg struct {
PasswordLogin bool `json:"password_login"`
OIDC struct {
Enabled bool `json:"enabled"`
} `json:"oidc"`
}
resp := newBrowser(t, s.URL).do(t, http.MethodGet, "/api/auth/config", nil)
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&cfg)
if !cfg.PasswordLogin || cfg.OIDC.Enabled {
t.Errorf("auth config: %+v", cfg)
}
// With SSO off the routes do not exist, rather than answering with an error
// page a person could land on.
resp = newBrowser(t, s.URL).do(t, http.MethodGet, "/api/oidc/login", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("/api/oidc/login with SSO off: %d, want 404", resp.StatusCode)
}
}
func TestSSO_UnreachableProviderRedirectsWithAnError(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
idp.Close() // the provider goes down after terdut has started
b := ssoBrowser(t, s)
resp := b.do(t, http.MethodGet, "/api/oidc/login", nil)
resp.Body.Close()
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=unavailable" {
t.Errorf("%d to %q, want a redirect to unavailable", resp.StatusCode, loc)
}
}
func TestSSO_APIShowsWhereAccessCameFrom(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
b := ssoBrowser(t, s)
signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "terdut-admins"))
var aliceID, sreID int64
s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID)
s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID)
// Users: alice's administrator flag is the groups', the bootstrap admin's is not.
var users []struct {
Username string `json:"username"`
AdminSource string `json:"admin_source"`
}
decode(t, s.req(t, http.MethodGet, "/api/users", nil), &users)
got := map[string]string{}
for _, u := range users {
got[u.Username] = u.AdminSource
}
if got["alice"] != "oidc" || got["admin"] != "manual" {
t.Errorf("admin_source by user: %v", got)
}
// The team's own member list, as a member sees it.
var members []struct {
Username string `json:"username"`
Source string `json:"source"`
}
resp := b.do(t, http.MethodGet, fmt.Sprintf("/api/teams/%d/members", sreID), nil)
decode(t, resp, &members)
if len(members) != 1 || members[0].Username != "alice" || members[0].Source != "oidc" {
t.Errorf("team members: %+v", members)
}
// The administrator's view of the same team, and of alice's teams.
var adminTeam struct {
Members []struct {
Username string `json:"username"`
Source string `json:"source"`
} `json:"members"`
}
decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/admin/teams/%d", sreID), nil), &adminTeam)
if len(adminTeam.Members) != 1 || adminTeam.Members[0].Source != "oidc" {
t.Errorf("admin team members: %+v", adminTeam.Members)
}
var teams []struct {
Name string `json:"name"`
Source string `json:"source"`
}
decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/users/%d/teams", aliceID), nil), &teams)
if len(teams) != 1 || teams[0].Name != "SRE" || teams[0].Source != "oidc" {
t.Errorf("user teams: %+v", teams)
}
// The bootstrap admin's own membership is manual.
var mine []struct {
Source string `json:"source"`
}
decode(t, s.req(t, http.MethodGet, "/api/users/1/teams", nil), &mine)
if len(mine) == 0 || mine[0].Source != "manual" {
t.Errorf("bootstrap admin's teams: %+v", mine)
}
}
+29 -2
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
"git.ryuvia.com/niklas/terdut-server/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -20,6 +21,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// two would let a burst of sign-ups lock somebody out of logging in.
loginLimit := newLoginLimiter()
signupLimiter := newLoginLimiter()
oidcLimit := newLoginLimiter()
r := chi.NewRouter()
r.Use(middleware.Logger)
@@ -50,18 +52,43 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// an invite link is good, so the form can say so before somebody picks a
// password.
r.Get("/api/signup", handleSignupInfo(db))
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
// How to sign in: what the login form and the TUI offer before anybody types.
r.Get("/api/auth/config", handleAuthConfig(cfg))
// Signing in to the web UI. Login trades a password for a session cookie,
// which AuthMiddleware accepts in place of an API key.
r.Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
// Single sign-on. Both routes are navigations the browser makes, to and from
// the provider, so they answer with redirects rather than JSON.
if cfg.OIDC.Enabled() {
prov := oidc.New(cfg.OIDC, notify.PublicURL)
r.Get("/api/oidc/login", handleOIDCLogin(db, prov, oidcLimit, notify.PublicURL))
r.Get("/api/oidc/callback", handleOIDCCallback(db, prov, notify.PublicURL))
// Device login, for a client with no browser of its own. Both are
// unauthenticated: the device code in the body is the credential.
r.Post("/api/oidc/device", handleDeviceStart(db, oidcLimit, notify.PublicURL))
r.Post("/api/oidc/device/token", handleDeviceToken(db, cfg.OIDC.SessionMaxAge, notify.PublicURL))
}
// All other /api routes require a valid API key.
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(db))
r.Get("/api/me", handleMe(db))
// Approving or refusing a device login is done by somebody signed in
// to a browser, and needs the same SSO configuration the flow does.
if cfg.OIDC.Enabled() {
r.Post("/api/oidc/device/approve", handleDeviceDecision(db, true))
r.Post("/api/oidc/device/deny", handleDeviceDecision(db, false))
}
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
// Proves the topic works, which is the only part of "notifications are
// set up" that the person holding the phone can confirm.
+2 -2
View File
@@ -315,7 +315,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
// Same query and same ordering as handleListTeamMembers, so the two
// answers to "who is in this team" cannot disagree about the answer.
rows, err := db.QueryContext(r.Context(), `
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source
FROM team_members m
JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1
@@ -330,7 +330,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var m models.TeamMember
var joined int64
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil {
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
+36 -6
View File
@@ -21,7 +21,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, _ := userFromContext(r.Context())
rows, err := db.QueryContext(r.Context(), `
SELECT t.id, t.name, t.created_at, m.role
SELECT t.id, t.name, t.created_at, m.role, m.source
FROM teams t
JOIN team_members m ON m.team_id = t.id
WHERE m.user_id = $1
@@ -36,7 +36,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var t models.Team
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -83,7 +83,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
}
rows, err := db.QueryContext(r.Context(), `
SELECT t.id, t.name, t.created_at, m.role
SELECT t.id, t.name, t.created_at, m.role, m.source
FROM teams t
JOIN team_members m ON m.team_id = t.id
WHERE m.user_id = $1
@@ -98,7 +98,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var t models.Team
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -255,7 +255,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
}
rows, err := db.QueryContext(r.Context(), `
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at,
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source,
u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '',
u.disabled_at IS NOT NULL,
GREATEST(
@@ -280,7 +280,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
var m memberStatus
var joined, lastActive int64
var hasTopic, disabled bool
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined,
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source,
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -343,6 +343,14 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
return
}
if managed, err := isSSOManagedMember(r.Context(), db, teamID, req.UserID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
} else if managed {
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
return
}
// Demoting the last owner is removing them by another route: the team
// would have nobody who can edit it.
if req.Role == models.RoleMember {
@@ -392,6 +400,14 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
return
}
if managed, err := isSSOManagedMember(r.Context(), db, teamID, userID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
} else if managed {
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
return
}
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
@@ -416,6 +432,20 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
}
}
// ssoManagedMsg is the refusal for editing access that single sign-on owns.
const ssoManagedMsg = "this membership is managed by single sign-on; change the user's groups in the identity provider"
// isSSOManagedMember reports whether the membership comes from the group sync.
// Editing it here would be undone at the person's next sign-in, so it is refused
// instead of appearing to work.
func isSSOManagedMember(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
var managed bool
err := db.QueryRowContext(ctx,
"SELECT EXISTS (SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND source = 'oidc')",
teamID, userID).Scan(&managed)
return managed, err
}
func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
var last bool
err := db.QueryRowContext(ctx, `
+16 -4
View File
@@ -96,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
func handleListUsers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users ORDER BY id")
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users ORDER BY id")
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -108,7 +108,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
var u models.User
var ts int64
var disabled *int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -329,8 +329,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var ts int64
var disabled *int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled)
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled)
if err != nil {
return u, err
}
@@ -361,6 +361,18 @@ func handleSetAdmin(db *sql.DB) http.HandlerFunc {
}
if !*req.IsAdmin {
var managed bool
if err := db.QueryRowContext(r.Context(),
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin AND admin_source = 'oidc')",
id).Scan(&managed); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if managed {
respond(w, http.StatusConflict, errResp("administrator access is managed by single sign-on; change the user's groups in the identity provider"))
return
}
caller, _ := userFromContext(r.Context())
if caller.ID == id {
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))