Changeset 0.24.1 (#157)

This commit is contained in:
2026-03-07 17:11:32 +00:00
parent a63728a481
commit b6cc4df6e7
27 changed files with 2094 additions and 453 deletions

View 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
View 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
View 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
View 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
View 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
}

View File

@@ -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"),
}
}

View File

@@ -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,

View 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
}

View File

@@ -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

View 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';

View 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';

View File

@@ -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{

View File

@@ -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

View File

@@ -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.
}

View File

@@ -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 ────────────────

View File

@@ -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
}

View File

@@ -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,
})
}
}

View File

@@ -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 &middot; 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();

View File

@@ -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)

View File

@@ -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)