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:
@@ -82,6 +82,8 @@ func main() {
|
||||
FrontendURL: envCfg.FrontendURL,
|
||||
})
|
||||
|
||||
server.SetupSessionIdleTimeout = appCfg.SetupSessionIdleTimeout
|
||||
|
||||
for _, e := range envCfg.DisplayEnv() {
|
||||
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
if err != nil {
|
||||
t.Fatalf("mint test session cookie: %v", err)
|
||||
}
|
||||
@@ -1081,7 +1081,8 @@ func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
|
||||
s, _ := newTestServerWithAuth(t, verifier)
|
||||
|
||||
cookie, err := auth.MintSessionCookie(
|
||||
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com", IDToken: "raw-id-token-jwt"},
|
||||
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"},
|
||||
"raw-id-token-jwt", // travels in the cookie apart from Claims
|
||||
testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1107,8 +1108,9 @@ func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
|
||||
func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
|
||||
verifier := &authmock.Verifier{
|
||||
CallbackResult: auth.LoginResult{
|
||||
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"},
|
||||
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"},
|
||||
Authorized: true,
|
||||
IDToken: "raw-id-token-jwt",
|
||||
},
|
||||
}
|
||||
s, _ := newTestServerWithAuth(t, verifier)
|
||||
@@ -1131,12 +1133,12 @@ func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("expected a session cookie to be set")
|
||||
}
|
||||
claims, err := auth.ParseSessionCookie(sessionCookie, testSessionConfig.Secret)
|
||||
idToken, err := auth.IDTokenFromSessionCookie(sessionCookie, testSessionConfig.Secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse session cookie: %v", err)
|
||||
t.Fatalf("read id token from session cookie: %v", err)
|
||||
}
|
||||
if claims.IDToken != "raw-id-token-jwt" {
|
||||
t.Errorf("claims.IDToken = %q, want %q", claims.IDToken, "raw-id-token-jwt")
|
||||
if idToken != "raw-id-token-jwt" {
|
||||
t.Errorf("cookie id token = %q, want %q", idToken, "raw-id-token-jwt")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1177,7 +1179,7 @@ func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
|
||||
req = req.WithContext(applog.WithLogger(req.Context(), logger))
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
if err != nil {
|
||||
t.Fatalf("mint session cookie: %v", err)
|
||||
}
|
||||
|
||||
@@ -36,12 +36,27 @@ func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(resp.Application) != 1 {
|
||||
t.Fatalf("expected 1 app-config entry, got %d", len(resp.Application))
|
||||
if len(resp.Application) != 2 {
|
||||
t.Fatalf("expected 2 app-config entries, got %d: %+v", len(resp.Application), resp.Application)
|
||||
}
|
||||
e := resp.Application[0]
|
||||
if e.Key != "session.duration" || e.Value != "720" || e.Default != "720" || e.Overridden || e.Description == "" {
|
||||
t.Fatalf("unexpected default entry: %+v", e)
|
||||
byKey := map[string]struct {
|
||||
value, def string
|
||||
overridden bool
|
||||
}{}
|
||||
for _, e := range resp.Application {
|
||||
if e.Description == "" {
|
||||
t.Errorf("entry %q has no description", e.Key)
|
||||
}
|
||||
byKey[e.Key] = struct {
|
||||
value, def string
|
||||
overridden bool
|
||||
}{e.Value, e.Default, e.Overridden}
|
||||
}
|
||||
if e := byKey["session.duration"]; e.value != "720" || e.def != "720" || e.overridden {
|
||||
t.Fatalf("session.duration default entry = %+v", e)
|
||||
}
|
||||
if e := byKey["session.idle_timeout"]; e.value != "15" || e.def != "15" || e.overridden {
|
||||
t.Fatalf("session.idle_timeout default entry = %+v", e)
|
||||
}
|
||||
if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" {
|
||||
t.Fatalf("env snapshot not passed through: %+v", resp.Environment)
|
||||
|
||||
@@ -31,7 +31,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||
if err != nil {
|
||||
t.Fatalf("mint test session cookie: %v", err)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ type Server struct {
|
||||
// never call os.Getenv.
|
||||
EnvVars []EnvVar
|
||||
|
||||
// SetupSessionIdleTimeout is the app-config session.idle_timeout value
|
||||
// (see internal/config); zero falls back to
|
||||
// defaultSetupSessionIdleTimeout.
|
||||
SetupSessionIdleTimeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
userClient map[int64]garmin.Client
|
||||
userSync map[int64]*garmin.Sync
|
||||
|
||||
@@ -82,7 +82,7 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
|
||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, result.IDToken, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -93,15 +93,23 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleSessionLogout clears geniusrun's own session cookie and redirects
|
||||
// through Keycloak's end-session endpoint, passing the session's ID token
|
||||
// as id_token_hint (see auth.Claims.IDToken) so Keycloak can skip its own
|
||||
// logout-confirmation prompt -- otherwise a user could cancel out of it and
|
||||
// land back on the app with a Keycloak SSO session but no geniusrun profile
|
||||
// (already deleted, in the profile-deletion case this exists for).
|
||||
// as id_token_hint (read back from the cookie via
|
||||
// auth.IDTokenFromSessionCookie -- it deliberately doesn't ride in Claims)
|
||||
// so Keycloak can skip its own logout-confirmation prompt -- otherwise a
|
||||
// user could cancel out of it and land back on the app with a Keycloak SSO
|
||||
// session but no geniusrun profile (already deleted, in the
|
||||
// profile-deletion case this exists for).
|
||||
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||||
claims, _ := auth.ClaimsFromContext(r.Context())
|
||||
s.removeSetupSession(claims.Sub)
|
||||
// Best-effort: an unreadable cookie just means logging out without the
|
||||
// hint, at worst showing Keycloak's own confirmation screen.
|
||||
var idToken string
|
||||
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
|
||||
idToken, _ = auth.IDTokenFromSessionCookie(cookie, s.SessionConfig.Secret)
|
||||
}
|
||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", idToken), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -13,11 +13,23 @@ import (
|
||||
applog "geniusrun/backend/internal/log"
|
||||
)
|
||||
|
||||
// setupSessionIdleTimeout bounds how long an onboarding Garmin session
|
||||
// survives without being touched (login, MFA, or complete) before it's
|
||||
// evicted -- long enough to check email for an MFA code, short enough that
|
||||
// an abandoned attempt doesn't leave a subprocess running indefinitely.
|
||||
const setupSessionIdleTimeout = 15 * time.Minute
|
||||
// defaultSetupSessionIdleTimeout bounds how long an onboarding Garmin
|
||||
// session survives without being touched (login, MFA, or complete) before
|
||||
// it's evicted -- long enough to check email for an MFA code, short enough
|
||||
// that an abandoned attempt doesn't leave a subprocess running
|
||||
// indefinitely. Tunable via the session.idle_timeout application-config
|
||||
// key (minutes -- distinct from session.duration, the login cookie
|
||||
// lifetime); this constant is the fallback when the Server field was never
|
||||
// wired (tests building a bare NewServer).
|
||||
const defaultSetupSessionIdleTimeout = 15 * time.Minute
|
||||
|
||||
// setupIdleTimeout returns the configured onboarding-session idle timeout.
|
||||
func (s *Server) setupIdleTimeout() time.Duration {
|
||||
if s.SetupSessionIdleTimeout > 0 {
|
||||
return s.SetupSessionIdleTimeout
|
||||
}
|
||||
return defaultSetupSessionIdleTimeout
|
||||
}
|
||||
|
||||
// setupSession is a temporary, not-yet-persisted Garmin authentication
|
||||
// attempt made during onboarding, before any users/profile row exists --
|
||||
@@ -30,7 +42,7 @@ const setupSessionIdleTimeout = 15 * time.Minute
|
||||
// the next time anything closes and restarts its subprocess; a later
|
||||
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
||||
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
|
||||
// subject checks staleness first) once idle past setupSessionIdleTimeout.
|
||||
// subject checks staleness first) once idle past setupIdleTimeout().
|
||||
type setupSession struct {
|
||||
Client garmin.Client
|
||||
Email, Password string
|
||||
@@ -213,7 +225,7 @@ func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
|
||||
if time.Since(sess.LastUsed) > s.setupIdleTimeout() {
|
||||
sess.Client.Close()
|
||||
delete(s.setupSession, sub)
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,16 @@ type AppKey struct {
|
||||
}
|
||||
|
||||
// KeySessionDuration is the session cookie lifetime, in integer hours.
|
||||
const KeySessionDuration = "session.duration"
|
||||
const (
|
||||
KeySessionDuration = "session.duration"
|
||||
// KeySessionIdleTimeout bounds an *onboarding Garmin session* (the
|
||||
// ephemeral, pre-account login attempt), not the login cookie --
|
||||
// session.duration is how long a signed-in user stays signed in
|
||||
// (hours); session.idle_timeout is how long an unfinished setup
|
||||
// attempt survives untouched before its subprocess is torn down
|
||||
// (minutes).
|
||||
KeySessionIdleTimeout = "session.idle_timeout"
|
||||
)
|
||||
|
||||
var appRegistry = []AppKey{
|
||||
{
|
||||
@@ -26,6 +35,12 @@ var appRegistry = []AppKey{
|
||||
Description: "Session cookie lifetime in hours",
|
||||
Validate: validatePositiveInt,
|
||||
},
|
||||
{
|
||||
Key: KeySessionIdleTimeout,
|
||||
Default: "15",
|
||||
Description: "Idle timeout in minutes for an unfinished onboarding Garmin session",
|
||||
Validate: validatePositiveInt,
|
||||
},
|
||||
}
|
||||
|
||||
// AppRegistry returns every known application-configuration key, in
|
||||
@@ -58,6 +73,7 @@ func ValidateAppValue(key, value string) error {
|
||||
// defaults.
|
||||
type AppConfig struct {
|
||||
SessionDuration time.Duration
|
||||
SetupSessionIdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure
|
||||
@@ -76,5 +92,9 @@ func LoadApp(overrides map[string]string) (AppConfig, error) {
|
||||
merged[key] = value
|
||||
}
|
||||
hours, _ := strconv.Atoi(merged[KeySessionDuration])
|
||||
return AppConfig{SessionDuration: time.Duration(hours) * time.Hour}, nil
|
||||
idleMinutes, _ := strconv.Atoi(merged[KeySessionIdleTimeout])
|
||||
return AppConfig{
|
||||
SessionDuration: time.Duration(hours) * time.Hour,
|
||||
SetupSessionIdleTimeout: time.Duration(idleMinutes) * time.Minute,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -11,11 +11,14 @@ func TestLoadApp(t *testing.T) {
|
||||
name string
|
||||
overrides map[string]string
|
||||
want time.Duration
|
||||
wantIdle time.Duration
|
||||
wantErr string
|
||||
}{
|
||||
{name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour},
|
||||
{name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour},
|
||||
{name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour, wantIdle: 15 * time.Minute},
|
||||
{name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour, wantIdle: 15 * time.Minute},
|
||||
{name: "idle timeout override applied", overrides: map[string]string{"session.idle_timeout": "30"}, want: 720 * time.Hour, wantIdle: 30 * time.Minute},
|
||||
{name: "invalid stored value", overrides: map[string]string{"session.duration": "zero"}, wantErr: "session.duration"},
|
||||
{name: "invalid idle timeout", overrides: map[string]string{"session.idle_timeout": "0"}, wantErr: "session.idle_timeout"},
|
||||
{name: "unknown stored key", overrides: map[string]string{"bogus.key": "1"}, wantErr: "unknown configuration key"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -33,6 +36,9 @@ func TestLoadApp(t *testing.T) {
|
||||
if got.SessionDuration != tt.want {
|
||||
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
|
||||
}
|
||||
if got.SetupSessionIdleTimeout != tt.wantIdle {
|
||||
t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupSessionIdleTimeout, tt.wantIdle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,6 @@ writing-plans flow (see `docs/superpowers/specs/` and `docs/superpowers/plans/`
|
||||
for that history) and remove it from here once a spec exists.
|
||||
|
||||
## Backlog
|
||||
- quickfixes
|
||||
- make setupSessionIdleTimeout an application configuration (key session.idle_timeout), check difference with appCfg.SessionDuration
|
||||
- don't put id_token in Claim, store it in the cookie apart from Claim
|
||||
- find alternative to deprecated React.FormEvent
|
||||
- replace all standard go log calls by our application logger,
|
||||
- new workout kinds
|
||||
- add "Recovery", "Quick", and "Sprint" workout kinds
|
||||
- order to follow (in activities page filter buttons, in analysis page combobox, in profile page workout kinds cards) is Recovery -> Easy -> Long -> Tempo -> 60' Threshold -> 30' Threshold -> Quick -> Intervals -> MAS Test -> Sprint -> Race
|
||||
@@ -77,4 +72,3 @@ for that history) and remove it from here once a spec exists.
|
||||
## Someday / maybe
|
||||
|
||||
- training programs with phases (tapering, easier weeks, post-race recovery)
|
||||
- improve logs (add non-REST logs, make python generate JSON logs through stdout, adapt go<->python protocol)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { api } from "./api/client";
|
||||
import { showError } from "./banner";
|
||||
import { BannerStack } from "./components/BannerStack";
|
||||
@@ -38,7 +39,7 @@ export function OnboardingWizard({
|
||||
const [completeFailed, setCompleteFailed] = useState(false);
|
||||
const completeTriggered = useRef(false);
|
||||
|
||||
function submitName(e: React.FormEvent) {
|
||||
function submitName(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!displayName.trim()) {
|
||||
setValidationError("Please enter a display name.");
|
||||
@@ -48,7 +49,7 @@ export function OnboardingWizard({
|
||||
setStep("garmin");
|
||||
}
|
||||
|
||||
async function submitGarmin(e: React.FormEvent) {
|
||||
async function submitGarmin(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!email.trim() || !password) {
|
||||
setValidationError("Please enter your Garmin email and password.");
|
||||
|
||||
Reference in New Issue
Block a user