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
|
||||
}
|
||||
@@ -70,6 +70,28 @@ type Config struct {
|
||||
|
||||
// Auth mode (v0.24.0): "builtin" (default) | "mtls" | "oidc"
|
||||
AuthMode string
|
||||
|
||||
// mTLS (v0.24.1)
|
||||
// Headers injected by TLS-terminating reverse proxy.
|
||||
MTLSHeaderDN string // default "X-SSL-Client-DN"
|
||||
MTLSHeaderVerify string // default "X-SSL-Client-Verify"
|
||||
MTLSHeaderFingerprint string // default "X-SSL-Client-Fingerprint"
|
||||
MTLSAutoActivate bool // auto-activate new users (default true)
|
||||
MTLSDefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
MTLSDefaultRole string // "user" (default) or "admin"
|
||||
|
||||
// OIDC (v0.24.1)
|
||||
OIDCIssuerURL string // e.g. "https://keycloak.corp/realms/switchboard"
|
||||
OIDCExternalIssuerURL string // OIDC_EXTERNAL_ISSUER_URL
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCRedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback"
|
||||
OIDCAutoActivate bool // auto-activate new users (default true)
|
||||
OIDCDefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
OIDCDefaultRole string // "user" (default) or "admin"
|
||||
OIDCRolesClaim string // JWT claim for role mapping (default "realm_access.roles")
|
||||
OIDCGroupsClaim string // JWT claim for group sync (default "groups")
|
||||
OIDCAdminRole string // IdP role value that maps to admin (default "admin")
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
@@ -110,6 +132,27 @@ func Load() *Config {
|
||||
ProviderAutoDisableThreshold: getEnvInt("PROVIDER_AUTO_DISABLE_THRESHOLD", 3),
|
||||
|
||||
AuthMode: getEnv("AUTH_MODE", "builtin"),
|
||||
|
||||
// mTLS
|
||||
MTLSHeaderDN: getEnv("MTLS_HEADER_DN", "X-SSL-Client-DN"),
|
||||
MTLSHeaderVerify: getEnv("MTLS_HEADER_VERIFY", "X-SSL-Client-Verify"),
|
||||
MTLSHeaderFingerprint: getEnv("MTLS_HEADER_FINGERPRINT", "X-SSL-Client-Fingerprint"),
|
||||
MTLSAutoActivate: getEnvBool("MTLS_AUTO_ACTIVATE", true),
|
||||
MTLSDefaultTeam: getEnv("MTLS_DEFAULT_TEAM", ""),
|
||||
MTLSDefaultRole: getEnv("MTLS_DEFAULT_ROLE", "user"),
|
||||
|
||||
// OIDC
|
||||
OIDCIssuerURL: getEnv("OIDC_ISSUER_URL", ""),
|
||||
OIDCExternalIssuerURL: getEnv("OIDC_EXTERNAL_ISSUER_URL", ""),
|
||||
OIDCClientID: getEnv("OIDC_CLIENT_ID", ""),
|
||||
OIDCClientSecret: getEnv("OIDC_CLIENT_SECRET", ""),
|
||||
OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""),
|
||||
OIDCAutoActivate: getEnvBool("OIDC_AUTO_ACTIVATE", true),
|
||||
OIDCDefaultTeam: getEnv("OIDC_DEFAULT_TEAM", ""),
|
||||
OIDCDefaultRole: getEnv("OIDC_DEFAULT_ROLE", "user"),
|
||||
OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"),
|
||||
OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"),
|
||||
OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -52,6 +54,112 @@ func Q(query string) string {
|
||||
return q
|
||||
}
|
||||
|
||||
// QArgs adapts a Postgres query AND its args for the current dialect.
|
||||
//
|
||||
// Unlike Q() which only rewrites the query string, QArgs also expands the
|
||||
// args slice to match positional ? placeholders. In Postgres, $1 can appear
|
||||
// multiple times and references the same arg. In SQLite, each ? is
|
||||
// positional — if $1 appears twice, the arg must appear twice.
|
||||
//
|
||||
// Use QArgs for any handler query that references $N more than once
|
||||
// (typically subqueries like "WHERE user_id = $1 OR ... participant_id = $1").
|
||||
//
|
||||
// On Postgres, returns query and args unchanged.
|
||||
func QArgs(query string, args ...interface{}) (string, []interface{}) {
|
||||
if !IsSQLite() {
|
||||
return query, args
|
||||
}
|
||||
|
||||
// First, apply the non-placeholder rewrites
|
||||
q := query
|
||||
q = strings.ReplaceAll(q, "::jsonb", "")
|
||||
q = strings.ReplaceAll(q, "::text", "")
|
||||
q = strings.ReplaceAll(q, "NOW()", "datetime('now')")
|
||||
q = strings.ReplaceAll(q, "= true", "= 1")
|
||||
q = strings.ReplaceAll(q, "= false", "= 0")
|
||||
q = strings.ReplaceAll(q, "COALESCE(is_private, false)", "COALESCE(is_private, 0)")
|
||||
q = strings.ReplaceAll(q, "NULLS LAST", "")
|
||||
q = strings.ReplaceAll(q, "ILIKE", "LIKE")
|
||||
|
||||
// Walk the query left-to-right, find each $N, record which arg index
|
||||
// it maps to, replace with ?, and build a new args list in order.
|
||||
var newArgs []interface{}
|
||||
var result strings.Builder
|
||||
i := 0
|
||||
for i < len(q) {
|
||||
if q[i] == '$' && i+1 < len(q) && q[i+1] >= '1' && q[i+1] <= '9' {
|
||||
// Parse the number after $
|
||||
j := i + 1
|
||||
for j < len(q) && q[j] >= '0' && q[j] <= '9' {
|
||||
j++
|
||||
}
|
||||
numStr := q[i+1 : j]
|
||||
n := 0
|
||||
for _, c := range numStr {
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
// $N is 1-based, args is 0-based
|
||||
if n >= 1 && n <= len(args) {
|
||||
newArgs = append(newArgs, args[n-1])
|
||||
}
|
||||
result.WriteByte('?')
|
||||
i = j
|
||||
} else {
|
||||
result.WriteByte(q[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return result.String(), newArgs
|
||||
}
|
||||
|
||||
// ── Safe Query Wrappers ─────────────────────
|
||||
//
|
||||
// These wrappers replace the pattern:
|
||||
// database.DB.QueryRow(database.Q(sql), args...)
|
||||
// with:
|
||||
// database.QueryRow(sql, args...)
|
||||
//
|
||||
// They call QArgs internally, handling $N→? conversion AND arg expansion
|
||||
// for repeated placeholder references. Use these for ALL handler-level
|
||||
// SQL that uses $N placeholders.
|
||||
|
||||
// QueryRow executes a query with dialect adaptation and arg expansion.
|
||||
func QueryRow(query string, args ...interface{}) *sql.Row {
|
||||
q, expanded := QArgs(query, args...)
|
||||
return DB.QueryRow(q, expanded...)
|
||||
}
|
||||
|
||||
// QueryRowContext is the context-aware variant of QueryRow.
|
||||
func QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row {
|
||||
q, expanded := QArgs(query, args...)
|
||||
return DB.QueryRowContext(ctx, q, expanded...)
|
||||
}
|
||||
|
||||
// Query executes a query returning rows, with dialect adaptation.
|
||||
func Query(query string, args ...interface{}) (*sql.Rows, error) {
|
||||
q, expanded := QArgs(query, args...)
|
||||
return DB.Query(q, expanded...)
|
||||
}
|
||||
|
||||
// QueryContext is the context-aware variant of Query.
|
||||
func QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
|
||||
q, expanded := QArgs(query, args...)
|
||||
return DB.QueryContext(ctx, q, expanded...)
|
||||
}
|
||||
|
||||
// Exec executes a statement with dialect adaptation.
|
||||
func Exec(query string, args ...interface{}) (sql.Result, error) {
|
||||
q, expanded := QArgs(query, args...)
|
||||
return DB.Exec(q, expanded...)
|
||||
}
|
||||
|
||||
// ExecContext is the context-aware variant of Exec.
|
||||
func ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
|
||||
q, expanded := QArgs(query, args...)
|
||||
return DB.ExecContext(ctx, q, expanded...)
|
||||
}
|
||||
|
||||
// InsertReturningID executes an INSERT … RETURNING id query.
|
||||
// On Postgres it uses RETURNING directly.
|
||||
// On SQLite it strips RETURNING, generates a UUID, prepends it to the args,
|
||||
|
||||
129
server/database/compat_test.go
Normal file
129
server/database/compat_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQArgs_Postgres(t *testing.T) {
|
||||
// Force Postgres dialect for test
|
||||
CurrentDialect = DialectPostgres
|
||||
defer func() { CurrentDialect = DialectSQLite }()
|
||||
|
||||
q, args := QArgs("SELECT * FROM t WHERE a = $1 AND b = $1", "val1")
|
||||
if q != "SELECT * FROM t WHERE a = $1 AND b = $1" {
|
||||
t.Errorf("Postgres: query should be unchanged, got %q", q)
|
||||
}
|
||||
if len(args) != 1 {
|
||||
t.Errorf("Postgres: args should be unchanged, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQArgs_SQLite_ReusedPlaceholder(t *testing.T) {
|
||||
CurrentDialect = DialectSQLite
|
||||
defer func() { CurrentDialect = DialectPostgres }()
|
||||
|
||||
// $1 appears twice — must be expanded to two ? with the same arg value
|
||||
q, args := QArgs(
|
||||
"SELECT COUNT(*) FROM channels c WHERE c.user_id = $1 OR c.id IN (SELECT channel_id FROM cp WHERE cp.user_id = $1) AND c.is_archived = $2",
|
||||
"user-123", false,
|
||||
)
|
||||
|
||||
// Should have 3 ? placeholders
|
||||
qCount := 0
|
||||
for _, c := range q {
|
||||
if c == '?' {
|
||||
qCount++
|
||||
}
|
||||
}
|
||||
if qCount != 3 {
|
||||
t.Errorf("expected 3 ? placeholders, got %d in %q", qCount, q)
|
||||
}
|
||||
|
||||
// Should have 3 args: user-123, user-123, false
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("expected 3 args, got %d: %v", len(args), args)
|
||||
}
|
||||
if args[0] != "user-123" {
|
||||
t.Errorf("args[0] = %v, want user-123", args[0])
|
||||
}
|
||||
if args[1] != "user-123" {
|
||||
t.Errorf("args[1] = %v, want user-123 (expanded from reused $1)", args[1])
|
||||
}
|
||||
if args[2] != false {
|
||||
t.Errorf("args[2] = %v, want false", args[2])
|
||||
}
|
||||
|
||||
// Should not contain $N
|
||||
if contains(q, "$") {
|
||||
t.Errorf("query still contains $: %q", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQArgs_SQLite_NoReuse(t *testing.T) {
|
||||
CurrentDialect = DialectSQLite
|
||||
defer func() { CurrentDialect = DialectPostgres }()
|
||||
|
||||
q, args := QArgs("SELECT * FROM t WHERE a = $1 AND b = $2", "a", "b")
|
||||
|
||||
qCount := 0
|
||||
for _, c := range q {
|
||||
if c == '?' {
|
||||
qCount++
|
||||
}
|
||||
}
|
||||
if qCount != 2 {
|
||||
t.Errorf("expected 2 ? placeholders, got %d", qCount)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Errorf("expected 2 args, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQArgs_SQLite_ILIKE(t *testing.T) {
|
||||
CurrentDialect = DialectSQLite
|
||||
defer func() { CurrentDialect = DialectPostgres }()
|
||||
|
||||
q, _ := QArgs("SELECT * FROM t WHERE name ILIKE $1", "%test%")
|
||||
if contains(q, "ILIKE") {
|
||||
t.Errorf("ILIKE should be rewritten to LIKE: %q", q)
|
||||
}
|
||||
if !contains(q, "LIKE") {
|
||||
t.Errorf("should contain LIKE: %q", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQArgs_SQLite_HighPlaceholders(t *testing.T) {
|
||||
CurrentDialect = DialectSQLite
|
||||
defer func() { CurrentDialect = DialectPostgres }()
|
||||
|
||||
q, args := QArgs(
|
||||
"INSERT INTO t (a,b,c,d,e,f,g,h,i,j,k) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)",
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
|
||||
)
|
||||
|
||||
qCount := 0
|
||||
for _, c := range q {
|
||||
if c == '?' {
|
||||
qCount++
|
||||
}
|
||||
}
|
||||
if qCount != 11 {
|
||||
t.Errorf("expected 11 ?, got %d in %q", qCount, q)
|
||||
}
|
||||
if len(args) != 11 {
|
||||
t.Errorf("expected 11 args, got %d", len(args))
|
||||
}
|
||||
// $10 and $11 should not be mangled
|
||||
if contains(q, "$") {
|
||||
t.Errorf("still contains $: %q", q)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -95,9 +95,9 @@ func connectSQLite(dsn string) error {
|
||||
// _time_format tells the driver how to convert between Go time.Time and SQLite TEXT timestamps.
|
||||
connStr := dsn
|
||||
if dsn != ":memory:" && !strings.Contains(dsn, "?") {
|
||||
connStr = fmt.Sprintf("file:%s?_pragma=journal_mode%%3Dwal&_pragma=busy_timeout%%3D5000&_pragma=foreign_keys%%3Don&_time_format=2006-01-02T15%%3A04%%3A05Z", dsn)
|
||||
connStr = fmt.Sprintf("file:%s?_pragma=journal_mode%%3Dwal&_pragma=busy_timeout%%3D5000&_pragma=foreign_keys%%3Don", dsn)
|
||||
} else if dsn == ":memory:" {
|
||||
connStr = "file::memory:?_pragma=foreign_keys%%3Don&_time_format=2006-01-02T15%%3A04%%3A05Z"
|
||||
connStr = "file::memory:?_pragma=foreign_keys%%3Don"
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
30
server/database/migrations/019_auth_providers.sql
Normal file
30
server/database/migrations/019_auth_providers.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 019 Auth Providers
|
||||
-- ==========================================
|
||||
-- OIDC authorization state tracking.
|
||||
-- Group source column for OIDC-synced groups.
|
||||
-- ICD §1 (Users), §2 (Groups) — v0.24.1
|
||||
-- ==========================================
|
||||
|
||||
-- ── OIDC authorization state (short-lived) ───────────────────────
|
||||
-- Stores state + nonce for the authorization code flow.
|
||||
-- Rows deleted after callback completes. Stale rows cleaned by age.
|
||||
CREATE TABLE IF NOT EXISTS oidc_auth_state (
|
||||
state TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
redirect_to TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_state_created
|
||||
ON oidc_auth_state(created_at);
|
||||
|
||||
-- ── Group source tracking ────────────────────────────────────────
|
||||
-- Distinguishes manually-created groups from OIDC-synced groups.
|
||||
-- OIDC groups are managed exclusively by the sync process.
|
||||
ALTER TABLE groups
|
||||
ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'manual'
|
||||
CHECK (source IN ('manual', 'oidc'));
|
||||
|
||||
COMMENT ON TABLE oidc_auth_state IS 'Ephemeral OIDC authorization code flow state. Rows deleted after callback.';
|
||||
COMMENT ON COLUMN groups.source IS 'manual=admin-created, oidc=synced from IdP groups claim';
|
||||
13
server/database/migrations/sqlite/019_auth_providers.sql
Normal file
13
server/database/migrations/sqlite/019_auth_providers.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Chat Switchboard — 019 Auth Providers (SQLite) (v0.24.1)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oidc_auth_state (
|
||||
state TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
redirect_to TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_state_created
|
||||
ON oidc_auth_state(created_at);
|
||||
|
||||
ALTER TABLE groups ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
|
||||
@@ -3,7 +3,10 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -155,6 +158,153 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
// ── OIDC Flow ──────────────────────────────
|
||||
|
||||
// OIDCLogin initiates the authorization code flow by redirecting to the IdP.
|
||||
// GET /api/v1/auth/oidc/login
|
||||
func (h *AuthHandler) OIDCLogin(c *gin.Context) {
|
||||
oidcProv, ok := h.provider.(*auth.OIDCProvider)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
state := uuid.New().String()
|
||||
nonce := uuid.New().String()
|
||||
|
||||
// Determine redirect URI
|
||||
redirectURI := h.cfg.OIDCRedirectURL
|
||||
if redirectURI == "" {
|
||||
// Auto-derive from request
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
scheme = "http"
|
||||
}
|
||||
redirectURI = scheme + "://" + c.Request.Host + h.cfg.BasePath + "/api/v1/auth/oidc/callback"
|
||||
}
|
||||
|
||||
// Store state for callback verification
|
||||
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
INSERT INTO oidc_auth_state (state, nonce, redirect_to) VALUES ($1, $2, $3)
|
||||
`), state, nonce, c.Query("redirect"))
|
||||
if err != nil {
|
||||
log.Printf("[auth/oidc] warn: could not store state: %v", err)
|
||||
}
|
||||
|
||||
authURL := oidcProv.AuthorizationURL(state, nonce, redirectURI)
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// OIDCCallback handles the IdP redirect after successful authentication.
|
||||
// GET /api/v1/auth/oidc/callback?code=...&state=...
|
||||
func (h *AuthHandler) OIDCCallback(c *gin.Context) {
|
||||
oidcProv, ok := h.provider.(*auth.OIDCProvider)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
|
||||
// Check for IdP error response (Keycloak returns ?error=...&error_description=...)
|
||||
if errCode := c.Query("error"); errCode != "" {
|
||||
errDesc := c.Query("error_description")
|
||||
log.Printf("[auth/oidc] IdP returned error: %s — %s", errCode, errDesc)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "IdP error: " + errCode, "description": errDesc})
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" || state == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify state
|
||||
var nonce, redirectTo string
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT nonce, COALESCE(redirect_to, '') FROM oidc_auth_state WHERE state = $1
|
||||
`), state).Scan(&nonce, &redirectTo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired state"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up state (one-time use)
|
||||
database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
DELETE FROM oidc_auth_state WHERE state = $1
|
||||
`), state)
|
||||
|
||||
// Also clean up stale states (> 10 minutes old)
|
||||
if database.IsSQLite() {
|
||||
database.DB.ExecContext(c.Request.Context(),
|
||||
`DELETE FROM oidc_auth_state WHERE created_at < datetime('now', '-10 minutes')`)
|
||||
} else {
|
||||
database.DB.ExecContext(c.Request.Context(),
|
||||
`DELETE FROM oidc_auth_state WHERE created_at < NOW() - INTERVAL '10 minutes'`)
|
||||
}
|
||||
|
||||
// Determine redirect URI (must match what was sent in the login request)
|
||||
redirectURI := h.cfg.OIDCRedirectURL
|
||||
if redirectURI == "" {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
scheme = "http"
|
||||
}
|
||||
redirectURI = scheme + "://" + c.Request.Host + h.cfg.BasePath + "/api/v1/auth/oidc/callback"
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
tokenResp, err := oidcProv.ExchangeCode(c.Request.Context(), code, redirectURI)
|
||||
if err != nil {
|
||||
log.Printf("[auth/oidc] code exchange failed: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Use the ID token (or access token) to authenticate via the provider
|
||||
// Temporarily set the Authorization header so Authenticate() can read it
|
||||
tokenToValidate := tokenResp.IDToken
|
||||
if tokenToValidate == "" {
|
||||
tokenToValidate = tokenResp.AccessToken
|
||||
}
|
||||
c.Request.Header.Set("Authorization", "Bearer "+tokenToValidate)
|
||||
|
||||
result, err := oidcProv.Authenticate(c, h.stores)
|
||||
if err != nil {
|
||||
log.Printf("[auth/oidc] authenticate after code exchange failed: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Issue internal JWT
|
||||
tokens, err := h.generateTokens(result.User)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
|
||||
|
||||
// For browser flow: encode tokens into a fragment that the login page JS
|
||||
// can pick up, store in localStorage, and redirect to the app.
|
||||
// This avoids httpOnly cookie issues — JS needs the token in localStorage.
|
||||
accessToken, _ := tokens["access_token"].(string)
|
||||
refreshToken, _ := tokens["refresh_token"].(string)
|
||||
userJSON, _ := json.Marshal(tokens["user"])
|
||||
|
||||
// Set page-auth cookie too (for SSR middleware)
|
||||
c.SetCookie("sb_token", accessToken, 900, "/", "", false, false)
|
||||
|
||||
// Base64-encode the token payload for the fragment
|
||||
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
|
||||
accessToken, refreshToken, string(userJSON))
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
|
||||
|
||||
dest := h.cfg.BasePath + "/login#oidc_result=" + encoded
|
||||
c.Redirect(http.StatusFound, dest)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
||||
// Access token (15 min)
|
||||
accessClaims := Claims{
|
||||
|
||||
@@ -201,7 +201,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := database.DB.QueryRow(database.Q(countQuery), countArgs...).Scan(&total); err != nil {
|
||||
if err := database.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"})
|
||||
return
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
|
||||
args = append(args, perPage, offset)
|
||||
|
||||
rows, err := database.DB.Query(database.Q(query), args...)
|
||||
rows, err := database.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
|
||||
return
|
||||
|
||||
@@ -3299,3 +3299,45 @@ func TestIntegration_Messages_TreePath(t *testing.T) {
|
||||
t.Fatalf("last in path should be follow-up, got %q", last["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel List with Type Filter (regression: $1 reuse in SQLite) ──
|
||||
|
||||
func TestIntegration_ChannelListWithTypeFilter(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, token := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create a direct chat
|
||||
w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
|
||||
"title": "Test Direct Chat",
|
||||
"type": "direct",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List without type filter — should succeed
|
||||
w = h.request("GET", "/api/v1/channels?page=1&per_page=100", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list channels (no filter): want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List with single type filter
|
||||
w = h.request("GET", "/api/v1/channels?page=1&per_page=100&type=direct", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list channels (type=direct): want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List with multi-value types filter — this triggers the $1 reuse in the
|
||||
// count subquery. Before the QArgs fix, this returned 500 on SQLite.
|
||||
w = h.request("GET", "/api/v1/channels?page=1&per_page=100&types=direct,group", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list channels (types=direct,group): want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify the response is valid
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
// The response may use "channels" or "data" depending on the handler.
|
||||
// Verify the request succeeded (200) — that's the regression test.
|
||||
// Channel content verification is covered by other tests.
|
||||
}
|
||||
|
||||
@@ -94,7 +94,11 @@ func main() {
|
||||
keyResolver = crypto.NewKeyResolver(envKey, uekCache)
|
||||
|
||||
// Initialize store layer
|
||||
stores = postgres.NewStores(database.DB)
|
||||
if database.IsSQLite() {
|
||||
stores = sqliteStore.NewStores(database.DB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.DB)
|
||||
}
|
||||
|
||||
// Provider health accumulator (v0.22.0)
|
||||
if database.IsSQLite() {
|
||||
@@ -339,9 +343,32 @@ func main() {
|
||||
case auth.ModeBuiltin:
|
||||
authProvider = auth.NewBuiltinProvider()
|
||||
case auth.ModeMTLS:
|
||||
log.Fatal("❌ AUTH_MODE=mtls is not yet implemented (planned for v0.24.1)")
|
||||
authProvider = auth.NewMTLSProvider(auth.MTLSConfig{
|
||||
HeaderDN: cfg.MTLSHeaderDN,
|
||||
HeaderVerify: cfg.MTLSHeaderVerify,
|
||||
HeaderFingerprint: cfg.MTLSHeaderFingerprint,
|
||||
AutoActivate: cfg.MTLSAutoActivate,
|
||||
DefaultTeam: cfg.MTLSDefaultTeam,
|
||||
DefaultRole: cfg.MTLSDefaultRole,
|
||||
})
|
||||
case auth.ModeOIDC:
|
||||
log.Fatal("❌ AUTH_MODE=oidc is not yet implemented (planned for v0.24.1)")
|
||||
var err error
|
||||
authProvider, err = auth.NewOIDCProvider(auth.OIDCConfig{
|
||||
IssuerURL: cfg.OIDCIssuerURL,
|
||||
ExternalIssuerURL: cfg.OIDCExternalIssuerURL,
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret,
|
||||
RedirectURL: cfg.OIDCRedirectURL,
|
||||
AutoActivate: cfg.OIDCAutoActivate,
|
||||
DefaultTeam: cfg.OIDCDefaultTeam,
|
||||
DefaultRole: cfg.OIDCDefaultRole,
|
||||
RolesClaim: cfg.OIDCRolesClaim,
|
||||
GroupsClaim: cfg.OIDCGroupsClaim,
|
||||
AdminRole: cfg.OIDCAdminRole,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("❌ OIDC provider init failed: %v", err)
|
||||
}
|
||||
}
|
||||
log.Printf(" 🔑 Auth mode: %s", authMode)
|
||||
|
||||
@@ -373,6 +400,10 @@ func main() {
|
||||
authGroup.POST("/login", authH.Login)
|
||||
authGroup.POST("/refresh", authH.Refresh)
|
||||
authGroup.POST("/logout", authH.Logout)
|
||||
|
||||
// OIDC routes (only active when AUTH_MODE=oidc)
|
||||
authGroup.GET("/oidc/login", authH.OIDCLogin)
|
||||
authGroup.GET("/oidc/callback", authH.OIDCCallback)
|
||||
}
|
||||
|
||||
// ── Public extension assets ────────────────
|
||||
|
||||
@@ -864,6 +864,7 @@ type Group struct {
|
||||
Scope string `json:"scope" db:"scope"` // global, team
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
Source string `json:"source" db:"source"` // manual (default), oidc
|
||||
MemberCount int `json:"member_count,omitempty"` // computed, not a DB column
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ type PageData struct {
|
||||
LogoURL string // branding: custom logo URL
|
||||
Tagline string // branding: tagline under instance name
|
||||
RegistrationOpen bool // whether self-registration is enabled
|
||||
AuthMode string // v0.24.1: "builtin", "mtls", "oidc"
|
||||
}
|
||||
|
||||
// UserContext is the authenticated user's info available to templates.
|
||||
@@ -211,6 +212,7 @@ func (e *Engine) RenderLogin() gin.HandlerFunc {
|
||||
LogoURL: logoURL,
|
||||
Tagline: tagline,
|
||||
RegistrationOpen: regOpen,
|
||||
AuthMode: e.cfg.AuthMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,28 @@
|
||||
<p id="authSubtitle">Sign in to your Switchboard instance</p>
|
||||
</div>
|
||||
|
||||
{{if eq .AuthMode "oidc"}}
|
||||
<!-- SSO LOGIN -->
|
||||
<div class="anim anim-a2">
|
||||
<p style="color: var(--text-2); font-size: 0.9rem; margin-bottom: 1.5rem;">
|
||||
This instance uses Single Sign-On. Click below to authenticate with your identity provider.
|
||||
</p>
|
||||
<a class="btn-login" href="{{.BasePath}}/api/v1/auth/oidc/login" style="display:block; text-align:center; text-decoration:none;">
|
||||
Sign in with SSO
|
||||
</a>
|
||||
</div>
|
||||
{{else if eq .AuthMode "mtls"}}
|
||||
<!-- mTLS LOGIN -->
|
||||
<div class="anim anim-a2">
|
||||
<p style="color: var(--text-2); font-size: 0.9rem; margin-bottom: 1.5rem;">
|
||||
This instance uses client certificate authentication. Ensure your browser has a valid certificate installed.
|
||||
</p>
|
||||
<a class="btn-login" href="{{.BasePath}}/" style="display:block; text-align:center; text-decoration:none;">
|
||||
Continue with Certificate
|
||||
</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- BUILTIN LOGIN -->
|
||||
<div class="auth-tabs anim anim-a2">
|
||||
<button class="auth-tab active" id="tabLogin" onclick="_switchTab('login')">Log In</button>
|
||||
{{if .RegistrationOpen}}<button class="auth-tab" id="tabRegister" onclick="_switchTab('register')">Register</button>{{end}}
|
||||
@@ -251,6 +273,7 @@
|
||||
<button class="btn-login" id="regBtn" onclick="_doRegister()">Create Account</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}{{/* end auth mode conditional */}}
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Self-hosted instance · Your data stays on your infrastructure</p>
|
||||
@@ -272,29 +295,60 @@
|
||||
</script>
|
||||
<script src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
|
||||
<script>
|
||||
// ── OIDC SSO callback handler ───────────────
|
||||
// The OIDC callback redirects here with #oidc_result=<base64 JSON>.
|
||||
// We decode it, save to localStorage (same format as builtin login),
|
||||
// set the page-auth cookie, and redirect to the app.
|
||||
(function() {
|
||||
const hash = window.location.hash;
|
||||
if (!hash.startsWith('#oidc_result=')) return;
|
||||
try {
|
||||
const encoded = hash.slice('#oidc_result='.length);
|
||||
const json = atob(encoded);
|
||||
const data = JSON.parse(json);
|
||||
const base = window.__BASE__ || '';
|
||||
const storageKey = base ? 'sb_auth_' + base.replace(/\//g, '') : 'sb_auth';
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
user: data.user,
|
||||
}));
|
||||
document.cookie = 'sb_token=' + data.access_token + '; path=/; max-age=900; SameSite=Strict';
|
||||
window.location.replace(base + '/');
|
||||
} catch (e) {
|
||||
console.error('[oidc] failed to process SSO callback:', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Builtin auth handlers — safe to include always; no-op if DOM elements absent.
|
||||
function _switchTab(tab) {
|
||||
document.getElementById('loginForm').style.display = tab === 'login' ? '' : 'none';
|
||||
const lf = document.getElementById('loginForm');
|
||||
if (lf) lf.style.display = tab === 'login' ? '' : 'none';
|
||||
const regForm = document.getElementById('registerForm');
|
||||
if (regForm) regForm.style.display = tab === 'register' ? '' : 'none';
|
||||
document.getElementById('tabLogin').classList.toggle('active', tab === 'login');
|
||||
const regTab = document.getElementById('tabRegister');
|
||||
if (regTab) regTab.classList.toggle('active', tab === 'register');
|
||||
document.getElementById('authTitle').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||||
document.getElementById('authSubtitle').textContent = tab === 'login'
|
||||
document.getElementById('tabLogin')?.classList.toggle('active', tab === 'login');
|
||||
document.getElementById('tabRegister')?.classList.toggle('active', tab === 'register');
|
||||
const title = document.getElementById('authTitle');
|
||||
if (title) title.textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||||
const sub = document.getElementById('authSubtitle');
|
||||
if (sub) sub.textContent = tab === 'login'
|
||||
? 'Sign in to your Switchboard instance'
|
||||
: 'Join your team on Switchboard';
|
||||
document.getElementById('loginError').textContent = '';
|
||||
const loginErr = document.getElementById('loginError');
|
||||
if (loginErr) loginErr.textContent = '';
|
||||
const regErr = document.getElementById('regError');
|
||||
if (regErr) { regErr.textContent = ''; regErr.style.display = 'none'; }
|
||||
}
|
||||
|
||||
async function _doRegister() {
|
||||
const username = document.getElementById('regUsername').value.trim();
|
||||
const email = document.getElementById('regEmail').value.trim();
|
||||
const password = document.getElementById('regPassword').value;
|
||||
const confirm = document.getElementById('regConfirm').value;
|
||||
const username = document.getElementById('regUsername')?.value.trim();
|
||||
const email = document.getElementById('regEmail')?.value.trim();
|
||||
const password = document.getElementById('regPassword')?.value;
|
||||
const confirm = document.getElementById('regConfirm')?.value;
|
||||
const errEl = document.getElementById('regError');
|
||||
const btn = document.getElementById('regBtn');
|
||||
if (!errEl || !btn) return;
|
||||
|
||||
if (!username || !password) { errEl.textContent = 'Username and password are required'; errEl.style.display = ''; return; }
|
||||
if (password.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; errEl.style.display = ''; return; }
|
||||
@@ -322,7 +376,6 @@
|
||||
errEl.style.display = '';
|
||||
setTimeout(() => { errEl.style.color = ''; errEl.style.display = 'none'; _switchTab('login'); }, 3000);
|
||||
} else {
|
||||
// Auto-login
|
||||
const storageKey = base ? 'sb_auth_' + base.replace(/\//g, '') : 'sb_auth';
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
accessToken: data.access_token, refreshToken: data.refresh_token, user: data.user,
|
||||
@@ -338,12 +391,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Enter key handlers
|
||||
document.getElementById('loginPassword').addEventListener('keydown', e => {
|
||||
document.getElementById('loginPassword')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') Pages.doLogin();
|
||||
});
|
||||
document.getElementById('loginUsername').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('loginPassword').focus();
|
||||
document.getElementById('loginUsername')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('loginPassword')?.focus();
|
||||
});
|
||||
document.getElementById('regConfirm')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') _doRegister();
|
||||
|
||||
@@ -15,12 +15,15 @@ type GroupStore struct{}
|
||||
func NewGroupStore() *GroupStore { return &GroupStore{} }
|
||||
|
||||
func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
|
||||
if g.Source == "" {
|
||||
g.Source = "manual"
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO groups (name, description, scope, team_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO groups (name, description, scope, team_id, created_by, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
g.Name, g.Description, g.Scope,
|
||||
models.NullString(g.TeamID), g.CreatedBy,
|
||||
models.NullString(g.TeamID), g.CreatedBy, g.Source,
|
||||
).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt)
|
||||
}
|
||||
|
||||
@@ -30,10 +33,11 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g WHERE g.id = $1`, id).Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
|
||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount,
|
||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -81,7 +85,8 @@ func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
ORDER BY g.scope, g.name`)
|
||||
}
|
||||
@@ -90,7 +95,8 @@ func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.G
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
WHERE g.scope = 'team' AND g.team_id = $1
|
||||
ORDER BY g.name`, teamID)
|
||||
@@ -100,7 +106,8 @@ func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.G
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = $1)
|
||||
ORDER BY g.scope, g.name`, userID)
|
||||
@@ -196,7 +203,7 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
|
||||
&g.CreatedBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount); err != nil {
|
||||
&g.CreatedBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
|
||||
@@ -21,11 +21,14 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
|
||||
now := time.Now().UTC()
|
||||
g.CreatedAt = now
|
||||
g.UpdatedAt = now
|
||||
if g.Source == "" {
|
||||
g.Source = "manual"
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO groups (id, name, description, scope, team_id, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
INSERT INTO groups (id, name, description, scope, team_id, created_by, source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
g.ID, g.Name, g.Description, g.Scope,
|
||||
models.NullString(g.TeamID), g.CreatedBy,
|
||||
models.NullString(g.TeamID), g.CreatedBy, g.Source,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
@@ -37,10 +40,11 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g WHERE g.id = ?`, id).Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
|
||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount,
|
||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -88,7 +92,8 @@ func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
ORDER BY g.scope, g.name`)
|
||||
}
|
||||
@@ -97,7 +102,8 @@ func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.G
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
WHERE g.scope = 'team' AND g.team_id = ?
|
||||
ORDER BY g.name`, teamID)
|
||||
@@ -107,7 +113,8 @@ func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.G
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = ?)
|
||||
ORDER BY g.scope, g.name`, userID)
|
||||
@@ -203,7 +210,7 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
|
||||
&g.CreatedBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount); err != nil {
|
||||
&g.CreatedBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
|
||||
Reference in New Issue
Block a user