Files
geniusrun/backend/internal/auth/session_test.go
Christophe Vila 8c9285f33c fix: IDEAS.md quickfixes — idle-timeout app config, id_token out of Claims, FormEvent import
session.idle_timeout (minutes, default 15) joins the app-config registry
and drives the onboarding Garmin session eviction, distinct from
session.duration (the login cookie lifetime in hours). The raw Keycloak
ID token no longer rides in auth.Claims through every request context:
it's minted into the session cookie separately and read back only by the
logout handler via IDTokenFromSessionCookie. OnboardingWizard uses the
type-imported FormEvent<HTMLFormElement> instead of the React.FormEvent
namespace alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:44:37 +02:00

123 lines
4.2 KiB
Go

package auth
import (
"testing"
"time"
)
const testSecret = "test-secret-at-least-32-bytes-long!"
func TestMintAndParseSessionCookie(t *testing.T) {
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}
cookie, err := MintSessionCookie(claims, "raw-id-token-jwt", []byte(testSecret), time.Hour, true)
if err != nil {
t.Fatalf("mint: %v", err)
}
if cookie.Name != SessionCookieName || !cookie.HttpOnly || !cookie.Secure {
t.Fatalf("cookie = %+v, want name=%s HttpOnly+Secure", cookie, SessionCookieName)
}
got, err := ParseSessionCookie(cookie, []byte(testSecret))
if err != nil {
t.Fatalf("parse: %v", err)
}
if got != claims {
t.Fatalf("got %+v, want %+v", got, claims)
}
// The ID token rides in the cookie apart from Claims, retrievable only
// through the dedicated logout-path helper.
idToken, err := IDTokenFromSessionCookie(cookie, []byte(testSecret))
if err != nil {
t.Fatalf("IDTokenFromSessionCookie: %v", err)
}
if idToken != "raw-id-token-jwt" {
t.Fatalf("idToken = %q, want raw-id-token-jwt", idToken)
}
}
func TestParseSessionCookie_Expired(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil {
t.Fatal("expected error for expired cookie")
}
}
func TestParseSessionCookie_Tampered(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
if err != nil {
t.Fatalf("mint: %v", err)
}
cookie.Value = flipSignatureChar(cookie.Value)
if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil {
t.Fatal("expected error for tampered cookie")
}
}
// flipSignatureChar corrupts a signed JWT for tamper tests by changing the
// second-to-last character of its base64url signature, guaranteeing the
// decoded signature bytes actually change. Two pitfalls to avoid here:
// 1. Blindly overwriting a character with a fixed replacement (e.g. "x")
// would occasionally be a no-op if that character was already there --
// it's derived from the token's embedded timestamp, so this isn't as
// rare as it sounds.
// 2. Flipping the *last* character of the signature specifically (as this
// helper used to) is flaky in a subtler way: HMAC-SHA256 produces a
// 32-byte digest, which base64url-encodes to 43 characters with a
// final 3-character group covering only a 2-byte remainder -- the
// true last character encodes 4 real bits plus 2 unused padding bits.
// Go's encoding/base64 ignores those padding bits when decoding
// (non-strict by default), so about 1 in 4 replacement characters for
// that position decode to byte-identical signature bytes, silently
// passing the test without having tampered with anything. The
// second-to-last character of that final group has no such unused
// bits, so corrupting it is deterministic.
func flipSignatureChar(s string) string {
pos := len(s) - 2
orig := s[pos]
replacement := byte('x')
if orig == replacement {
replacement = 'y'
}
return s[:pos] + string(replacement) + s[pos+1:]
}
func TestParseSessionCookie_WrongSecret(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, err := ParseSessionCookie(cookie, []byte("a-completely-different-secret!!")); err == nil {
t.Fatal("expected error for wrong secret")
}
}
func TestMintAndParseTxnCookie(t *testing.T) {
txn := TxnState{State: "abc123", CodeVerifier: "verifier-xyz"}
cookie, err := MintTxnCookie(txn, []byte(testSecret), false)
if err != nil {
t.Fatalf("mint: %v", err)
}
if cookie.Name != TxnCookieName {
t.Fatalf("cookie name = %q, want %q", cookie.Name, TxnCookieName)
}
got, err := ParseTxnCookie(cookie, []byte(testSecret))
if err != nil {
t.Fatalf("parse: %v", err)
}
if got != txn {
t.Fatalf("got %+v, want %+v", got, txn)
}
}
func TestClearCookie(t *testing.T) {
c := ClearCookie(SessionCookieName, true)
if c.Value != "" || c.MaxAge >= 0 {
t.Fatalf("ClearCookie = %+v, want empty value and negative MaxAge", c)
}
}