- 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>
227 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|