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>
This commit is contained in:
@@ -28,7 +28,7 @@ func TestRequireSession_NoCookie(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireSession_ValidCookie(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, []byte(testSecret), time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, "", []byte(testSecret), time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func TestRequireSession_ValidCookie(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireSession_ExpiredCookie(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestRequireSession_ExpiredCookie(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireSession_TamperedCookie(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ type Verifier interface {
|
||||
type LoginResult struct {
|
||||
Claims Claims
|
||||
Authorized bool
|
||||
// IDToken is the raw Keycloak ID token JWT, kept apart from Claims:
|
||||
// it's only ever needed once more, at logout (id_token_hint), so it
|
||||
// rides in the session cookie but never in the per-request Claims.
|
||||
IDToken string
|
||||
}
|
||||
|
||||
// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the
|
||||
@@ -135,8 +139,9 @@ func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query u
|
||||
return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err)
|
||||
}
|
||||
return LoginResult{
|
||||
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email, IDToken: rawIDToken},
|
||||
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email},
|
||||
Authorized: claims.hasRole(v.requiredRole),
|
||||
IDToken: rawIDToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
// tokens are never stored or refreshed -- once HandleCallback verifies the
|
||||
// ID token and role, only this package's own cookie matters for subsequent
|
||||
// requests. The one exception is the raw ID token itself, carried opaquely
|
||||
// inside the signed session cookie (Claims.IDToken) solely so a later
|
||||
// logout can pass it back to Keycloak as id_token_hint -- letting Keycloak
|
||||
// inside the signed session cookie (apart from Claims -- see
|
||||
// MintSessionCookie's idToken parameter and IDTokenFromSessionCookie)
|
||||
// solely so a later logout can pass it back to Keycloak as
|
||||
// id_token_hint -- letting Keycloak
|
||||
// skip its own logout-confirmation prompt for a session it can positively
|
||||
// identify, rather than leaving the user a chance to cancel out of it after
|
||||
// their geniusrun account (and profile) is already gone.
|
||||
@@ -32,14 +34,15 @@ const (
|
||||
)
|
||||
|
||||
// Claims identifies the authenticated user, carried in the signed session
|
||||
// cookie. IDToken is the raw Keycloak ID token JWT from the OIDC callback,
|
||||
// carried opaquely so a later logout can pass it back to Keycloak as
|
||||
// id_token_hint (see EndSessionURL) -- geniusrun never inspects it itself.
|
||||
// cookie and stashed in every request's context. Deliberately does NOT
|
||||
// carry the raw Keycloak ID token: that JWT lives in the cookie payload
|
||||
// separately (see MintSessionCookie/IDTokenFromSessionCookie) because it's
|
||||
// only ever needed once more, at logout, and has no business riding
|
||||
// through every handler's context.
|
||||
type Claims struct {
|
||||
Sub string
|
||||
Name string
|
||||
Email string
|
||||
IDToken string
|
||||
Sub string
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
type sessionClaims struct {
|
||||
@@ -65,13 +68,15 @@ type txnClaims struct {
|
||||
|
||||
// MintSessionCookie signs claims into a JWT valid for duration and wraps it
|
||||
// in a cookie. secure should be true whenever the app is served over HTTPS.
|
||||
func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
|
||||
// idToken is the raw Keycloak ID token to carry for the eventual logout's
|
||||
// id_token_hint (empty is fine, e.g. in tests -- the hint is optional).
|
||||
func MintSessionCookie(claims Claims, idToken string, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
|
||||
now := time.Now()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
|
||||
Sub: claims.Sub,
|
||||
Name: claims.Name,
|
||||
Email: claims.Email,
|
||||
IDToken: claims.IDToken,
|
||||
IDToken: idToken,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
|
||||
@@ -102,7 +107,21 @@ func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) {
|
||||
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
|
||||
return Claims{}, fmt.Errorf("parse session token: %w", err)
|
||||
}
|
||||
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email, IDToken: sc.IDToken}, nil
|
||||
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil
|
||||
}
|
||||
|
||||
// IDTokenFromSessionCookie verifies the cookie and returns the raw Keycloak
|
||||
// ID token it carries, for logout's id_token_hint. Only the logout handler
|
||||
// needs this -- everything else uses ParseSessionCookie's Claims.
|
||||
func IDTokenFromSessionCookie(cookie *http.Cookie, secret []byte) (string, error) {
|
||||
if cookie == nil {
|
||||
return "", fmt.Errorf("no session cookie")
|
||||
}
|
||||
var sc sessionClaims
|
||||
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
|
||||
return "", fmt.Errorf("parse session token: %w", err)
|
||||
}
|
||||
return sc.IDToken, nil
|
||||
}
|
||||
|
||||
// MintTxnCookie signs an OIDC login transaction into a short-lived cookie.
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
const testSecret = "test-secret-at-least-32-bytes-long!"
|
||||
|
||||
func TestMintAndParseSessionCookie(t *testing.T) {
|
||||
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"}
|
||||
cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true)
|
||||
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)
|
||||
}
|
||||
@@ -24,10 +24,20 @@ func TestMintAndParseSessionCookie(t *testing.T) {
|
||||
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)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
@@ -37,7 +47,7 @@ func TestParseSessionCookie_Expired(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseSessionCookie_Tampered(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
@@ -76,7 +86,7 @@ func flipSignatureChar(s string) string {
|
||||
}
|
||||
|
||||
func TestParseSessionCookie_WrongSecret(t *testing.T) {
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
|
||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user