Files
gsc-shell-api/internal/auth/keycloak.go
Claude 7fb24e0452 feat: gsc-shell-api v0.1 — central chrome data API
Tiny Go service that returns ShellConfig JSON for any registered app.
Backs the runtime-loaded <AppShell> component being added to
@limitless/ui (next).

Endpoints:
  GET /api/v1/shell/{appKey}  → app + branding + user + menus, ETag-cached
  GET /api/v1/apps            → registered app inventory
  GET /healthz, /readyz       → ops probes

Auth:
  Keycloak Bearer JWT validated against the gosecCloud realm.
  Discovery URL is overridable so pods can hit Keycloak via the
  in-cluster service (https://keycloak.keycloak.svc.cluster.local:8443)
  while still validating the canonical issuer (auth.gosec.cloud).
  Lazy JWKS init — pod stays up if Keycloak is briefly unreachable.

Data model (gsc_core.shell):
  apps · menu_items (zone enum: topbar/sidebar/footer/user-menu) ·
  menu_role_grants (Keycloak realm roles, OR semantics, empty=all) ·
  branding

Seed includes the 8 gsc-crm sidebar items + topbar search +
user-menu (settings/support/logout) + footer (docs).

K8s:
  Namespace gsc-shell (ambient mesh).
  Deployment 2 replicas, internal-only ingress shell-api.gosec.internal,
  EJBCA SERVER cert.
  ServiceEntry for auth.gosec.cloud (vestigial — discovery now uses
  in-cluster path; keeping for ad-hoc curl from inside pods).
  Added to keycloak/allow-keycloak-clients AuthorizationPolicy out of band.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:10:22 +02:00

188 lines
5.3 KiB
Go

// Package auth verifies Keycloak-issued JWTs.
//
// JWKS discovery is lazy: we don't hit `.well-known/openid-configuration`
// at startup because pod-to-public-internet TLS may be slow during pod
// boot and we don't want a healthy DB-only deployment to crash because
// auth.gosec.cloud hiccupped. Discovery happens on first token validation
// and the result is cached. We retry on every fresh request until it
// succeeds.
package auth
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gofiber/fiber/v2"
)
type UserClaims struct {
Subject string `json:"sub"`
Email string `json:"email"`
DisplayName string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
TenantID string `json:"tenant_id"`
Roles []string // realm roles + flattened client roles
}
type Verifier struct {
issuer string // the canonical issuer claim Keycloak emits
discoveryURL string // optional: point discovery at an internal URL
allowedAudCSV string
mu sync.Mutex
v *oidc.IDTokenVerifier // nil until first successful discovery
}
// NewVerifier returns a Verifier that validates tokens whose `iss` matches
// `issuer`. If `discoveryURL` is non-empty, OIDC discovery is performed
// against that URL instead — useful when the canonical issuer is a public
// hostname (auth.gosec.cloud) but the pod can only reach Keycloak through
// an in-cluster service.
func NewVerifier(_ context.Context, issuer, discoveryURL, allowedAudiencesCSV string) (*Verifier, error) {
if issuer == "" {
return nil, errors.New("issuer is required")
}
return &Verifier{
issuer: issuer,
discoveryURL: discoveryURL,
allowedAudCSV: allowedAudiencesCSV,
}, nil
}
// ensure resolves the underlying OIDC verifier on demand. Cached after first success.
func (vr *Verifier) ensure(ctx context.Context) (*oidc.IDTokenVerifier, error) {
vr.mu.Lock()
defer vr.mu.Unlock()
if vr.v != nil {
return vr.v, nil
}
disc, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
target := vr.issuer
if vr.discoveryURL != "" {
// Tell go-oidc that "the issuer claim Keycloak puts in tokens" and
// "the URL we hit for discovery" can differ — otherwise it errors
// because the discovery doc's issuer field won't match what we
// passed to NewProvider.
disc = oidc.InsecureIssuerURLContext(disc, vr.issuer)
target = vr.discoveryURL
// In-cluster Keycloak presents a cert for `auth.gosec.cloud`, not
// for `keycloak.keycloak.svc.cluster.local`. Ambient mesh policy
// gates who can reach Keycloak; we don't need cert hostname
// verification on top of that for the internal discovery hop.
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
},
Timeout: 10 * time.Second,
}
disc = oidc.ClientContext(disc, client)
}
provider, err := oidc.NewProvider(disc, target)
if err != nil {
return nil, fmt.Errorf("oidc discovery: %w", err)
}
vr.v = provider.Verifier(&oidc.Config{
// Custom audience check below; many Keycloak access tokens have
// aud="account" which would fail strict OIDC checks.
SkipClientIDCheck: true,
})
return vr.v, nil
}
func (vr *Verifier) Verify(ctx context.Context, raw string) (*UserClaims, error) {
v, err := vr.ensure(ctx)
if err != nil {
return nil, err
}
tok, err := v.Verify(ctx, raw)
if err != nil {
return nil, err
}
var raw1 struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
TenantID string `json:"tenant_id"`
AzpAudience string `json:"azp"`
RealmAccess struct {
Roles []string `json:"roles"`
} `json:"realm_access"`
ResourceAccess map[string]struct {
Roles []string `json:"roles"`
} `json:"resource_access"`
}
if err := tok.Claims(&raw1); err != nil {
return nil, fmt.Errorf("decode claims: %w", err)
}
if vr.allowedAudCSV != "" {
if !contains(strings.Split(vr.allowedAudCSV, ","), raw1.AzpAudience) {
return nil, errors.New("token azp not in allowed audiences")
}
}
roles := append([]string{}, raw1.RealmAccess.Roles...)
for _, ra := range raw1.ResourceAccess {
roles = append(roles, ra.Roles...)
}
return &UserClaims{
Subject: raw1.Subject,
Email: raw1.Email,
DisplayName: raw1.Name,
GivenName: raw1.GivenName,
FamilyName: raw1.FamilyName,
TenantID: raw1.TenantID,
Roles: roles,
}, nil
}
func (vr *Verifier) Middleware() fiber.Handler {
return func(c *fiber.Ctx) error {
header := c.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return c.Status(fiber.StatusUnauthorized).
JSON(fiber.Map{"error": "missing bearer token"})
}
raw := strings.TrimPrefix(header, "Bearer ")
claims, err := vr.Verify(c.UserContext(), raw)
if err != nil {
return c.Status(fiber.StatusUnauthorized).
JSON(fiber.Map{"error": fmt.Sprintf("invalid token: %v", err)})
}
c.Locals("user", claims)
return c.Next()
}
}
func User(c *fiber.Ctx) *UserClaims {
if u, ok := c.Locals("user").(*UserClaims); ok {
return u
}
return nil
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if strings.TrimSpace(h) == needle {
return true
}
}
return false
}