5b4683febf
Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.
Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.
This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.
TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
816 lines
28 KiB
Go
816 lines
28 KiB
Go
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: terdut-users may sign in,
|
|
// terdut-admins administer. Which groups grant which team is not config
|
|
// anymore — it is each team's own oidc_member_group/oidc_owner_group, so a
|
|
// test that needs one seeds it with seedTeam.
|
|
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",
|
|
SessionMaxAge: 12 * time.Hour,
|
|
}
|
|
return c
|
|
}
|
|
|
|
// seedTeam creates a team with an OIDC group binding, the way an owner would
|
|
// set one from the Members tab. Teams are no longer created by the sync
|
|
// itself, so a test whose groups should grant something needs the team to
|
|
// already exist. An empty group means that role is not granted by one.
|
|
func (s *ts) seedTeam(t *testing.T, name, memberGroup, ownerGroup string) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
err := s.db.QueryRow(`
|
|
INSERT INTO teams (name, oidc_member_group, oidc_owner_group)
|
|
VALUES ($1, NULLIF($2, ''), NULLIF($3, '')) RETURNING id`,
|
|
name, memberGroup, ownerGroup).Scan(&id)
|
|
if err != nil {
|
|
t.Fatalf("seed team %q: %v", name, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
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)
|
|
s.seedTeam(t, "SRE", "sre", "sre-leads")
|
|
s.seedTeam(t, "Platform", "platform", "")
|
|
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)
|
|
}
|
|
}
|
|
|
|
// A group matching no team's own binding grants nothing and creates nothing:
|
|
// unlike the old global mapping, the sync never creates a team by name.
|
|
func TestSSO_NoAutoCreateTeam(t *testing.T) {
|
|
idp := newFakeIdP(t)
|
|
s := newSSOTS(t, idp)
|
|
|
|
var before int
|
|
s.db.QueryRow("SELECT COUNT(*) FROM teams").Scan(&before)
|
|
|
|
signInSSO(t, idp, ssoBrowser(t, s), alice) // groups include "sre"; no team names it
|
|
if got := s.memberships(t, "alice"); len(got) != 0 {
|
|
t.Errorf("memberships %v, want none: no team's oidc_member_group/oidc_owner_group is set", got)
|
|
}
|
|
|
|
var after int
|
|
s.db.QueryRow("SELECT COUNT(*) FROM teams").Scan(&after)
|
|
if after != before {
|
|
t.Errorf("team count %d -> %d, want no team created", before, after)
|
|
}
|
|
}
|
|
|
|
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)
|
|
s.seedTeam(t, "SRE", "sre", "sre-leads")
|
|
|
|
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)
|
|
s.seedTeam(t, "SRE", "sre", "sre-leads")
|
|
|
|
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.seedTeam(t, "SRE", "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 group only grants 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)
|
|
s.seedTeam(t, "SRE", "sre", "")
|
|
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)
|
|
s.seedTeam(t, "SRE", "sre", "")
|
|
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)
|
|
}
|
|
}
|