package authn_test import ( "context" "crypto/rand" "crypto/rsa" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" jose "github.com/go-jose/go-jose/v4" "gitea.meghsakha.com/platform/tenant-registry/internal/authn" "gitea.meghsakha.com/platform/tenant-registry/internal/config" "gitea.meghsakha.com/platform/tenant-registry/internal/keycloak" "gitea.meghsakha.com/platform/tenant-registry/internal/server" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) // stubIssuer is a minimal OIDC issuer: discovery + JWKS + an RS256 signer. type stubIssuer struct { URL string key *rsa.PrivateKey sign func(t *testing.T, claims map[string]any) string } func newStubIssuer(t *testing.T) *stubIssuer { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } s := &stubIssuer{key: key} mux := http.NewServeMux() mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "issuer": s.URL, "jwks_uri": s.URL + "/jwks", }) }) mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{ {Key: key.Public(), KeyID: "test-kid", Algorithm: "RS256", Use: "sig"}, }}) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) s.URL = srv.URL signer, err := jose.NewSigner( jose.SigningKey{Algorithm: jose.RS256, Key: key}, (&jose.SignerOptions{}).WithHeader("kid", "test-kid"), ) if err != nil { t.Fatal(err) } s.sign = func(t *testing.T, claims map[string]any) string { t.Helper() payload, _ := json.Marshal(claims) jws, err := signer.Sign(payload) if err != nil { t.Fatal(err) } raw, err := jws.CompactSerialize() if err != nil { t.Fatal(err) } return raw } return s } func (s *stubIssuer) claims(overrides map[string]any) map[string]any { c := map[string]any{ "iss": s.URL, "aud": "tenant-registry", "sub": "svc-account-1", "azp": "compliance-svc", "exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(), } for k, v := range overrides { c[k] = v } return c } func TestVerifier(t *testing.T) { iss := newStubIssuer(t) v, err := authn.New(context.Background(), iss.URL, "tenant-registry") if err != nil { t.Fatalf("New: %v", err) } ctx := context.Background() t.Run("valid token yields principal", func(t *testing.T) { p, err := v.Verify(ctx, "Bearer "+iss.sign(t, iss.claims(nil))) if err != nil { t.Fatalf("verify: %v", err) } if p.Subject != "svc-account-1" || p.ClientID != "compliance-svc" || p.Issuer != iss.URL { t.Errorf("principal wrong: %+v", p) } }) fail := func(name, header string) { t.Run(name, func(t *testing.T) { if _, err := v.Verify(ctx, header); err == nil { t.Fatal("expected verification failure") } }) } fail("missing header", "") fail("not bearer", "Basic abc") fail("garbage token", "Bearer not.a.jwt") fail("expired", "Bearer "+iss.sign(t, iss.claims(map[string]any{"exp": time.Now().Add(-time.Minute).Unix()}))) fail("wrong audience", "Bearer "+iss.sign(t, iss.claims(map[string]any{"aud": "someone-else"}))) fail("wrong issuer", "Bearer "+iss.sign(t, iss.claims(map[string]any{"iss": "https://evil.example"}))) t.Run("missing header is ErrNoToken", func(t *testing.T) { if _, err := v.Verify(ctx, ""); err != authn.ErrNoToken { t.Fatalf("want ErrNoToken, got %v", err) } }) } func TestNew_failsClosed(t *testing.T) { if _, err := authn.New(context.Background(), "", "aud"); err == nil { t.Fatal("empty issuer must error") } if _, err := authn.New(context.Background(), "http://127.0.0.1:1/realms/none", "aud"); err == nil { t.Fatal("unreachable issuer must error") } } // TestRouterGating proves the wiring: health stays PUBLIC_EXPLICIT, every // API route fails closed without a token, and a valid service token passes. func TestRouterGating(t *testing.T) { iss := newStubIssuer(t) v, err := authn.New(context.Background(), iss.URL, "tenant-registry") if err != nil { t.Fatalf("New: %v", err) } handler := server.NewRouter(&server.Server{ Cfg: &config.Config{Env: "dev"}, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), Store: store.NewMemory(), Keycloak: keycloak.NewMock(), Auth: v, }) srv := httptest.NewServer(handler) defer srv.Close() get := func(t *testing.T, path, authz string) (int, string) { t.Helper() req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil) if authz != "" { req.Header.Set("Authorization", authz) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) return resp.StatusCode, string(raw) } if code, _ := get(t, "/healthz", ""); code != http.StatusOK { t.Errorf("healthz must stay public, got %d", code) } if code, body := get(t, "/v1/catalog", ""); code != http.StatusUnauthorized || !strings.Contains(body, "TOKEN_MISSING") { t.Errorf("no token: want 401 TOKEN_MISSING, got %d %s", code, body) } if code, body := get(t, "/v1/catalog", "Bearer junk"); code != http.StatusUnauthorized || !strings.Contains(body, "TOKEN_INVALID") { t.Errorf("bad token: want 401 TOKEN_INVALID, got %d %s", code, body) } if code, _ := get(t, "/v1/catalog", "Bearer "+iss.sign(t, iss.claims(nil))); code != http.StatusOK { t.Errorf("valid token: want 200, got %d", code) } }