Changeset 0.24.1 (#157)
This commit is contained in:
176
server/auth/mock_idp_test.go
Normal file
176
server/auth/mock_idp_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// mockIdP is a test OIDC identity provider that serves discovery,
|
||||
// JWKS, and token endpoints. It signs JWTs with a known RSA key
|
||||
// so the OIDCProvider can validate them in tests.
|
||||
type mockIdP struct {
|
||||
server *httptest.Server
|
||||
privateKey *rsa.PrivateKey
|
||||
kid string
|
||||
}
|
||||
|
||||
func newMockIdP() *mockIdP {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
panic("generate RSA key: " + err.Error())
|
||||
}
|
||||
|
||||
m := &mockIdP{
|
||||
privateKey: key,
|
||||
kid: "test-key-1",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", m.handleDiscovery)
|
||||
mux.HandleFunc("/protocol/openid-connect/certs", m.handleJWKS)
|
||||
mux.HandleFunc("/protocol/openid-connect/token", m.handleToken)
|
||||
|
||||
m.server = httptest.NewServer(mux)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockIdP) Close() {
|
||||
m.server.Close()
|
||||
}
|
||||
|
||||
func (m *mockIdP) IssuerURL() string {
|
||||
return m.server.URL
|
||||
}
|
||||
|
||||
func (m *mockIdP) handleDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||
base := m.server.URL
|
||||
disc := map[string]string{
|
||||
"issuer": base,
|
||||
"authorization_endpoint": base + "/protocol/openid-connect/auth",
|
||||
"token_endpoint": base + "/protocol/openid-connect/token",
|
||||
"jwks_uri": base + "/protocol/openid-connect/certs",
|
||||
"userinfo_endpoint": base + "/protocol/openid-connect/userinfo",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(disc)
|
||||
}
|
||||
|
||||
func (m *mockIdP) handleJWKS(w http.ResponseWriter, r *http.Request) {
|
||||
pub := m.privateKey.PublicKey
|
||||
jwks := map[string]interface{}{
|
||||
"keys": []map[string]string{
|
||||
{
|
||||
"kid": m.kid,
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"use": "sig",
|
||||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(jwks)
|
||||
}
|
||||
|
||||
func (m *mockIdP) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||
// Simple password grant for testing
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", 400)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
if username == "" {
|
||||
username = "testuser"
|
||||
}
|
||||
|
||||
token := m.issueToken(username, username+"@test.local", username, nil, nil)
|
||||
resp := map[string]interface{}{
|
||||
"access_token": token,
|
||||
"id_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// issueToken creates a signed JWT with the given claims.
|
||||
func (m *mockIdP) issueToken(sub, email, name string, groups []string, roles []string) string {
|
||||
claims := jwt.MapClaims{
|
||||
"iss": m.server.URL,
|
||||
"sub": sub,
|
||||
"aud": "switchboard",
|
||||
"exp": time.Now().Add(1 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"email": email,
|
||||
"preferred_username": name,
|
||||
"name": name,
|
||||
}
|
||||
if groups != nil {
|
||||
claims["groups"] = groups
|
||||
}
|
||||
if roles != nil {
|
||||
claims["realm_access"] = map[string]interface{}{
|
||||
"roles": roles,
|
||||
}
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = m.kid
|
||||
|
||||
signed, err := token.SignedString(m.privateKey)
|
||||
if err != nil {
|
||||
panic("sign token: " + err.Error())
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
// issueExpiredToken creates a token that's already expired.
|
||||
func (m *mockIdP) issueExpiredToken(sub string) string {
|
||||
claims := jwt.MapClaims{
|
||||
"iss": m.server.URL,
|
||||
"sub": sub,
|
||||
"aud": "switchboard",
|
||||
"exp": time.Now().Add(-1 * time.Hour).Unix(),
|
||||
"iat": time.Now().Add(-2 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = m.kid
|
||||
|
||||
signed, err := token.SignedString(m.privateKey)
|
||||
if err != nil {
|
||||
panic("sign expired token: " + err.Error())
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
// issueTokenBadIssuer creates a token with a mismatched issuer.
|
||||
func (m *mockIdP) issueTokenBadIssuer(sub string) string {
|
||||
claims := jwt.MapClaims{
|
||||
"iss": "https://evil.example.com",
|
||||
"sub": sub,
|
||||
"aud": "switchboard",
|
||||
"exp": time.Now().Add(1 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = m.kid
|
||||
|
||||
signed, err := token.SignedString(m.privateKey)
|
||||
if err != nil {
|
||||
panic("sign bad issuer token: " + err.Error())
|
||||
}
|
||||
return signed
|
||||
}
|
||||
184
server/auth/mtls.go
Normal file
184
server/auth/mtls.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// MTLSConfig holds mTLS-specific configuration.
|
||||
type MTLSConfig struct {
|
||||
HeaderDN string // header carrying cert DN (default "X-SSL-Client-DN")
|
||||
HeaderVerify string // header carrying verify status (default "X-SSL-Client-Verify")
|
||||
HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint")
|
||||
AutoActivate bool // auto-activate new users (default true)
|
||||
DefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
DefaultRole string // "user" (default) or "admin"
|
||||
}
|
||||
|
||||
// MTLSProvider authenticates via client certificate headers injected
|
||||
// by the TLS-terminating reverse proxy (nginx, Traefik, Istio).
|
||||
//
|
||||
// The backend never sees the actual TLS handshake — it trusts headers
|
||||
// injected by the proxy after cert validation. After reading and parsing
|
||||
// the DN, the provider resolves an existing user or auto-provisions a
|
||||
// new one, then returns a Result. The auth handler issues an internal JWT.
|
||||
//
|
||||
// Header flow:
|
||||
//
|
||||
// Client cert → nginx ssl_verify_client → injects X-SSL-Client-DN,
|
||||
// X-SSL-Client-Verify, X-SSL-Client-Fingerprint → backend reads headers.
|
||||
type MTLSProvider struct {
|
||||
cfg MTLSConfig
|
||||
}
|
||||
|
||||
func NewMTLSProvider(cfg MTLSConfig) *MTLSProvider {
|
||||
if cfg.HeaderDN == "" {
|
||||
cfg.HeaderDN = "X-SSL-Client-DN"
|
||||
}
|
||||
if cfg.HeaderVerify == "" {
|
||||
cfg.HeaderVerify = "X-SSL-Client-Verify"
|
||||
}
|
||||
if cfg.HeaderFingerprint == "" {
|
||||
cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint"
|
||||
}
|
||||
if cfg.DefaultRole == "" {
|
||||
cfg.DefaultRole = models.UserRoleUser
|
||||
}
|
||||
return &MTLSProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
func (p *MTLSProvider) Mode() Mode { return ModeMTLS }
|
||||
|
||||
func (p *MTLSProvider) SupportsRegistration() bool { return false }
|
||||
|
||||
func (p *MTLSProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) {
|
||||
return nil, ErrNotSupported
|
||||
}
|
||||
|
||||
// Authenticate reads the cert DN and verify status from proxy headers,
|
||||
// parses the DN fields, and resolves or auto-provisions the user.
|
||||
//
|
||||
// Returns ErrInvalidCreds when headers are missing or verify fails.
|
||||
// Returns ErrInactive when user exists but is deactivated.
|
||||
func (p *MTLSProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
|
||||
// ── Validate headers ───────────────────────────────────────────
|
||||
verify := c.GetHeader(p.cfg.HeaderVerify)
|
||||
if verify == "" {
|
||||
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderVerify)
|
||||
}
|
||||
// nginx: "SUCCESS", Traefik: "0" (both mean valid cert)
|
||||
if verify != "SUCCESS" && verify != "0" {
|
||||
return nil, fmt.Errorf("%w: cert verify=%s", ErrInvalidCreds, verify)
|
||||
}
|
||||
|
||||
dn := c.GetHeader(p.cfg.HeaderDN)
|
||||
if dn == "" {
|
||||
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderDN)
|
||||
}
|
||||
|
||||
fields := ParseDN(dn)
|
||||
cn := fields["CN"]
|
||||
if cn == "" {
|
||||
return nil, fmt.Errorf("%w: cert DN has no CN field", ErrInvalidCreds)
|
||||
}
|
||||
|
||||
// Stable external identity: fingerprint if available, else full DN
|
||||
fingerprint := c.GetHeader(p.cfg.HeaderFingerprint)
|
||||
if fingerprint == "" {
|
||||
fingerprint = dn
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// ── Look up existing user ──────────────────────────────────────
|
||||
user, err := stores.Users.GetByExternalID(ctx, string(ModeMTLS), fingerprint)
|
||||
if err == nil && user != nil {
|
||||
if !user.IsActive {
|
||||
return nil, ErrInactive
|
||||
}
|
||||
log.Printf("[auth/mtls] existing user %s (%s)", user.Username, cn)
|
||||
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, fields, fingerprint, stores)
|
||||
}
|
||||
|
||||
func (p *MTLSProvider) autoProvision(
|
||||
ctx context.Context,
|
||||
dn map[string]string,
|
||||
fingerprint string,
|
||||
stores store.Stores,
|
||||
) (*Result, error) {
|
||||
cn := dn["CN"]
|
||||
|
||||
email := dn["emailAddress"]
|
||||
if email == "" {
|
||||
email = models.HandleFromName(cn) + "@mtls.local"
|
||||
}
|
||||
|
||||
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(cn))
|
||||
|
||||
user := &models.User{
|
||||
Username: handle,
|
||||
Email: email,
|
||||
DisplayName: cn,
|
||||
Role: p.cfg.DefaultRole,
|
||||
IsActive: true,
|
||||
AuthSource: string(ModeMTLS),
|
||||
ExternalID: &fingerprint,
|
||||
Handle: handle,
|
||||
}
|
||||
|
||||
if err := stores.Users.Create(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("auto-provision failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn)
|
||||
|
||||
// Auto-add to default team if configured
|
||||
if p.cfg.DefaultTeam != "" {
|
||||
if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil {
|
||||
log.Printf("[auth/mtls] warn: could not add %s to default team: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil
|
||||
}
|
||||
|
||||
// ParseDN parses an RFC 2253 / RFC 4514 distinguished name into key-value pairs.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "CN=Jeff Smith,O=Acme Corp,OU=Engineering"
|
||||
// "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp"
|
||||
//
|
||||
// Handles simple comma-separated key=value pairs. Does NOT handle
|
||||
// escaped commas in values (\,) or multi-valued RDNs (+). Sufficient
|
||||
// for typical X.509 client cert DNs.
|
||||
func ParseDN(dn string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
parts := strings.Split(dn, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
idx := strings.Index(part, "=")
|
||||
if idx < 1 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(part[:idx])
|
||||
val := strings.TrimSpace(part[idx+1:])
|
||||
result[key] = val
|
||||
}
|
||||
return result
|
||||
}
|
||||
135
server/auth/mtls_test.go
Normal file
135
server/auth/mtls_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
func TestParseDN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dn string
|
||||
expect map[string]string
|
||||
}{
|
||||
{
|
||||
name: "standard cert DN",
|
||||
dn: "CN=Jeff Smith,O=Acme Corp,OU=Engineering",
|
||||
expect: map[string]string{
|
||||
"CN": "Jeff Smith",
|
||||
"O": "Acme Corp",
|
||||
"OU": "Engineering",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with email",
|
||||
dn: "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp",
|
||||
expect: map[string]string{
|
||||
"CN": "Jane Doe",
|
||||
"emailAddress": "jane@acme.com",
|
||||
"O": "Acme Corp",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "spaces around equals",
|
||||
dn: "CN = Test User , O = Test Org",
|
||||
expect: map[string]string{
|
||||
"CN": "Test User",
|
||||
"O": "Test Org",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single field",
|
||||
dn: "CN=Solo",
|
||||
expect: map[string]string{
|
||||
"CN": "Solo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
dn: "",
|
||||
expect: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "no equals sign",
|
||||
dn: "garbage,data",
|
||||
expect: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "country and state",
|
||||
dn: "CN=Server,C=US,ST=Maryland,L=Laurel,O=DoD",
|
||||
expect: map[string]string{
|
||||
"CN": "Server",
|
||||
"C": "US",
|
||||
"ST": "Maryland",
|
||||
"L": "Laurel",
|
||||
"O": "DoD",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ParseDN(tt.dn)
|
||||
for k, want := range tt.expect {
|
||||
got := result[k]
|
||||
if got != want {
|
||||
t.Errorf("ParseDN(%q)[%q] = %q, want %q", tt.dn, k, got, want)
|
||||
}
|
||||
}
|
||||
if len(result) != len(tt.expect) {
|
||||
t.Errorf("ParseDN(%q) returned %d fields, want %d", tt.dn, len(result), len(tt.expect))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSConfig_Defaults(t *testing.T) {
|
||||
p := NewMTLSProvider(MTLSConfig{})
|
||||
|
||||
if p.cfg.HeaderDN != "X-SSL-Client-DN" {
|
||||
t.Errorf("HeaderDN = %q, want X-SSL-Client-DN", p.cfg.HeaderDN)
|
||||
}
|
||||
if p.cfg.HeaderVerify != "X-SSL-Client-Verify" {
|
||||
t.Errorf("HeaderVerify = %q, want X-SSL-Client-Verify", p.cfg.HeaderVerify)
|
||||
}
|
||||
if p.cfg.HeaderFingerprint != "X-SSL-Client-Fingerprint" {
|
||||
t.Errorf("HeaderFingerprint = %q, want X-SSL-Client-Fingerprint", p.cfg.HeaderFingerprint)
|
||||
}
|
||||
if p.cfg.DefaultRole != "user" {
|
||||
t.Errorf("DefaultRole = %q, want user", p.cfg.DefaultRole)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSConfig_Custom(t *testing.T) {
|
||||
p := NewMTLSProvider(MTLSConfig{
|
||||
HeaderDN: "X-Client-Cert-DN",
|
||||
HeaderVerify: "X-Client-Cert-Verify",
|
||||
DefaultRole: "admin",
|
||||
})
|
||||
|
||||
if p.cfg.HeaderDN != "X-Client-Cert-DN" {
|
||||
t.Errorf("HeaderDN = %q, want X-Client-Cert-DN", p.cfg.HeaderDN)
|
||||
}
|
||||
if p.cfg.DefaultRole != "admin" {
|
||||
t.Errorf("DefaultRole = %q, want admin", p.cfg.DefaultRole)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSProvider_Mode(t *testing.T) {
|
||||
p := NewMTLSProvider(MTLSConfig{})
|
||||
if p.Mode() != ModeMTLS {
|
||||
t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSProvider_NoRegistration(t *testing.T) {
|
||||
p := NewMTLSProvider(MTLSConfig{})
|
||||
if p.SupportsRegistration() {
|
||||
t.Error("mTLS should not support registration")
|
||||
}
|
||||
_, err := p.Register(nil, store.Stores{})
|
||||
if err != ErrNotSupported {
|
||||
t.Errorf("Register() = %v, want ErrNotSupported", err)
|
||||
}
|
||||
}
|
||||
587
server/auth/oidc.go
Normal file
587
server/auth/oidc.go
Normal file
@@ -0,0 +1,587 @@
|
||||
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"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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)
|
||||
DefaultRole string // "user" (default) or "admin"
|
||||
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.DefaultRole == "" {
|
||||
cfg.DefaultRole = models.UserRoleUser
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Re-evaluate role on every login
|
||||
role := p.resolveRole(claims)
|
||||
if role != user.Role {
|
||||
stores.Users.Update(ctx, user.ID, map[string]interface{}{"role": role})
|
||||
user.Role = role
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) resolveRole(claims *oidcClaims) string {
|
||||
// Check realm_access.roles for admin role
|
||||
for _, role := range claims.RealmAccess.Roles {
|
||||
if role == p.cfg.AdminRole {
|
||||
return models.UserRoleAdmin
|
||||
}
|
||||
}
|
||||
return p.cfg.DefaultRole
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
role := p.resolveRole(claims)
|
||||
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(username))
|
||||
|
||||
user := &models.User{
|
||||
Username: strings.ToLower(username),
|
||||
Email: strings.ToLower(email),
|
||||
DisplayName: displayName,
|
||||
Role: role,
|
||||
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)
|
||||
|
||||
// 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
|
||||
}
|
||||
136
server/auth/oidc_test.go
Normal file
136
server/auth/oidc_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/auth"
|
||||
)
|
||||
|
||||
func TestOIDCProvider_Discovery(t *testing.T) {
|
||||
idp := newMockIdP()
|
||||
defer idp.Close()
|
||||
|
||||
p, err := auth.NewOIDCProvider(auth.OIDCConfig{
|
||||
IssuerURL: idp.IssuerURL(),
|
||||
ClientID: "switchboard",
|
||||
ClientSecret: "secret",
|
||||
AutoActivate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOIDCProvider: %v", err)
|
||||
}
|
||||
|
||||
disc := p.Discovery()
|
||||
if disc == nil {
|
||||
t.Fatal("discovery should not be nil")
|
||||
}
|
||||
if disc.JWKSUri == "" {
|
||||
t.Error("JWKS URI should not be empty")
|
||||
}
|
||||
if disc.TokenEndpoint == "" {
|
||||
t.Error("token endpoint should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCProvider_Mode(t *testing.T) {
|
||||
idp := newMockIdP()
|
||||
defer idp.Close()
|
||||
|
||||
p, err := auth.NewOIDCProvider(auth.OIDCConfig{
|
||||
IssuerURL: idp.IssuerURL(),
|
||||
ClientID: "switchboard",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOIDCProvider: %v", err)
|
||||
}
|
||||
|
||||
if p.Mode() != auth.ModeOIDC {
|
||||
t.Errorf("Mode() = %q, want %q", p.Mode(), auth.ModeOIDC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCProvider_NoRegistration(t *testing.T) {
|
||||
idp := newMockIdP()
|
||||
defer idp.Close()
|
||||
|
||||
p, err := auth.NewOIDCProvider(auth.OIDCConfig{
|
||||
IssuerURL: idp.IssuerURL(),
|
||||
ClientID: "switchboard",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOIDCProvider: %v", err)
|
||||
}
|
||||
|
||||
if p.SupportsRegistration() {
|
||||
t.Error("OIDC should not support registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCProvider_MissingIssuer(t *testing.T) {
|
||||
_, err := auth.NewOIDCProvider(auth.OIDCConfig{
|
||||
ClientID: "switchboard",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for missing issuer URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCProvider_MissingClientID(t *testing.T) {
|
||||
idp := newMockIdP()
|
||||
defer idp.Close()
|
||||
|
||||
_, err := auth.NewOIDCProvider(auth.OIDCConfig{
|
||||
IssuerURL: idp.IssuerURL(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for missing client ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCProvider_AuthorizationURL(t *testing.T) {
|
||||
idp := newMockIdP()
|
||||
defer idp.Close()
|
||||
|
||||
p, err := auth.NewOIDCProvider(auth.OIDCConfig{
|
||||
IssuerURL: idp.IssuerURL(),
|
||||
ClientID: "switchboard",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOIDCProvider: %v", err)
|
||||
}
|
||||
|
||||
url := p.AuthorizationURL("test-state", "test-nonce", "http://localhost/callback")
|
||||
if url == "" {
|
||||
t.Fatal("AuthorizationURL should not be empty")
|
||||
}
|
||||
|
||||
// Should contain the required OIDC parameters
|
||||
for _, want := range []string{"response_type=code", "client_id=switchboard", "state=test-state", "nonce=test-nonce"} {
|
||||
if !containsStr(url, want) {
|
||||
t.Errorf("AuthorizationURL missing %q in %q", want, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCProvider_UnreachableIssuer(t *testing.T) {
|
||||
_, err := auth.NewOIDCProvider(auth.OIDCConfig{
|
||||
IssuerURL: "http://127.0.0.1:1/unreachable",
|
||||
ClientID: "switchboard",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for unreachable issuer")
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
|
||||
}
|
||||
|
||||
func containsSubstring(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user