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

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