diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 541db7b3..4eb33b20 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -86,6 +86,7 @@ const ( linkResendCooldown = 60 * time.Second prefixDrySend = "dry_send:" headlessJWKSFetchTTL = 5 * time.Second + defaultRefreshTokenTTL = 30 * 24 * time.Hour ) type AuthHandler struct { @@ -1244,9 +1245,31 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string return claims } +func hydraRefreshTokenTTL() time.Duration { + raw := strings.TrimSpace(os.Getenv("HYDRA_REFRESH_TOKEN_TTL")) + if raw == "" { + return defaultRefreshTokenTTL + } + ttl, err := time.ParseDuration(raw) + if err != nil || ttl <= 0 { + slog.Warn("invalid HYDRA_REFRESH_TOKEN_TTL, falling back to default", "value", raw, "default", defaultRefreshTokenTTL.String(), "error", err) + return defaultRefreshTokenTTL + } + return ttl +} + +func withRefreshTokenExpiryClaim(claims map[string]any, issuedAt time.Time) map[string]any { + if claims == nil { + claims = map[string]any{} + } + claims["rt_expires_at"] = issuedAt.Add(hydraRefreshTokenTTL()).Unix() + return claims +} + func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any { claims := buildOidcClaimsFromTraits(traits, scopes, tenantID) claims = applyConfiguredIDTokenClaims(claims, client.Metadata) + claims = withRefreshTokenExpiryClaim(claims, time.Now()) return withOidcSessionMetadata(claims, sessionID) } diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go index f6d310e8..5b919ef2 100644 --- a/backend/internal/handler/auth_handler_dynamic_claims_test.go +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -11,12 +11,31 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) +func assertRefreshTokenExpiryClaimWithin(t *testing.T, claims map[string]any, issuedAfter, issuedBefore time.Time, ttl time.Duration) { + t.Helper() + + rawExpiresAt, ok := claims["rt_expires_at"] + if !assert.True(t, ok, "rt_expires_at claim should exist") { + return + } + + expiresAtFloat, ok := rawExpiresAt.(float64) + if !assert.True(t, ok, "rt_expires_at should be encoded as a unix timestamp number") { + return + } + + expiresAt := time.Unix(int64(expiresAtFloat), 0) + assert.False(t, expiresAt.Before(issuedAfter.Add(ttl).Add(-time.Second)), "rt_expires_at should be after or equal to request start + ttl with second precision tolerance") + assert.False(t, expiresAt.After(issuedBefore.Add(ttl).Add(time.Second)), "rt_expires_at should be before or equal to request end + ttl") +} + func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { traits := map[string]any{ "email": "user@baron.com", @@ -131,6 +150,7 @@ func TestRepresentativeTenantIDFromTraits(t *testing.T) { } func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "48h") var capturedClaims map[string]any transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -213,7 +233,9 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") + issuedAfter := time.Now() resp, err := app.Test(req) + issuedBefore := time.Now() assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -223,6 +245,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { assert.Equal(t, "tenant-abc", capturedClaims["tenant_id"]) assert.Equal(t, "Innovation", capturedClaims["department"]) assert.Equal(t, "Architect", capturedClaims["position"]) + assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 48*time.Hour) } func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) { @@ -603,6 +626,7 @@ func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) { } func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "24h") var capturedClaims map[string]any transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -678,7 +702,9 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { app.Get("/api/v1/auth/consent", h.GetConsentRequest) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip-dynamic", nil) + issuedAfter := time.Now() resp, err := app.Test(req) + issuedBefore := time.Now() assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -688,6 +714,87 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { assert.Equal(t, "tenant-xyz", capturedClaims["tenant_id"]) assert.Equal(t, "Security", capturedClaims["department"]) assert.Equal(t, "Officer", capturedClaims["position"]) + assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 24*time.Hour) +} + +func TestGetConsentRequest_LocalConsentAutoApprove_IncludesRefreshTokenExpiryClaim(t *testing.T) { + t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "72h") + + var capturedClaims map[string]any + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-local-auto-approve" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "challenge": "challenge-local-auto-approve", + "requested_scope": []string{"openid", "profile"}, + "skip": false, + "subject": "user-local-123", + "client": map[string]any{ + "client_id": "local-app", + "metadata": map[string]any{ + "tenant_id": "tenant-local", + }, + }, + }), nil + } + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-local-auto-approve" { + body, _ := io.ReadAll(r.Body) + var acceptReq map[string]any + json.Unmarshal(body, &acceptReq) + if session, ok := acceptReq["session"].(map[string]any); ok { + capturedClaims = session["id_token"].(map[string]any) + } + return httpJSONAny(r, http.StatusOK, map[string]any{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + consentRepo := &mockConsentRepo{ + consents: []domain.ClientConsent{ + { + ClientID: "local-app", + Subject: "user-local-123", + GrantedScopes: []string{"openid", "profile"}, + }, + }, + } + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + ConsentRepo: consentRepo, + } + h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-local-123").Return(&service.KratosIdentity{ + ID: "user-local-123", + Traits: map[string]any{ + "email": "local@test.com", + "name": "Local User", + }, + }, nil) + + app := fiber.New() + app.Get("/api/v1/auth/consent", h.GetConsentRequest) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-local-auto-approve", nil) + issuedAfter := time.Now() + resp, err := app.Test(req) + issuedBefore := time.Now() + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.NotNil(t, capturedClaims) + assert.Equal(t, "local@test.com", capturedClaims["email"]) + assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 72*time.Hour) } func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) {