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:
@@ -0,0 +1,81 @@
|
||||
// Package oidc signs users in through an OpenID Connect provider and turns the
|
||||
// groups it reports into the access terdut grants.
|
||||
//
|
||||
// The package knows nothing about the database or HTTP handlers: Grants is a
|
||||
// pure function of configuration and groups, and Provider is the protocol. The
|
||||
// api package joins them to users, teams and sessions.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
)
|
||||
|
||||
// Role names match models.RoleOwner and RoleMember. They are restated here so
|
||||
// the package stays free of the models import; config.Validate has already
|
||||
// refused anything else.
|
||||
const (
|
||||
roleOwner = "owner"
|
||||
roleMember = "member"
|
||||
)
|
||||
|
||||
// Grants is the access a set of groups confers.
|
||||
type Grants struct {
|
||||
// Admitted is false when AllowedGroups is set and the user is in none of
|
||||
// them. Nothing else in the struct means anything then.
|
||||
Admitted bool
|
||||
|
||||
// Admin is whether the user is in the admin group.
|
||||
Admin bool
|
||||
|
||||
// Teams maps team name to role. Where several groups grant the same team the
|
||||
// highest role wins, so belonging to both a members group and an owners
|
||||
// group makes somebody an owner rather than whichever mapping came last.
|
||||
Teams map[string]string
|
||||
}
|
||||
|
||||
// ComputeGrants evaluates the configured mappings against groups.
|
||||
func ComputeGrants(cfg config.OIDC, groups []string) Grants {
|
||||
in := make(map[string]bool, len(groups))
|
||||
for _, g := range groups {
|
||||
in[g] = true
|
||||
}
|
||||
|
||||
g := Grants{Teams: map[string]string{}}
|
||||
|
||||
g.Admitted = len(cfg.AllowedGroups) == 0
|
||||
for _, allowed := range cfg.AllowedGroups {
|
||||
if in[allowed] {
|
||||
g.Admitted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !g.Admitted {
|
||||
return g
|
||||
}
|
||||
|
||||
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
|
||||
|
||||
for _, m := range cfg.GroupMappings {
|
||||
if !in[m.Group] {
|
||||
continue
|
||||
}
|
||||
if rank(m.Role) > rank(g.Teams[m.Team]) {
|
||||
g.Teams[m.Team] = m.Role
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// rank orders roles; an unknown or absent role ranks lowest.
|
||||
func rank(role string) int {
|
||||
switch role {
|
||||
case roleOwner:
|
||||
return 2
|
||||
case roleMember:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// HigherRole reports whether role a outranks role b.
|
||||
func HigherRole(a, b string) bool { return rank(a) > rank(b) }
|
||||
@@ -0,0 +1,82 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
)
|
||||
|
||||
func testCfg() config.OIDC {
|
||||
return config.OIDC{
|
||||
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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeGrants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
groups []string
|
||||
want Grants
|
||||
}{
|
||||
{
|
||||
name: "not in an allowed group is refused",
|
||||
groups: []string{"sre", "terdut-admins"},
|
||||
want: Grants{Admitted: false, Teams: map[string]string{}},
|
||||
},
|
||||
{
|
||||
name: "allowed but no grants",
|
||||
groups: []string{"terdut-users"},
|
||||
want: Grants{Admitted: true, Teams: map[string]string{}},
|
||||
},
|
||||
{
|
||||
name: "admin group grants admin",
|
||||
groups: []string{"terdut-users", "terdut-admins"},
|
||||
want: Grants{Admitted: true, Admin: true, Teams: map[string]string{}},
|
||||
},
|
||||
{
|
||||
name: "team roles from several groups",
|
||||
groups: []string{"terdut-users", "sre", "platform"},
|
||||
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "member", "Platform": "member"}},
|
||||
},
|
||||
{
|
||||
name: "highest role wins whatever the order",
|
||||
groups: []string{"sre-leads", "terdut-users", "sre"},
|
||||
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "owner"}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ComputeGrants(testCfg(), tt.groups)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("got %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeGrants_NoAllowedGroupsAdmitsEveryone(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
cfg.AllowedGroups = nil
|
||||
if g := ComputeGrants(cfg, nil); !g.Admitted {
|
||||
t.Error("with no allowed groups configured, everybody the provider authenticates is admitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringList(t *testing.T) {
|
||||
if got := stringList([]any{"a", "", 3, "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
|
||||
t.Errorf("list: %v", got)
|
||||
}
|
||||
if got := stringList("solo"); !reflect.DeepEqual(got, []string{"solo"}) {
|
||||
t.Errorf("single string: %v", got)
|
||||
}
|
||||
if got := stringList(nil); got != nil {
|
||||
t.Errorf("nil: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
)
|
||||
|
||||
// CallbackPath is where the provider sends the browser back to. Register
|
||||
// <TERDUT_PUBLIC_URL>/api/oidc/callback as the redirect URI at the provider.
|
||||
const CallbackPath = "/api/oidc/callback"
|
||||
|
||||
// Identity is what the provider says about somebody who has just signed in.
|
||||
type Identity struct {
|
||||
Issuer string
|
||||
Subject string
|
||||
Username string
|
||||
Email string
|
||||
|
||||
// EmailVerified is the provider's own claim. Whether to believe it is
|
||||
// config.OIDC.TrustEmail's business, not this package's.
|
||||
EmailVerified bool
|
||||
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// Provider runs the authorization-code flow with PKCE against one issuer.
|
||||
type Provider struct {
|
||||
cfg config.OIDC
|
||||
redirectURL string
|
||||
http *http.Client
|
||||
|
||||
// Discovery is fetched on first use, not at startup. A provider that is
|
||||
// down when terdut starts must not stop terdut starting: password login is
|
||||
// the way in while it is down, and it can only be that if the server is up.
|
||||
mu sync.Mutex
|
||||
provider *gooidc.Provider
|
||||
}
|
||||
|
||||
// New returns a Provider for cfg. publicURL is the base of the redirect URI.
|
||||
func New(cfg config.OIDC, publicURL string) *Provider {
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
redirectURL: trimSlash(publicURL) + CallbackPath,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func trimSlash(s string) string {
|
||||
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Name is what the sign-in button calls the provider.
|
||||
func (p *Provider) Name() string { return p.cfg.Name }
|
||||
|
||||
// Config is the configuration this provider was built from.
|
||||
func (p *Provider) Config() config.OIDC { return p.cfg }
|
||||
|
||||
// discover returns the provider's metadata, fetching it if need be. A failure is
|
||||
// not cached, so the next login tries again.
|
||||
func (p *Provider) discover(ctx context.Context) (*gooidc.Provider, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.provider != nil {
|
||||
return p.provider, nil
|
||||
}
|
||||
ctx = gooidc.ClientContext(ctx, p.http)
|
||||
prov, err := gooidc.NewProvider(ctx, p.cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc discovery: %w", err)
|
||||
}
|
||||
p.provider = prov
|
||||
return prov, nil
|
||||
}
|
||||
|
||||
func (p *Provider) oauth(prov *gooidc.Provider) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: p.cfg.ClientID,
|
||||
ClientSecret: p.cfg.ClientSecret,
|
||||
Endpoint: prov.Endpoint(),
|
||||
RedirectURL: p.redirectURL,
|
||||
Scopes: p.cfg.Scopes,
|
||||
}
|
||||
}
|
||||
|
||||
// NewVerifier returns a fresh PKCE code verifier.
|
||||
func NewVerifier() string { return oauth2.GenerateVerifier() }
|
||||
|
||||
// AuthURL is where to send the browser to sign in.
|
||||
func (p *Provider) AuthURL(ctx context.Context, state, nonce, verifier string) (string, error) {
|
||||
prov, err := p.discover(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p.oauth(prov).AuthCodeURL(state,
|
||||
oauth2.S256ChallengeOption(verifier),
|
||||
gooidc.Nonce(nonce),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Exchange trades the authorization code for tokens, verifies the ID token
|
||||
// (signature, issuer, audience, expiry and nonce) and returns who it names.
|
||||
func (p *Provider) Exchange(ctx context.Context, code, verifier, nonce string) (*Identity, error) {
|
||||
prov, err := p.discover(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx = gooidc.ClientContext(ctx, p.http)
|
||||
|
||||
tok, err := p.oauth(prov).Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc token exchange: %w", err)
|
||||
}
|
||||
raw, _ := tok.Extra("id_token").(string)
|
||||
if raw == "" {
|
||||
return nil, errors.New("oidc: token response has no id_token")
|
||||
}
|
||||
idToken, err := prov.Verifier(&gooidc.Config{ClientID: p.cfg.ClientID}).Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: verify id_token: %w", err)
|
||||
}
|
||||
if idToken.Nonce != nonce {
|
||||
return nil, errors.New("oidc: id_token nonce mismatch")
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("oidc: read claims: %w", err)
|
||||
}
|
||||
return p.identity(idToken.Issuer, idToken.Subject, claims), nil
|
||||
}
|
||||
|
||||
// identity maps raw claims onto an Identity using the configured claim names.
|
||||
func (p *Provider) identity(issuer, subject string, claims map[string]any) *Identity {
|
||||
id := &Identity{Issuer: issuer, Subject: subject}
|
||||
id.Username, _ = claims[p.cfg.UsernameClaim].(string)
|
||||
id.Email, _ = claims[p.cfg.EmailClaim].(string)
|
||||
id.EmailVerified, _ = claims["email_verified"].(bool)
|
||||
id.Groups = stringList(claims[p.cfg.GroupsClaim])
|
||||
return id
|
||||
}
|
||||
|
||||
// stringList reads a claim that is a list of strings, or a single string, which
|
||||
// some providers send for a one-element list.
|
||||
func stringList(v any) []string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{t}
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, e := range t {
|
||||
if s, ok := e.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user