Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Session cookie (`os_session`) issued on login and refresh, cleared on
logout. The `GET /api/calls/:id/audio` route now accepts authentication
via either the existing `Authorization: Bearer` header or the new
cookie, so `<audio>` element playback can be authenticated without
client-side header injection. Cookie is httpOnly, Secure when served
over HTTPS, `SameSite=Strict`, scoped to `/api`. Cross-site requests
are rejected via a `Sec-Fetch-Site` check; invalid or expired cookies
fall through to anonymous so existing `publicAccess=true` deployments
continue to work unchanged.
- Canonical `GET /api/ws` listener WebSocket route. The existing `GET /ws`
remains as a compatibility alias that delegates to the same handler. The
frontend now connects to `/api/ws`, and the Vite dev proxy covers both
Expand Down
31 changes: 31 additions & 0 deletions backend/internal/auth/cookie.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ const (

// RefreshCookiePath restricts the cookie to auth endpoints only.
RefreshCookiePath = "/api/auth"

// SessionCookieName is the HTTP cookie name for the session access token.
// The cookie value is the access JWT itself; ParseToken + Tokens.IsRevoked
// remain the single source of truth for validity.
SessionCookieName = "os_session"

// SessionCookiePath scopes the cookie to /api so it accompanies API
// requests (including <audio src="/api/calls/:id/audio">) but not
// arbitrary same-origin asset requests.
SessionCookiePath = "/api"
)

// isSecure returns true if the request arrived over HTTPS (directly or via proxy).
Expand All @@ -35,3 +45,24 @@ func ClearRefreshCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(RefreshCookieName, "", -1, RefreshCookiePath, "", secure, true)
}

// SetSessionCookie writes the access JWT as an httpOnly Secure SameSite=Strict
// cookie scoped to /api so that <audio src=…> and other same-origin browser
// requests authenticate without an Authorization header. The cookie's lifetime
// mirrors the access-token TTL via maxAgeSeconds (pass 0 for a session cookie).
//
// SameSite=Strict (deliberately stricter than the refresh cookie's Lax) is
// the primary CSRF defence: the cookie is never sent on cross-site navigations
// or sub-resource requests.
func SetSessionCookie(c *gin.Context, token string, maxAgeSeconds int) {
secure := isSecure(c)
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(SessionCookieName, token, maxAgeSeconds, SessionCookiePath, "", secure, true)
}

// ClearSessionCookie expires the os_session cookie immediately.
func ClearSessionCookie(c *gin.Context) {
secure := isSecure(c)
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(SessionCookieName, "", -1, SessionCookiePath, "", secure, true)
}
65 changes: 65 additions & 0 deletions backend/internal/auth/cookie_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,68 @@ func TestClearRefreshCookie(t *testing.T) {
t.Errorf("SameSite = %v, want Lax", ck.SameSite)
}
}

func TestSetSessionCookie(t *testing.T) {
tests := []struct {
name string
scheme string
maxAge int
token string
wantSecure bool
}{
{"http (dev)", "http", 900, "jwt-dev", false},
{"https (prod)", "https", 900, "jwt-prod", true},
{"session (maxAge=0)", "https", 0, "jwt-session", true},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c, w := newCookieContext(t, tc.scheme)
auth.SetSessionCookie(c, tc.token, tc.maxAge)

ck := findSetCookie(t, w, auth.SessionCookieName)

if ck.Value != tc.token {
t.Errorf("value = %q, want %q", ck.Value, tc.token)
}
if !ck.HttpOnly {
t.Error("HttpOnly = false, want true")
}
if ck.Secure != tc.wantSecure {
t.Errorf("Secure = %v, want %v", ck.Secure, tc.wantSecure)
}
if ck.SameSite != http.SameSiteStrictMode {
t.Errorf("SameSite = %v, want Strict (%v)", ck.SameSite, http.SameSiteStrictMode)
}
if ck.Path != auth.SessionCookiePath {
t.Errorf("Path = %q, want %q", ck.Path, auth.SessionCookiePath)
}
if tc.maxAge > 0 && ck.MaxAge != tc.maxAge {
t.Errorf("MaxAge = %d, want %d", ck.MaxAge, tc.maxAge)
}
})
}
}

func TestClearSessionCookie(t *testing.T) {
c, w := newCookieContext(t, "https")
auth.ClearSessionCookie(c)

ck := findSetCookie(t, w, auth.SessionCookieName)

if ck.Value != "" {
t.Errorf("value = %q, want empty", ck.Value)
}
if ck.MaxAge > 0 {
t.Errorf("MaxAge = %d, want <= 0", ck.MaxAge)
}
if ck.Path != auth.SessionCookiePath {
t.Errorf("Path = %q, want %q", ck.Path, auth.SessionCookiePath)
}
if !ck.HttpOnly {
t.Error("HttpOnly = false, want true")
}
if ck.SameSite != http.SameSiteStrictMode {
t.Errorf("SameSite = %v, want Strict", ck.SameSite)
}
}
11 changes: 11 additions & 0 deletions backend/internal/handler/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ func (h *Handler) PostLogin(c *gin.Context) {
auth.SetRefreshCookie(c, rawRefresh, 0)
}

// Also set the os_session cookie carrying the access JWT so that
// <audio src=…> and other same-origin browser requests can authenticate
// without injecting an Authorization header. Lifetime mirrors the
// access-token TTL; the frontend bearer flow continues to work unchanged.
auth.SetSessionCookie(c, token, int(auth.AccessTokenExpiry.Seconds()))

h.logAuthEvent(c.Request.Context(), "info", "login success: "+user.Username, ip)
slog.Info("user logged in", "user_id", user.ID, "username", user.Username, "ip", ip)

Expand Down Expand Up @@ -238,6 +244,7 @@ func (h *Handler) PostLogout(c *gin.Context) {
}
}
auth.ClearRefreshCookie(c)
auth.ClearSessionCookie(c)

c.JSON(http.StatusOK, gin.H{"ok": true})
}
Expand Down Expand Up @@ -349,6 +356,10 @@ func (h *Handler) PostRefresh(c *gin.Context) {
// Set new cookie with same Max-Age as original.
auth.SetRefreshCookie(c, newRaw, int(auth.RefreshTokenExpiry.Seconds()))

// Rotate the os_session cookie alongside the refresh cookie so the
// browser-only <audio> auth path always carries a fresh access JWT.
auth.SetSessionCookie(c, accessToken, int(auth.AccessTokenExpiry.Seconds()))

c.JSON(http.StatusOK, refreshResponse{
Token: accessToken,
User: loginUserResponse{
Expand Down
Loading
Loading