Implements the model-B2 membership authority (ratified compliance auth design) and inbound token verification.
- GET /v1/users/{id}/memberships: Keycloak supplies user->tenant links + org_roles (attribute projection until the realm migrates to Organizations); registered tenants override status/plan/products from registry tables (source: registry|keycloak). Closes the M5.2-deferred org_roles stub.
- New internal/authn: OIDC discovery + JWKS verification (go-oidc/v3), audience tenant-registry. /healthz + /readyz stay PUBLIC_EXPLICIT; all other routes INTERNAL_SERVICE_ONLY. AUTH_ENABLED=true refuses to start on incomplete config (fail-closed); false (dev default) keeps current behavior.
- Full suite green incl. postgres testcontainers; openapi.yaml updated (contract test passes).
Activation is a separate step (Auth-5): requires the orca-infra env merge and a portal service token (portal currently calls with no auth).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com>
Reviewed-on: #14
190 lines
5.5 KiB
Go
190 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|