This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/auth/oidc.go
Jeffrey Smith b10f5bee05
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m22s
CI/CD / test-sqlite (pull_request) Successful in 2m35s
CI/CD / build-and-deploy (pull_request) Successful in 1m18s
drop users.role column: full RBAC through group membership
The role column was a pre-RBAC artifact. All authorization now flows
through explicit group membership and permission grants:

- Everyone group: all users added on creation (no implicit membership)
- Admins group: grants surface.admin.access + all platform permissions
- JWT claims, login response, profile: role field removed
- OIDC: isIdPAdmin() maps IdP claims → Admins group (no role writes)
- Admin UI: role dropdown removed, admin managed through groups
- Middleware cache simplified to isActive only

28 files changed, -79 lines net. Zero magic roles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:23:12 +00:00

585 lines
16 KiB
Go

package auth
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"switchboard-core/models"
"switchboard-core/store"
)
// OIDCConfig holds OIDC-specific configuration from env vars.
type OIDCConfig struct {
IssuerURL string // e.g. "https://keycloak.corp/realms/switchboard"
ExternalIssuerURL string // browser-facing URL (optional, defaults to IssuerURL)
ClientID string
ClientSecret string
RedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback"
AutoActivate bool // auto-activate new users (default true)
DefaultTeam string // team ID for auto-provisioned users (optional)
RolesClaim string // JWT claim for role mapping (default "realm_access.roles")
GroupsClaim string // JWT claim for group sync (default "groups")
AdminRole string // IdP role value that maps to admin (default "admin")
}
// OIDCProvider authenticates via OpenID Connect tokens from an external IdP.
//
// Supports two flows:
// 1. Authorization code flow: browser redirects to IdP, callback exchanges
// code for tokens. Used by the login page.
// 2. Direct token validation: API clients present a bearer token from the
// IdP, backend validates signature + claims. Used by programmatic access.
//
// JWKS keys are cached and refreshed periodically.
type OIDCProvider struct {
cfg OIDCConfig
discovery *OIDCDiscovery
keys map[string]*rsa.PublicKey
keysMu sync.RWMutex
keysAt time.Time
client *http.Client
}
// OIDCDiscovery holds the OIDC well-known configuration.
type OIDCDiscovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSUri string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
}
type oidcJWKS struct {
Keys []oidcJWK `json:"keys"`
}
type oidcJWK struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
Alg string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
}
type oidcTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}
func NewOIDCProvider(cfg OIDCConfig) (*OIDCProvider, error) {
if cfg.IssuerURL == "" {
return nil, fmt.Errorf("OIDC_ISSUER_URL is required")
}
if cfg.ClientID == "" {
return nil, fmt.Errorf("OIDC_CLIENT_ID is required")
}
if cfg.RolesClaim == "" {
cfg.RolesClaim = "realm_access.roles"
}
if cfg.GroupsClaim == "" {
cfg.GroupsClaim = "groups"
}
if cfg.AdminRole == "" {
cfg.AdminRole = "admin"
}
p := &OIDCProvider{
cfg: cfg,
keys: make(map[string]*rsa.PublicKey),
client: &http.Client{Timeout: 10 * time.Second},
}
// Fetch discovery document on startup
if err := p.fetchDiscovery(); err != nil {
return nil, fmt.Errorf("OIDC discovery failed: %w", err)
}
// Pre-fetch JWKS
if err := p.refreshKeys(); err != nil {
log.Printf("[auth/oidc] warn: initial JWKS fetch failed: %v (will retry on first request)", err)
}
return p, nil
}
func (p *OIDCProvider) Mode() Mode { return ModeOIDC }
func (p *OIDCProvider) SupportsRegistration() bool { return false }
func (p *OIDCProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) {
return nil, ErrNotSupported
}
// Authenticate validates a bearer token from the OIDC IdP.
//
// For the authorization code callback flow, use ExchangeCode() first
// to get the token, then call Authenticate with the token in the
// Authorization header.
func (p *OIDCProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
// Extract token from Authorization header
header := c.GetHeader("Authorization")
if header == "" {
return nil, fmt.Errorf("%w: missing Authorization header", ErrInvalidCreds)
}
tokenString := strings.TrimPrefix(header, "Bearer ")
if tokenString == header {
return nil, fmt.Errorf("%w: invalid authorization format", ErrInvalidCreds)
}
// Validate and parse the token
claims, err := p.validateToken(tokenString)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidCreds, err)
}
ctx := c.Request.Context()
sub := claims.Subject
// Look up existing user by external ID
user, err := stores.Users.GetByExternalID(ctx, string(ModeOIDC), sub)
if err == nil && user != nil {
if !user.IsActive {
return nil, ErrInactive
}
// Sync Admins group based on IdP role claim
if p.isIdPAdmin(claims) {
AddToAdminsGroup(ctx, stores, user.ID)
}
// Sync groups
p.syncGroups(ctx, user.ID, claims, stores)
log.Printf("[auth/oidc] existing user %s (sub=%s)", user.Username, sub)
return &Result{User: user, IsNewUser: false, VaultHint: ""}, nil
}
// Auto-provision
if !p.cfg.AutoActivate {
return nil, fmt.Errorf("%w: auto-provision disabled", ErrInvalidCreds)
}
return p.autoProvision(ctx, claims, stores)
}
// ExchangeCode exchanges an authorization code for tokens.
// Called by the /auth/oidc/callback handler.
func (p *OIDCProvider) ExchangeCode(ctx context.Context, code, redirectURI string) (*oidcTokenResponse, error) {
if p.discovery == nil {
return nil, fmt.Errorf("OIDC discovery not loaded")
}
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
"client_id": {p.cfg.ClientID},
}
if p.cfg.ClientSecret != "" {
data.Set("client_secret", p.cfg.ClientSecret)
}
resp, err := p.client.PostForm(p.discovery.TokenEndpoint, data)
if err != nil {
return nil, fmt.Errorf("token exchange failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp oidcTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("decode token response: %w", err)
}
return &tokenResp, nil
}
// AuthorizationURL returns the IdP login URL for the authorization code flow.
func (p *OIDCProvider) AuthorizationURL(state, nonce, redirectURI string) string {
if p.discovery == nil {
return ""
}
params := url.Values{
"response_type": {"code"},
"client_id": {p.cfg.ClientID},
"redirect_uri": {redirectURI},
"scope": {"openid profile email"},
"state": {state},
"nonce": {nonce},
}
authEndpoint := p.discovery.AuthorizationEndpoint
if p.cfg.ExternalIssuerURL != "" {
// Rewrite internal hostname to external for browser redirect
authEndpoint = strings.Replace(authEndpoint,
strings.TrimRight(p.cfg.IssuerURL, "/"),
strings.TrimRight(p.cfg.ExternalIssuerURL, "/"), 1)
}
return authEndpoint + "?" + params.Encode()
}
// ── Token Validation ────────────────────────────────────────────────
type oidcClaims struct {
jwt.RegisteredClaims
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
Groups []string `json:"groups"`
RealmAccess realmAccess `json:"realm_access"`
ResourceAccess interface{} `json:"resource_access"`
}
type realmAccess struct {
Roles []string `json:"roles"`
}
func (p *OIDCProvider) validateToken(tokenString string) (*oidcClaims, error) {
claims := &oidcClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
// Ensure RSA signing
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
kid, _ := t.Header["kid"].(string)
if kid == "" {
return nil, fmt.Errorf("token has no kid header")
}
key := p.getKey(kid)
if key == nil {
// Key not found — try refreshing JWKS (key rotation)
if err := p.refreshKeys(); err != nil {
return nil, fmt.Errorf("JWKS refresh failed: %w", err)
}
key = p.getKey(kid)
}
if key == nil {
return nil, fmt.Errorf("unknown signing key: %s", kid)
}
return key, nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, fmt.Errorf("token is invalid")
}
// Verify issuer — accept both internal and external (split-horizon).
// In Docker/K8s, the backend reaches Keycloak via internal hostname
// but the browser (and thus the token's iss claim) uses the external URL.
issuer := strings.TrimRight(p.cfg.IssuerURL, "/")
externalIssuer := strings.TrimRight(p.cfg.ExternalIssuerURL, "/")
tokenIssuer := strings.TrimRight(claims.Issuer, "/")
if tokenIssuer != issuer && (externalIssuer == "" || tokenIssuer != externalIssuer) {
return nil, fmt.Errorf("issuer mismatch: got %s, want %s (or %s)", claims.Issuer, issuer, externalIssuer)
}
return claims, nil
}
// isIdPAdmin checks whether the OIDC claims include the configured admin role.
func (p *OIDCProvider) isIdPAdmin(claims *oidcClaims) bool {
for _, role := range claims.RealmAccess.Roles {
if role == p.cfg.AdminRole {
return true
}
}
return false
}
// syncGroups compares IdP groups claim with internal group memberships
// and adds/removes as needed. OIDC-synced groups have source='oidc'.
func (p *OIDCProvider) syncGroups(ctx context.Context, userID string, claims *oidcClaims, stores store.Stores) {
idpGroups := claims.Groups
if len(idpGroups) == 0 {
// Also try extracting from a nested claim path
idpGroups = p.extractGroups(claims)
}
if len(idpGroups) == 0 {
return
}
// Get user's current group IDs
currentGroupIDs, err := stores.Groups.GetUserGroupIDs(ctx, userID)
if err != nil {
log.Printf("[auth/oidc] warn: could not get user groups: %v", err)
return
}
// Build set of current OIDC-sourced groups the user belongs to
currentOIDCGroups := make(map[string]string) // group name → group ID
for _, gid := range currentGroupIDs {
g, err := stores.Groups.GetByID(ctx, gid)
if err != nil {
continue
}
// Only track OIDC-sourced groups — manual groups are untouched
if g.Source == "oidc" {
currentOIDCGroups[g.Name] = g.ID
}
}
// Ensure each IdP group exists and user is a member
idpGroupSet := make(map[string]bool)
for _, gname := range idpGroups {
gname = strings.TrimSpace(gname)
if gname == "" {
continue
}
idpGroupSet[gname] = true
if _, already := currentOIDCGroups[gname]; already {
continue // already a member
}
// Find or create the group
groupID := p.findOrCreateOIDCGroup(ctx, gname, userID, stores)
if groupID == "" {
continue
}
// Add membership
if err := stores.Groups.AddMember(ctx, groupID, userID, userID); err != nil {
log.Printf("[auth/oidc] warn: could not add %s to group %s: %v", userID, gname, err)
} else {
log.Printf("[auth/oidc] added user %s to OIDC group %s", userID, gname)
}
}
// Remove from OIDC groups no longer in the IdP claim
for gname, gid := range currentOIDCGroups {
if !idpGroupSet[gname] {
if err := stores.Groups.RemoveMember(ctx, gid, userID); err != nil {
log.Printf("[auth/oidc] warn: could not remove %s from group %s: %v", userID, gname, err)
} else {
log.Printf("[auth/oidc] removed user %s from OIDC group %s (no longer in IdP)", userID, gname)
}
}
}
}
func (p *OIDCProvider) findOrCreateOIDCGroup(ctx context.Context, name, createdBy string, stores store.Stores) string {
// Search existing OIDC-sourced groups by name
groups, err := stores.Groups.ListAll(ctx)
if err != nil {
return ""
}
for _, g := range groups {
if g.Name == name && g.Source == "oidc" {
return g.ID
}
}
// Create new OIDC group
g := &models.Group{
Name: name,
Description: "Synced from OIDC",
Scope: "global",
Source: "oidc",
CreatedBy: &createdBy,
}
if err := stores.Groups.Create(ctx, g); err != nil {
log.Printf("[auth/oidc] warn: could not create group %s: %v", name, err)
return ""
}
log.Printf("[auth/oidc] created OIDC group %s (%s)", name, g.ID)
return g.ID
}
func (p *OIDCProvider) extractGroups(claims *oidcClaims) []string {
// Keycloak puts groups in different places depending on mapper config.
// Try the configured claim path as a fallback.
// The claims.Groups field handles the simple case. This handles
// nested paths like "resource_access.switchboard.roles".
// For now, return empty — the direct Groups field covers the
// standard Keycloak group mapper output.
return nil
}
func (p *OIDCProvider) autoProvision(
ctx context.Context,
claims *oidcClaims,
stores store.Stores,
) (*Result, error) {
sub := claims.Subject
username := claims.PreferredUsername
if username == "" {
username = claims.Email
}
if username == "" {
username = sub
}
email := claims.Email
if email == "" {
email = models.HandleFromName(username) + "@oidc.local"
}
displayName := claims.Name
if displayName == "" {
displayName = username
}
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(username))
user := &models.User{
Username: strings.ToLower(username),
Email: strings.ToLower(email),
DisplayName: displayName,
IsActive: true,
AuthSource: string(ModeOIDC),
ExternalID: &sub,
Handle: handle,
}
if err := stores.Users.Create(ctx, user); err != nil {
return nil, fmt.Errorf("auto-provision failed: %w", err)
}
log.Printf("[auth/oidc] auto-provisioned user %s (sub=%s, email=%s)", user.ID, sub, email)
EnsureEveryoneGroup(ctx, stores, user.ID)
if p.isIdPAdmin(claims) {
AddToAdminsGroup(ctx, stores, user.ID)
}
// Auto-add to default team
if p.cfg.DefaultTeam != "" {
if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil {
log.Printf("[auth/oidc] warn: could not add %s to default team: %v", user.ID, err)
}
}
// Sync groups
p.syncGroups(ctx, user.ID, claims, stores)
return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil
}
// ── JWKS Management ─────────────────────────────────────────────────
func (p *OIDCProvider) fetchDiscovery() error {
discoveryURL := strings.TrimRight(p.cfg.IssuerURL, "/") + "/.well-known/openid-configuration"
resp, err := p.client.Get(discoveryURL)
if err != nil {
return fmt.Errorf("fetch %s: %w", discoveryURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("discovery returned %d: %s", resp.StatusCode, string(body))
}
var disc OIDCDiscovery
if err := json.NewDecoder(resp.Body).Decode(&disc); err != nil {
return fmt.Errorf("decode discovery: %w", err)
}
p.discovery = &disc
log.Printf("[auth/oidc] discovery loaded: issuer=%s, jwks=%s", disc.Issuer, disc.JWKSUri)
return nil
}
func (p *OIDCProvider) refreshKeys() error {
if p.discovery == nil || p.discovery.JWKSUri == "" {
return fmt.Errorf("no JWKS URI (discovery not loaded)")
}
resp, err := p.client.Get(p.discovery.JWKSUri)
if err != nil {
return fmt.Errorf("fetch JWKS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("JWKS returned %d", resp.StatusCode)
}
var jwks oidcJWKS
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
return fmt.Errorf("decode JWKS: %w", err)
}
newKeys := make(map[string]*rsa.PublicKey)
for _, k := range jwks.Keys {
if k.Kty != "RSA" || k.Use != "sig" {
continue
}
pub, err := parseRSAPublicKey(k.N, k.E)
if err != nil {
log.Printf("[auth/oidc] warn: skip key %s: %v", k.Kid, err)
continue
}
newKeys[k.Kid] = pub
}
p.keysMu.Lock()
p.keys = newKeys
p.keysAt = time.Now()
p.keysMu.Unlock()
log.Printf("[auth/oidc] JWKS refreshed: %d signing keys", len(newKeys))
return nil
}
func (p *OIDCProvider) getKey(kid string) *rsa.PublicKey {
p.keysMu.RLock()
defer p.keysMu.RUnlock()
return p.keys[kid]
}
// parseRSAPublicKey builds an RSA public key from base64url-encoded N and E.
func parseRSAPublicKey(nB64, eB64 string) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
if err != nil {
return nil, fmt.Errorf("decode N: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
if err != nil {
return nil, fmt.Errorf("decode E: %w", err)
}
n := new(big.Int).SetBytes(nBytes)
e := new(big.Int).SetBytes(eBytes)
return &rsa.PublicKey{
N: n,
E: int(e.Int64()),
}, nil
}
// Discovery exposes the cached discovery document for route handlers.
func (p *OIDCProvider) Discovery() *OIDCDiscovery {
return p.discovery
}