Changeset 0.24.1 (#157)
This commit is contained in:
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user