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>
123 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|