Files
anchorage/internal/pkg/auth/oidc_test.go
William Gill 90ac4b169c
Some checks failed
Security / Vulnerability Check (push) Successful in 1m47s
Test / Build & Unit Tests (push) Successful in 5m4s
Test / Lint (push) Successful in 27s
Test / Integration Tests (push) Failing after 1m58s
ci: fix first CI run — tidy, gofmt, ordering test, govulncheck pin
- ids: TestIDsAreTimeOrdered asserted strict lexicographic ordering of
  back-to-back UUIDv7s, but the sub-ms tail is random and not required
  to be monotonic. Sleep between samples so each ID lands in a distinct
  millisecond — the property that actually gives Postgres index
  locality on (org_id, id desc).
- go.mod/go.sum: run go mod tidy. keyfunc/v3, prometheus/client_golang
  and testcontainers-go/modules/postgres are imported directly and
  should not be marked // indirect; also drops stale sum entries.
- gofmt -w across 12 files flagged by the lint job.
- security.yml: pin govulncheck to v1.2.0. @latest triggers a proxy
  lookup every run, which is the step that hung for 16m on the Gitea
  runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 09:18:49 -05:00

227 lines
6.2 KiB
Go

package auth_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"anchorage/internal/pkg/auth"
)
// fakeAuthentik simulates the small surface of Authentik anchorage
// actually touches: a JWKS endpoint and a helper to mint ID tokens.
type fakeAuthentik struct {
server *httptest.Server
priv *rsa.PrivateKey
kid string
issuer string
audience string
}
func newFakeAuthentik(t *testing.T, audience string) *fakeAuthentik {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
mux := http.NewServeMux()
kid := "test-kid-1"
fk := &fakeAuthentik{priv: priv, kid: kid, audience: audience}
mux.HandleFunc("/jwks/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]string{
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": kid,
"n": base64urlBigInt(priv.N),
"e": base64urlBigInt(big.NewInt(int64(priv.E))),
},
},
})
})
fk.server = httptest.NewServer(mux)
// Authentik-style per-app issuer. The verifier derives JWKS URL as
// <issuer>/jwks/ so our mux handles "/jwks/" under the issuer path.
fk.issuer = fk.server.URL + "/"
return fk
}
func (fk *fakeAuthentik) close() { fk.server.Close() }
// mintIDToken mints an RS256-signed ID token with optional overrides.
func (fk *fakeAuthentik) mintIDToken(t *testing.T, overrides func(c *auth.OIDCClaims)) string {
t.Helper()
now := time.Now().UTC()
c := &auth.OIDCClaims{
Sub: "hashed-authentik-sub-123",
Email: "alice@example.com",
Name: "Alice Example",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: fk.issuer,
Audience: jwt.ClaimStrings{fk.audience},
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
},
}
if overrides != nil {
overrides(c)
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, c)
tok.Header["kid"] = fk.kid
signed, err := tok.SignedString(fk.priv)
if err != nil {
t.Fatalf("sign: %v", err)
}
return signed
}
func base64urlBigInt(i *big.Int) string {
return base64.RawURLEncoding.EncodeToString(i.Bytes())
}
// ---------------------------------------------------------------------------
func TestOIDCVerifierAcceptsWellFormedToken(t *testing.T) {
fk := newFakeAuthentik(t, "anchorage-web")
defer fk.close()
v, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{
Issuer: fk.issuer,
Audience: fk.audience,
})
if err != nil {
t.Fatalf("NewOIDCVerifier: %v", err)
}
raw := fk.mintIDToken(t, nil)
claims, err := v.Verify(context.Background(), raw)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if claims.Sub != "hashed-authentik-sub-123" {
t.Errorf("Sub = %q", claims.Sub)
}
if claims.Email != "alice@example.com" {
t.Errorf("Email = %q", claims.Email)
}
if claims.DisplayName() != "Alice Example" {
t.Errorf("DisplayName = %q", claims.DisplayName())
}
}
func TestOIDCVerifierRejectsWrongAudience(t *testing.T) {
fk := newFakeAuthentik(t, "anchorage-web")
defer fk.close()
v, _ := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{
Issuer: fk.issuer,
Audience: "anchorage-web",
})
raw := fk.mintIDToken(t, func(c *auth.OIDCClaims) {
c.Audience = jwt.ClaimStrings{"some-other-client"}
})
if _, err := v.Verify(context.Background(), raw); err == nil {
t.Error("expected verify to fail on wrong audience")
}
}
func TestOIDCVerifierRejectsWrongIssuer(t *testing.T) {
fk := newFakeAuthentik(t, "anchorage-web")
defer fk.close()
v, _ := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{
Issuer: fk.issuer,
Audience: "anchorage-web",
})
raw := fk.mintIDToken(t, func(c *auth.OIDCClaims) {
c.Issuer = "https://evil.example.com/"
})
if _, err := v.Verify(context.Background(), raw); err == nil {
t.Error("expected verify to fail on wrong issuer")
}
}
func TestOIDCVerifierRejectsExpired(t *testing.T) {
fk := newFakeAuthentik(t, "anchorage-web")
defer fk.close()
v, _ := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{
Issuer: fk.issuer,
Audience: "anchorage-web",
})
raw := fk.mintIDToken(t, func(c *auth.OIDCClaims) {
c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-5 * time.Minute))
c.IssuedAt = jwt.NewNumericDate(time.Now().Add(-10 * time.Minute))
})
if _, err := v.Verify(context.Background(), raw); err == nil {
t.Error("expected verify to fail on expired token")
}
}
func TestOIDCVerifierRejectsMissingEmail(t *testing.T) {
fk := newFakeAuthentik(t, "anchorage-web")
defer fk.close()
v, _ := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{
Issuer: fk.issuer,
Audience: "anchorage-web",
})
raw := fk.mintIDToken(t, func(c *auth.OIDCClaims) {
c.Email = ""
})
_, err := v.Verify(context.Background(), raw)
if err == nil {
t.Fatal("expected verify to reject token with empty email")
}
if !strings.Contains(err.Error(), "email") {
t.Errorf("error should mention email; got %v", err)
}
}
func TestNewOIDCVerifierRequiresIssuerAndAudience(t *testing.T) {
if _, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{}); err == nil {
t.Error("expected error for empty options")
}
if _, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{Issuer: "https://x/"}); err == nil {
t.Error("expected error for missing audience")
}
}
// TestDisplayNameFallbacks locks in the Name → PreferredName → Email
// fallback chain so a user without a configured display_name in
// Authentik still gets a human-readable label in the anchorage UI.
func TestDisplayNameFallbacks(t *testing.T) {
tests := []struct {
in auth.OIDCClaims
want string
}{
{auth.OIDCClaims{Name: "Alice"}, "Alice"},
{auth.OIDCClaims{PreferredName: "alice"}, "alice"},
{auth.OIDCClaims{Email: "alice@example.com"}, "alice@example.com"},
}
for _, tt := range tests {
got := tt.in.DisplayName()
if got != tt.want {
t.Errorf("DisplayName(%+v) = %q, want %q", tt.in, got, tt.want)
}
}
}