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:
@@ -1,7 +1,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -59,8 +64,78 @@ type Config struct {
|
||||
// NotifyRepeat is how long an incident may sit unacknowledged before it is
|
||||
// notified again. Zero disables reminders.
|
||||
NotifyRepeat time.Duration
|
||||
|
||||
// DisablePasswordLogin refuses signing in, or signing up, with a password.
|
||||
// It is how an install moves to SSO only, and turning it back off is the way
|
||||
// in when the identity provider is down. Stated negatively so that the zero
|
||||
// Config, which is what a test or a new caller builds, keeps passwords working.
|
||||
DisablePasswordLogin bool
|
||||
|
||||
// OIDC configures single sign-on. The zero value, with no Issuer, is off.
|
||||
OIDC OIDC
|
||||
}
|
||||
|
||||
// OIDC is the single sign-on configuration. Groups from the provider decide
|
||||
// who may sign in, which teams they belong to, and whether they administer the
|
||||
// install, in the manner of Grafana's org and role mapping.
|
||||
type OIDC struct {
|
||||
// Issuer is the provider's issuer URL. Discovery is fetched from
|
||||
// <Issuer>/.well-known/openid-configuration. For Authentik this is the
|
||||
// application's issuer, e.g. https://auth.example.com/application/o/terdut/.
|
||||
// Empty turns single sign-on off.
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
|
||||
// Name is what the sign-in button calls the provider.
|
||||
Name string
|
||||
|
||||
// Scopes to request. The groups claim normally needs "profile" on Authentik.
|
||||
Scopes []string
|
||||
|
||||
// UsernameClaim, EmailClaim and GroupsClaim name the ID token claims read.
|
||||
UsernameClaim string
|
||||
EmailClaim string
|
||||
GroupsClaim string
|
||||
|
||||
// TrustEmail links a sign-in to an existing local user by email even when the
|
||||
// provider does not vouch that the address is verified. Authentik reports
|
||||
// email_verified false unless told otherwise, and an install that runs its
|
||||
// own provider has already decided that its addresses can be trusted.
|
||||
TrustEmail bool
|
||||
|
||||
// AllowedGroups gates sign-in: somebody in none of them is refused, however
|
||||
// well the provider authenticated them. Empty admits everybody the provider
|
||||
// authenticates, and access control is left to the provider.
|
||||
AllowedGroups []string
|
||||
|
||||
// AdminGroup grants the system administrator flag while the user is in it.
|
||||
AdminGroup string
|
||||
|
||||
// GroupMappings grants team roles. A user in Group gets Role in Team.
|
||||
GroupMappings []GroupMapping
|
||||
|
||||
// SessionMaxAge is the hard ceiling on a session made by an SSO login. The
|
||||
// login is the only moment groups are re-read, so this is how long a change
|
||||
// in the provider may take to reach terdut.
|
||||
SessionMaxAge time.Duration
|
||||
|
||||
// parseErr is a malformed TERDUT_OIDC_GROUP_MAPPINGS, reported by Validate:
|
||||
// Load cannot fail, and a mapping that was silently dropped would grant
|
||||
// less access than the operator wrote down.
|
||||
parseErr error
|
||||
}
|
||||
|
||||
// GroupMapping grants Role in Team to members of Group.
|
||||
type GroupMapping struct {
|
||||
Group string `json:"group"`
|
||||
Team string `json:"team"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// Enabled reports whether single sign-on is configured.
|
||||
func (o OIDC) Enabled() bool { return o.Issuer != "" }
|
||||
|
||||
func Load() Config {
|
||||
addr := os.Getenv("TERDUT_ADDR")
|
||||
if addr == "" {
|
||||
@@ -89,9 +164,108 @@ func Load() Config {
|
||||
NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"),
|
||||
PublicURL: os.Getenv("TERDUT_PUBLIC_URL"),
|
||||
NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute),
|
||||
|
||||
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
|
||||
OIDC: loadOIDC(),
|
||||
}
|
||||
}
|
||||
|
||||
func loadOIDC() OIDC {
|
||||
o := OIDC{
|
||||
Issuer: strings.TrimSpace(os.Getenv("TERDUT_OIDC_ISSUER")),
|
||||
ClientID: os.Getenv("TERDUT_OIDC_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("TERDUT_OIDC_CLIENT_SECRET"),
|
||||
Name: str("TERDUT_OIDC_NAME", "SSO"),
|
||||
Scopes: list("TERDUT_OIDC_SCOPES", "openid profile email"),
|
||||
UsernameClaim: str("TERDUT_OIDC_USERNAME_CLAIM", "preferred_username"),
|
||||
EmailClaim: str("TERDUT_OIDC_EMAIL_CLAIM", "email"),
|
||||
GroupsClaim: str("TERDUT_OIDC_GROUPS_CLAIM", "groups"),
|
||||
TrustEmail: boolean("TERDUT_OIDC_TRUST_EMAIL", false),
|
||||
AllowedGroups: list("TERDUT_OIDC_ALLOWED_GROUPS", ""),
|
||||
AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"),
|
||||
SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour),
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("TERDUT_OIDC_GROUP_MAPPINGS")); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &o.GroupMappings); err != nil {
|
||||
o.parseErr = fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS: %w", err)
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Validate reports a configuration the server should refuse to start with.
|
||||
// Single sign-on is the only part that can be inconsistent: a half-configured
|
||||
// provider would come up and then fail every login, which is harder to notice
|
||||
// than not starting.
|
||||
func (c Config) Validate() error {
|
||||
o := c.OIDC
|
||||
if o.parseErr != nil {
|
||||
return o.parseErr
|
||||
}
|
||||
if !o.Enabled() {
|
||||
if c.DisablePasswordLogin {
|
||||
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
|
||||
}
|
||||
if len(o.GroupMappings) > 0 || o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
|
||||
return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if u, err := url.Parse(o.Issuer); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("TERDUT_OIDC_ISSUER %q is not a URL", o.Issuer)
|
||||
}
|
||||
if o.ClientID == "" || o.ClientSecret == "" {
|
||||
return errors.New("TERDUT_OIDC_CLIENT_ID and TERDUT_OIDC_CLIENT_SECRET are required with TERDUT_OIDC_ISSUER")
|
||||
}
|
||||
if c.PublicURL == "" {
|
||||
return errors.New("TERDUT_PUBLIC_URL is required with TERDUT_OIDC_ISSUER: it is the base of the redirect URI")
|
||||
}
|
||||
if o.SessionMaxAge <= 0 {
|
||||
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
|
||||
}
|
||||
for i, m := range o.GroupMappings {
|
||||
if m.Group == "" || m.Team == "" {
|
||||
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: group and team are required", i)
|
||||
}
|
||||
if m.Role != "owner" && m.Role != "member" {
|
||||
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: role must be owner or member, got %q", i, m.Role)
|
||||
}
|
||||
}
|
||||
if c.DisablePasswordLogin && len(o.GroupMappings) == 0 && o.AdminGroup == "" {
|
||||
return errors.New("TERDUT_PASSWORD_LOGIN=false with no OIDC group grants leaves nobody able to do anything")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func str(env, def string) string {
|
||||
if s := strings.TrimSpace(os.Getenv(env)); s != "" {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// list reads a comma- or space-separated env var.
|
||||
func list(env, def string) []string {
|
||||
s := os.Getenv(env)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
s = def
|
||||
}
|
||||
return strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' })
|
||||
}
|
||||
|
||||
// boolean reads a true/false env var. An unrecognised value takes the default,
|
||||
// so the two flags read this way (password login on, trusting email off) both
|
||||
// fail towards the cautious setting.
|
||||
func boolean(env string, def bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(env))) {
|
||||
case "true", "1", "yes":
|
||||
return true
|
||||
case "false", "0", "no":
|
||||
return false
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// duration reads a time.ParseDuration-formatted env var. An unset or
|
||||
// unparseable value falls back to def rather than failing startup: a typo in one
|
||||
// tuning knob should not take the server down.
|
||||
|
||||
Reference in New Issue
Block a user