diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index aa0e1a17..18063016 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -24,6 +24,8 @@ type OidcClientDto struct { Credentials OidcClientCredentialsDto `json:"credentials"` IsGroupRestricted bool `json:"isGroupRestricted"` PkceSupported bool `json:"pkceSupported,omitempty"` + AccessTokenDurationMinutes int64 `json:"accessTokenDurationMinutes"` + RefreshTokenDurationMinutes int64 `json:"refreshTokenDurationMinutes"` } type OidcClientWithAllowedUserGroupsDto struct { @@ -53,6 +55,8 @@ type OidcClientUpdateDto struct { LogoURL *string `json:"logoUrl"` DarkLogoURL *string `json:"darkLogoUrl"` IsGroupRestricted bool `json:"isGroupRestricted"` + AccessTokenDurationMinutes int64 `json:"accessTokenDurationMinutes" binding:"required,token_duration"` + RefreshTokenDurationMinutes int64 `json:"refreshTokenDurationMinutes" binding:"required,token_duration"` } type OidcClientCreateDto struct { diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index f0ae5ad5..2637c1ab 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ory/fosite" + "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/gin-gonic/gin/binding" @@ -61,6 +62,9 @@ func init() { "resource_uri": func(fl validator.FieldLevel) bool { return ValidateResourceURI(fl.Field().String()) }, + "token_duration": func(fl validator.FieldLevel) bool { + return model.IsValidTokenDurationMinutes(fl.Field().Int()) + }, } for k, v := range validators { err := engine.RegisterValidation(k, v) diff --git a/backend/internal/dto/validations_test.go b/backend/internal/dto/validations_test.go index c752743e..a3d7bfd0 100644 --- a/backend/internal/dto/validations_test.go +++ b/backend/internal/dto/validations_test.go @@ -3,9 +3,38 @@ package dto import ( "testing" + "github.com/gin-gonic/gin/binding" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestTokenDurationValidation(t *testing.T) { + type input struct { + Duration int64 `binding:"required,token_duration"` + } + + for _, test := range []struct { + name string + value int64 + wantErr bool + }{ + {name: "omitted", wantErr: true}, + {name: "below minimum", value: 0, wantErr: true}, + {name: "minimum", value: 1}, + {name: "custom duration", value: 90}, + {name: "above maximum", value: 365*24*60 + 1, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + err := binding.Validator.ValidateStruct(input{Duration: test.value}) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + func TestValidateUsername(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index ae12c320..1c6e7d38 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -25,6 +25,15 @@ type OidcClientType string const ( OidcClientTypeStandard OidcClientType = "standard" OidcClientTypeCIMD OidcClientType = "cimd" + + // DefaultAccessTokenDurationMinutes is the access-token lifetime used for new clients + DefaultAccessTokenDurationMinutes int64 = 60 + // DefaultRefreshTokenDurationMinutes is the refresh-token lifetime used for new clients + DefaultRefreshTokenDurationMinutes int64 = 30 * 24 * 60 + // MinTokenDurationMinutes is the shortest configurable token lifetime + MinTokenDurationMinutes int64 = 1 + // MaxTokenDurationMinutes is the longest configurable token lifetime + MaxTokenDurationMinutes int64 = 365 * 24 * 60 ) type OidcClient struct { @@ -49,6 +58,8 @@ type OidcClient struct { ClientType OidcClientType `gorm:"default:standard" sortable:"true" filterable:"true"` MetadataExpiresAt *datatype.DateTime MetadataGrantTypes datatype.StringList + AccessTokenDurationMinutes int64 `gorm:"default:60"` + RefreshTokenDurationMinutes int64 `gorm:"default:43200"` AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` CreatedByID *string @@ -56,6 +67,11 @@ type OidcClient struct { UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"` } +// IsValidTokenDurationMinutes reports whether a duration is within the configurable range +func IsValidTokenDurationMinutes(minutes int64) bool { + return minutes >= MinTokenDurationMinutes && minutes <= MaxTokenDurationMinutes +} + func (c OidcClient) HasLogo() bool { return c.ImageType != nil && *c.ImageType != "" } diff --git a/backend/internal/oidc/cimd.go b/backend/internal/oidc/cimd.go index a4560bff..e0293dcf 100644 --- a/backend/internal/oidc/cimd.go +++ b/backend/internal/oidc/cimd.go @@ -201,12 +201,14 @@ func buildClientFromMetadata(doc *fosite.ClientMetadataDocument, rawURL string) } client := model.OidcClient{ - Base: model.Base{ID: rawURL}, - Name: doc.ClientName, - CallbackURLs: datatype.StringList(doc.RedirectURIs), - LogoutCallbackURLs: datatype.StringList(doc.PostLogoutRedirectURIs), - ClientType: model.OidcClientTypeCIMD, - MetadataGrantTypes: datatype.StringList(grantTypes), + Base: model.Base{ID: rawURL}, + Name: doc.ClientName, + CallbackURLs: datatype.StringList(doc.RedirectURIs), + LogoutCallbackURLs: datatype.StringList(doc.PostLogoutRedirectURIs), + ClientType: model.OidcClientTypeCIMD, + MetadataGrantTypes: datatype.StringList(grantTypes), + AccessTokenDurationMinutes: model.DefaultAccessTokenDurationMinutes, + RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes, } switch doc.TokenEndpointAuthMethod { diff --git a/backend/internal/oidc/cimd_test.go b/backend/internal/oidc/cimd_test.go index d6638ff5..f75105c7 100644 --- a/backend/internal/oidc/cimd_test.go +++ b/backend/internal/oidc/cimd_test.go @@ -40,6 +40,8 @@ func TestBuildClientFromMetadata(t *testing.T) { assert.Equal(t, []string{"https://app.example.com/logout"}, []string(c.LogoutCallbackURLs)) assert.Equal(t, []string{"authorization_code"}, []string(c.MetadataGrantTypes)) assert.Empty(t, c.Credentials.FederatedIdentities) + assert.Equal(t, model.DefaultAccessTokenDurationMinutes, c.AccessTokenDurationMinutes) + assert.Equal(t, model.DefaultRefreshTokenDurationMinutes, c.RefreshTokenDurationMinutes) }) t.Run("authenticated clients are rejected", func(t *testing.T) { @@ -161,7 +163,16 @@ func TestRefreshMetadataClient(t *testing.T) { s := newMetadataStore(t, map[string]*http.Response{id: resp}) fresh := datatype.DateTime(time.Now().Add(time.Hour)) - seed := model.OidcClient{Base: model.Base{ID: id}, Name: "Old", IsPublic: true, PkceEnabled: true, ClientType: model.OidcClientTypeCIMD, MetadataExpiresAt: &fresh} + seed := model.OidcClient{ + Base: model.Base{ID: id}, + Name: "Old", + IsPublic: true, + PkceEnabled: true, + ClientType: model.OidcClientTypeCIMD, + MetadataExpiresAt: &fresh, + AccessTokenDurationMinutes: 2 * 60, + RefreshTokenDurationMinutes: 7 * 24 * 60, + } require.NoError(t, s.db.Create(&seed).Error) // A normal lookup still returns the cached value. @@ -174,6 +185,8 @@ func TestRefreshMetadataClient(t *testing.T) { require.NoError(t, err) assert.Equal(t, "App", c.Name) assert.True(t, c.IsMetadataDocument()) + assert.Equal(t, int64(2*60), c.AccessTokenDurationMinutes) + assert.Equal(t, int64(7*24*60), c.RefreshTokenDurationMinutes) }) } diff --git a/backend/internal/oidc/client.go b/backend/internal/oidc/client.go index 181734e5..2344b8d6 100644 --- a/backend/internal/oidc/client.go +++ b/backend/internal/oidc/client.go @@ -2,6 +2,7 @@ package oidc import ( "slices" + "time" "github.com/ory/fosite" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -86,3 +87,36 @@ func (c Client) GetResponseModes() []fosite.ResponseModeType { fosite.ResponseModeFormPost, } } + +func (c Client) GetEffectiveLifespan(grantType fosite.GrantType, tokenType fosite.TokenType, fallback time.Duration) time.Duration { + var minutes int64 + switch tokenType { + case fosite.AccessToken: + switch grantType { + case fosite.GrantTypeAuthorizationCode, fosite.GrantTypeRefreshToken, fosite.GrantTypeDeviceCode, fosite.GrantTypeClientCredentials: + minutes = c.AccessTokenDurationMinutes + case fosite.GrantTypeImplicit, fosite.GrantTypePassword, fosite.GrantTypeJWTBearer: + return fallback + default: + return fallback + } + case fosite.RefreshToken: + switch grantType { + case fosite.GrantTypeAuthorizationCode, fosite.GrantTypeRefreshToken, fosite.GrantTypeDeviceCode: + minutes = c.RefreshTokenDurationMinutes + case fosite.GrantTypeImplicit, fosite.GrantTypePassword, fosite.GrantTypeClientCredentials, fosite.GrantTypeJWTBearer: + return fallback + default: + return fallback + } + case fosite.AuthorizeCode, fosite.IDToken, fosite.UserCode, fosite.DeviceCode, fosite.PushedAuthorizeRequestContext: + return fallback + default: + return fallback + } + + if !model.IsValidTokenDurationMinutes(minutes) { + return fallback + } + return time.Duration(minutes) * time.Minute +} diff --git a/backend/internal/oidc/client_test.go b/backend/internal/oidc/client_test.go index 1674f817..5279134d 100644 --- a/backend/internal/oidc/client_test.go +++ b/backend/internal/oidc/client_test.go @@ -1,11 +1,53 @@ package oidc import ( + "testing" + "time" + "github.com/ory/fosite" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/model" ) // Interface assertions var ( - _ fosite.Client = (*Client)(nil) - _ fosite.ResponseModeClient = (*Client)(nil) + _ fosite.Client = (*Client)(nil) + _ fosite.ResponseModeClient = (*Client)(nil) + _ fosite.ClientWithCustomTokenLifespans = (*Client)(nil) ) + +func TestClientGetEffectiveLifespan(t *testing.T) { + client := Client{OidcClient: model.OidcClient{ + AccessTokenDurationMinutes: 2 * 60, + RefreshTokenDurationMinutes: 7 * 24 * 60, + }} + fallback := 13 * time.Minute + + for _, test := range []struct { + name string + grantType fosite.GrantType + tokenType fosite.TokenType + want time.Duration + }{ + {name: "authorization code access token", grantType: fosite.GrantTypeAuthorizationCode, tokenType: fosite.AccessToken, want: 2 * time.Hour}, + {name: "authorization code refresh token", grantType: fosite.GrantTypeAuthorizationCode, tokenType: fosite.RefreshToken, want: 7 * 24 * time.Hour}, + {name: "refresh grant access token", grantType: fosite.GrantTypeRefreshToken, tokenType: fosite.AccessToken, want: 2 * time.Hour}, + {name: "refresh grant refresh token", grantType: fosite.GrantTypeRefreshToken, tokenType: fosite.RefreshToken, want: 7 * 24 * time.Hour}, + {name: "device grant access token", grantType: fosite.GrantTypeDeviceCode, tokenType: fosite.AccessToken, want: 2 * time.Hour}, + {name: "device grant refresh token", grantType: fosite.GrantTypeDeviceCode, tokenType: fosite.RefreshToken, want: 7 * 24 * time.Hour}, + {name: "client credentials access token", grantType: fosite.GrantTypeClientCredentials, tokenType: fosite.AccessToken, want: 2 * time.Hour}, + {name: "client credentials refresh token falls back", grantType: fosite.GrantTypeClientCredentials, tokenType: fosite.RefreshToken, want: fallback}, + {name: "ID token falls back", grantType: fosite.GrantTypeAuthorizationCode, tokenType: fosite.IDToken, want: fallback}, + {name: "unsupported grant falls back", grantType: fosite.GrantTypePassword, tokenType: fosite.AccessToken, want: fallback}, + } { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, client.GetEffectiveLifespan(test.grantType, test.tokenType, fallback)) + }) + } + + client.AccessTokenDurationMinutes = 0 + client.RefreshTokenDurationMinutes = model.MaxTokenDurationMinutes + 1 + require.Equal(t, fallback, client.GetEffectiveLifespan(fosite.GrantTypeAuthorizationCode, fosite.AccessToken, fallback)) + require.Equal(t, fallback, client.GetEffectiveLifespan(fosite.GrantTypeAuthorizationCode, fosite.RefreshToken, fallback)) +} diff --git a/backend/internal/oidc/preview.go b/backend/internal/oidc/preview.go index ee1b0595..1cd63483 100644 --- a/backend/internal/oidc/preview.go +++ b/backend/internal/oidc/preview.go @@ -94,12 +94,14 @@ func (b *ClientPreviewBuilder) validatedScopes(ctx context.Context, client model func (b *ClientPreviewBuilder) newPreviewRequest(ctx context.Context, client model.OidcClient, userID string, scopes fosite.Arguments, authenticationMethod string) *fosite.Request { now := time.Now().UTC() + runtimeClient := Client{OidcClient: client} session := NewAuthenticatedSession(userID, authenticationMethod, now, now) - session.SetExpiresAt(fosite.AccessToken, now.Add(b.strategies.config.GetAccessTokenLifespan(ctx))) + accessTokenLifespan := fosite.GetEffectiveLifespan(runtimeClient, fosite.GrantTypeAuthorizationCode, fosite.AccessToken, b.strategies.config.GetAccessTokenLifespan(ctx)) + session.SetExpiresAt(fosite.AccessToken, now.Add(accessTokenLifespan)) request := fosite.NewRequest() request.RequestedAt = now - request.Client = Client{OidcClient: client} + request.Client = runtimeClient request.RequestedScope = scopes request.GrantedScope = scopes request.RequestedAudience = fosite.Arguments{client.ID} diff --git a/backend/internal/oidc/preview_test.go b/backend/internal/oidc/preview_test.go index 1d48da5c..58bdaab2 100644 --- a/backend/internal/oidc/preview_test.go +++ b/backend/internal/oidc/preview_test.go @@ -5,6 +5,7 @@ import ( "crypto/elliptic" "crypto/rand" "testing" + "time" "github.com/stretchr/testify/require" @@ -40,8 +41,9 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) { }).Error) preview, err := builder.BuildClientPreview(t.Context(), model.OidcClient{ - Base: model.Base{ID: clientID}, - Name: "Test Client", + Base: model.Base{ID: clientID}, + Name: "Test Client", + AccessTokenDurationMinutes: 2 * 60, }, userID, []string{"openid", "email"}, "phr") require.NoError(t, err) @@ -51,6 +53,11 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) { // The identity scopes add the issuer to the audience so the previewed token would also work at /userinfo require.ElementsMatch(t, []string{clientID, "https://issuer.example.com"}, stringSliceClaim(t, preview.AccessToken["aud"])) require.NotContains(t, preview.AccessToken, "type") + issuedAt, ok := preview.AccessToken["iat"].(time.Time) + require.Truef(t, ok, "expected time.Time iat, got %T", preview.AccessToken["iat"]) + expiresAt, ok := preview.AccessToken["exp"].(time.Time) + require.Truef(t, ok, "expected time.Time exp, got %T", preview.AccessToken["exp"]) + require.Equal(t, 2*time.Hour, expiresAt.Sub(issuedAt)) require.Equal(t, userID, preview.IDToken["sub"]) // ID tokens carry the "type" marker (so the end-session endpoint can reject access tokens diff --git a/backend/internal/oidc/token_handler_test.go b/backend/internal/oidc/token_handler_test.go index d272af09..20867504 100644 --- a/backend/internal/oidc/token_handler_test.go +++ b/backend/internal/oidc/token_handler_test.go @@ -52,10 +52,11 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) { hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost) require.NoError(t, err) require.NoError(t, db.Create(&model.OidcClient{ - Base: model.Base{ID: clientID}, - Name: "Client Credentials Client", - Secret: string(hashed), - IsPublic: false, + Base: model.Base{ID: clientID}, + Name: "Client Credentials Client", + Secret: string(hashed), + IsPublic: false, + AccessTokenDurationMinutes: 2 * 60, }).Error) provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: key}, Config{ @@ -88,6 +89,8 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) { require.Contains(t, jwtAudience(claims), clientID, "access token must be audience-bound to the client") require.Equal(t, "client-"+clientID, claims["sub"]) require.Equal(t, clientID, claims["client_id"]) + require.InDelta(t, 2*time.Hour/time.Second, body["expires_in"], 1) + require.InDelta(t, 2*time.Hour/time.Second, claims["exp"].(float64)-claims["iat"].(float64), 1) } // TestTokenHandlerClientCredentialsDropsIdentityScopes guards that a machine token never @@ -452,6 +455,46 @@ func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) { require.NotEqual(t, token, body["refresh_token"], "refresh token must be rotated") }) + t.Run("rotation applies the current client lifetimes", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + const clientID, userID = "client-custom-lifetimes", "user-custom-lifetimes" + const accessDuration = 2 * time.Hour + const refreshDuration = 7 * 24 * time.Hour + createClient(t, db, model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true}) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error) + + token := mintRefreshToken(t, db, clientID, userID) + globalSecret, err := DeriveGlobalSecret([]byte(secret)) + require.NoError(t, err) + strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{GlobalSecret: globalSecret}) + existingSignature := strategy.RefreshTokenSignature(t.Context(), token) + var existingBeforeUpdate OAuth2Session + require.NoError(t, db.First(&existingBeforeUpdate, "kind = ? AND key = ?", sessionKindRefreshToken, existingSignature).Error) + require.NotNil(t, existingBeforeUpdate.ExpiresAt) + + require.NoError(t, db.Model(&model.OidcClient{}).Where("id = ?", clientID).Updates(map[string]any{ + "access_token_duration_minutes": int64(accessDuration / time.Minute), + "refresh_token_duration_minutes": int64(refreshDuration / time.Minute), + }).Error) + var existingAfterUpdate OAuth2Session + require.NoError(t, db.First(&existingAfterUpdate, "kind = ? AND key = ?", sessionKindRefreshToken, existingSignature).Error) + require.Equal(t, existingBeforeUpdate.ExpiresAt, existingAfterUpdate.ExpiresAt) + + rotationStartedAt := time.Now().UTC() + body := doRefresh(t, db, clientID, token) + + require.NotEmpty(t, body["access_token"], "expected a new access token, got error: %v", body["error"]) + require.InDelta(t, accessDuration/time.Second, body["expires_in"], 1) + claims := decodeJWTPart(t, body["access_token"].(string), 1) + require.InDelta(t, accessDuration/time.Second, claims["exp"].(float64)-claims["iat"].(float64), 1) + + rotatedSignature := strategy.RefreshTokenSignature(t.Context(), body["refresh_token"].(string)) + var stored OAuth2Session + require.NoError(t, db.First(&stored, "kind = ? AND key = ?", sessionKindRefreshToken, rotatedSignature).Error) + require.NotNil(t, stored.ExpiresAt) + require.WithinDuration(t, rotationStartedAt.Add(refreshDuration), time.Time(*stored.ExpiresAt), 2*time.Second) + }) + t.Run("disabled user is rejected on refresh", func(t *testing.T) { db := testutils.NewDatabaseForTest(t) const clientID, userID = "client-disabled", "user-disabled" diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 3c8eb6c1..a0bad26d 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -32,8 +32,8 @@ const ( GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" GrantTypeClientCredentials = "client_credentials" - AccessTokenDuration = time.Hour - RefreshTokenDuration = 30 * 24 * time.Hour // 30 days + AccessTokenDuration = time.Duration(model.DefaultAccessTokenDurationMinutes) * time.Minute + RefreshTokenDuration = time.Duration(model.DefaultRefreshTokenDurationMinutes) * time.Minute ) type OidcService struct { @@ -148,7 +148,9 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea Base: model.Base{ ID: input.ID, }, - CreatedByID: new(userID), + CreatedByID: new(userID), + AccessTokenDurationMinutes: model.DefaultAccessTokenDurationMinutes, + RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes, } updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto) @@ -213,6 +215,8 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d "SkipConsent", "LaunchURL", "IsGroupRestricted", + "AccessTokenDurationMinutes", + "RefreshTokenDurationMinutes", ). Updates(&client).Error } else { @@ -253,6 +257,8 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien client.SkipConsent = input.SkipConsent client.LaunchURL = input.LaunchURL client.IsGroupRestricted = input.IsGroupRestricted + client.AccessTokenDurationMinutes = input.AccessTokenDurationMinutes + client.RefreshTokenDurationMinutes = input.RefreshTokenDurationMinutes // Preserve fields that are sourced from the client metadata document if client.IsMetadataDocument() { diff --git a/backend/internal/service/oidc_service_test.go b/backend/internal/service/oidc_service_test.go index 6c9cc816..786b454c 100644 --- a/backend/internal/service/oidc_service_test.go +++ b/backend/internal/service/oidc_service_test.go @@ -528,9 +528,11 @@ func TestOidcService_CreateClient_withDescription(t *testing.T) { description := "A test client description" input := dto.OidcClientCreateDto{ OidcClientUpdateDto: dto.OidcClientUpdateDto{ - Name: "Test Client", - Description: description, - CallbackURLs: []string{"https://example.com/callback"}, + Name: "Test Client", + Description: description, + CallbackURLs: []string{"https://example.com/callback"}, + AccessTokenDurationMinutes: model.DefaultAccessTokenDurationMinutes, + RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes, }, } @@ -552,8 +554,10 @@ func TestOidcService_CreateClient_withoutDescription(t *testing.T) { input := dto.OidcClientCreateDto{ OidcClientUpdateDto: dto.OidcClientUpdateDto{ - Name: "Test Client", - CallbackURLs: []string{"https://example.com/callback"}, + Name: "Test Client", + CallbackURLs: []string{"https://example.com/callback"}, + AccessTokenDurationMinutes: model.DefaultAccessTokenDurationMinutes, + RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes, }, } @@ -606,9 +610,11 @@ func TestOidcService_UpdateClient_description(t *testing.T) { // Update with a description description := "Updated description" input := dto.OidcClientUpdateDto{ - Name: "Test Client", - Description: description, - CallbackURLs: []string{"https://example.com/callback"}, + Name: "Test Client", + Description: description, + CallbackURLs: []string{"https://example.com/callback"}, + AccessTokenDurationMinutes: model.DefaultAccessTokenDurationMinutes, + RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes, } _, err = s.UpdateClient(t.Context(), client.ID, input) @@ -654,6 +660,8 @@ func TestOidcService_UpdateClient_CIMDPreservesMetadataFields(t *testing.T) { require.NoError(t, db.Create(&client).Error) launchURL := "https://app.example.com" + accessDuration := int64(2 * 60) + refreshDuration := int64(7 * 24 * 60) input := dto.OidcClientUpdateDto{ Name: "Overridden Client", Description: "Locally managed description", @@ -666,6 +674,8 @@ func TestOidcService_UpdateClient_CIMDPreservesMetadataFields(t *testing.T) { SkipConsent: true, LaunchURL: &launchURL, IsGroupRestricted: true, + AccessTokenDurationMinutes: accessDuration, + RefreshTokenDurationMinutes: refreshDuration, Credentials: dto.OidcClientCredentialsDto{ FederatedIdentities: []dto.OidcClientFederatedIdentityDto{{ Issuer: "https://override.example.com", @@ -691,6 +701,8 @@ func TestOidcService_UpdateClient_CIMDPreservesMetadataFields(t *testing.T) { assert.Equal(t, input.SkipConsent, fetched.SkipConsent) assert.Equal(t, input.LaunchURL, fetched.LaunchURL) assert.Equal(t, input.IsGroupRestricted, fetched.IsGroupRestricted) + assert.Equal(t, accessDuration, fetched.AccessTokenDurationMinutes) + assert.Equal(t, refreshDuration, fetched.RefreshTokenDurationMinutes) } func TestOidcService_UpdateClient_CIMDDoesNotOverwriteConcurrentMetadataRefresh(t *testing.T) { @@ -715,7 +727,11 @@ func TestOidcService_UpdateClient_CIMDDoesNotOverwriteConcurrentMetadataRefresh( END; `).Error) - input := dto.OidcClientUpdateDto{Description: "Locally managed description"} + input := dto.OidcClientUpdateDto{ + Description: "Locally managed description", + AccessTokenDurationMinutes: model.DefaultAccessTokenDurationMinutes, + RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes, + } _, err = s.UpdateClient(t.Context(), client.ID, input) require.NoError(t, err) diff --git a/backend/resources/migrations/postgres/20260802120000_add_oidc_client_token_lifetimes.down.sql b/backend/resources/migrations/postgres/20260802120000_add_oidc_client_token_lifetimes.down.sql new file mode 100644 index 00000000..3868925b --- /dev/null +++ b/backend/resources/migrations/postgres/20260802120000_add_oidc_client_token_lifetimes.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE oidc_clients DROP COLUMN access_token_duration_minutes; +ALTER TABLE oidc_clients DROP COLUMN refresh_token_duration_minutes; diff --git a/backend/resources/migrations/postgres/20260802120000_add_oidc_client_token_lifetimes.up.sql b/backend/resources/migrations/postgres/20260802120000_add_oidc_client_token_lifetimes.up.sql new file mode 100644 index 00000000..7c23871a --- /dev/null +++ b/backend/resources/migrations/postgres/20260802120000_add_oidc_client_token_lifetimes.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE oidc_clients ADD COLUMN access_token_duration_minutes BIGINT NOT NULL DEFAULT 60; +ALTER TABLE oidc_clients ADD COLUMN refresh_token_duration_minutes BIGINT NOT NULL DEFAULT 43200; diff --git a/backend/resources/migrations/sqlite/20260802120000_add_oidc_client_token_lifetimes.down.sql b/backend/resources/migrations/sqlite/20260802120000_add_oidc_client_token_lifetimes.down.sql new file mode 100644 index 00000000..3868925b --- /dev/null +++ b/backend/resources/migrations/sqlite/20260802120000_add_oidc_client_token_lifetimes.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE oidc_clients DROP COLUMN access_token_duration_minutes; +ALTER TABLE oidc_clients DROP COLUMN refresh_token_duration_minutes; diff --git a/backend/resources/migrations/sqlite/20260802120000_add_oidc_client_token_lifetimes.up.sql b/backend/resources/migrations/sqlite/20260802120000_add_oidc_client_token_lifetimes.up.sql new file mode 100644 index 00000000..32567454 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260802120000_add_oidc_client_token_lifetimes.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE oidc_clients ADD COLUMN access_token_duration_minutes INTEGER NOT NULL DEFAULT 60; +ALTER TABLE oidc_clients ADD COLUMN refresh_token_duration_minutes INTEGER NOT NULL DEFAULT 43200; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 985d24a2..4d83c4fa 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -388,6 +388,19 @@ "preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users", "id_token": "ID Token", "access_token": "Access Token", + "token_lifetimes": "Token lifetimes", + "token_lifetimes_description": "Change the lifetimes of the access and refresh tokens issued to this client. Shorter lifetimes improve security, but may require users to re-authenticate more frequently.", + "access_token_lifetime": "Access token lifetime", + "access_token_lifetime_description": "Controls how long the application credential remains usable. Refresh tokens may keep the application session active.", + "refresh_token_inactivity_timeout": "Refresh token inactivity timeout", + "refresh_token_inactivity_timeout_description": "Controls how long a refresh token remains usable. The timeout restarts whenever the application successfully refreshes its tokens.", + "token_lifetime_minimum": "Token lifetime must be at least 1 minute.", + "token_lifetime_maximum": "Token lifetime cannot exceed 365 days.", + "token_lifetime_whole_minutes": "Token lifetime must use whole-minute increments.", + "duration_unit_for": "{name} unit", + "minutes": "Minutes", + "hours": "Hours", + "days": "Days", "userinfo": "Userinfo", "id_token_payload": "ID Token Payload", "access_token_payload": "Access Token Payload", diff --git a/frontend/src/lib/components/form/duration-input.svelte b/frontend/src/lib/components/form/duration-input.svelte new file mode 100644 index 00000000..a5b7b687 --- /dev/null +++ b/frontend/src/lib/components/form/duration-input.svelte @@ -0,0 +1,108 @@ + + + +
+ {label} + {description} +
+
+ + + + + {unitLabel(unit)} + + + + {#each ['minutes', 'hours', 'days'] as option (option)} + {unitLabel(option as DurationUnit)} + {/each} + + + + + {#if input.error} + {input.error} + {/if} +
+
diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index 7a7d28d8..8ed34ddf 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -46,8 +46,15 @@ export type OidcClient = OidcClientMetaData & { launchURL?: string; isGroupRestricted: boolean; pkceSupported: boolean; + accessTokenDurationMinutes: number; + refreshTokenDurationMinutes: number; }; +export type OidcClientTokenLifetimes = Pick< + OidcClient, + 'accessTokenDurationMinutes' | 'refreshTokenDurationMinutes' +>; + export type OidcClientWithAllowedUserGroups = OidcClient & { allowedUserGroups: UserGroup[]; }; diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte index df276cd8..92ca5bfd 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte @@ -13,7 +13,11 @@ import OidcService from '$lib/services/oidc-service'; import ScimService from '$lib/services/scim-service'; import clientSecretStore from '$lib/stores/client-secret-store'; - import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type'; + import type { + OidcClientCreateWithLogo, + OidcClientCredentials, + OidcClientTokenLifetimes + } from '$lib/types/oidc.type'; import type { ScimServiceProviderCreate } from '$lib/types/scim.type'; import { cachedOidcClientLogo } from '$lib/utils/cached-image-util'; import { axiosErrorToast } from '$lib/utils/error-util'; @@ -29,6 +33,8 @@ import OidcForm from '../oidc-client-form.svelte'; import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte'; import ApiAccessCard from './api-access-card.svelte'; + import OidcClientFederatedCredentialsCard from './oidc-client-federated-credentials-card.svelte'; + import OidcClientTokenLifetimesCard from './oidc-client-token-lifetimes-card.svelte'; import ScimResourceProviderForm from './scim-resource-provider-form.svelte'; let { data } = $props(); @@ -110,6 +116,23 @@ return success; } + async function updateTokenLifetimes(lifetimes: OidcClientTokenLifetimes) { + const success = await updateClient({ ...client, ...lifetimes }); + if (success) { + client.accessTokenDurationMinutes = lifetimes.accessTokenDurationMinutes; + client.refreshTokenDurationMinutes = lifetimes.refreshTokenDurationMinutes; + } + return success; + } + + async function updateFederatedCredentials(credentials: OidcClientCredentials) { + const success = await updateClient({ ...client, credentials }); + if (success) { + client.credentials = credentials; + } + return success; + } + async function enableGroupRestriction() { client.isGroupRestricted = true; await oidcService @@ -334,6 +357,10 @@ + + + + diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-federated-credentials-card.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-federated-credentials-card.svelte new file mode 100644 index 00000000..3a8175c3 --- /dev/null +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-federated-credentials-card.svelte @@ -0,0 +1,121 @@ + + +
+ + +
+
+ {m.federated_client_credentials()} + + {m.federated_client_credentials_description()} + + {m.docs()} + + +
+ {#if !hasFederatedIdentities} + + {/if} +
+
+ {#if hasFederatedIdentities} +
+ + + +
+ {/if} + {#if !isCIMDClient && hasFederatedIdentities} + + + + {/if} +
+
diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-token-lifetimes-card.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-token-lifetimes-card.svelte new file mode 100644 index 00000000..99de2e06 --- /dev/null +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-token-lifetimes-card.svelte @@ -0,0 +1,72 @@ + + +
+ + + {m.token_lifetimes()} + {m.token_lifetimes_description()} + + +
+ + +
+
+ + + +
+
diff --git a/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte b/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte index 30f48fd5..e64b4b46 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/federated-identities-input.svelte @@ -20,7 +20,6 @@ federatedIdentities: OidcClientFederatedIdentity[]; errors?: z.core.$ZodIssue[]; disabled?: boolean; - children?: Snippet; } = $props(); @@ -60,15 +59,10 @@
- -
+ +
{#each federatedIdentities as identity, i (identity)} -
+
Identity {i + 1} {#if federatedIdentities.length > 0} @@ -79,7 +73,7 @@ aria-label="Remove federated identity" {disabled} > - + {/if}
@@ -159,14 +153,14 @@
{/if} diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index 2bdaaecc..bd47b034 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -1,6 +1,6 @@ { "provider": "sqlite", - "version": 20260731120000, + "version": 20260802120000, "tableOrder": [ "users", "user_groups", @@ -87,6 +87,7 @@ ], "oidc_clients": [ { + "access_token_duration_minutes": 60, "callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=", "client_type": "standard", "created_at": "2025-11-25T12:39:02Z", @@ -107,10 +108,12 @@ "pkce_supported": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, + "refresh_token_duration_minutes": 43200, "secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", "skip_consent": false }, { + "access_token_duration_minutes": 60, "callback_urls": "WyJodHRwOi8vaW1taWNoLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=", "client_type": "standard", "created_at": "2025-11-25T12:39:02Z", @@ -131,10 +134,12 @@ "pkce_supported": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, + "refresh_token_duration_minutes": 43200, "secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", "skip_consent": false }, { + "access_token_duration_minutes": 60, "callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=", "client_type": "standard", "created_at": "2025-11-25T12:39:02Z", @@ -155,10 +160,12 @@ "pkce_supported": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, + "refresh_token_duration_minutes": 43200, "secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", "skip_consent": false }, { + "access_token_duration_minutes": 60, "callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=", "client_type": "standard", "created_at": "2025-11-25T12:39:02Z", @@ -179,10 +186,12 @@ "pkce_supported": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, + "refresh_token_duration_minutes": 43200, "secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", "skip_consent": false }, { + "access_token_duration_minutes": 60, "callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd", "client_type": "standard", "created_at": "2025-11-25T12:39:02Z", @@ -202,10 +211,12 @@ "pkce_supported": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, + "refresh_token_duration_minutes": 43200, "secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", "skip_consent": false }, { + "access_token_duration_minutes": 60, "callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd", "client_type": "standard", "created_at": "2025-11-25T12:39:02Z", @@ -226,10 +237,12 @@ "pkce_supported": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, + "refresh_token_duration_minutes": 43200, "secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", "skip_consent": false }, { + "access_token_duration_minutes": 60, "callback_urls": "WyJodHRwOi8vc2tpcC1jb25zZW50LmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=", "client_type": "standard", "created_at": "2025-11-25T12:39:02Z", @@ -250,6 +263,7 @@ "pkce_supported": false, "requires_pushed_authorization_requests": false, "requires_reauthentication": false, + "refresh_token_duration_minutes": 43200, "secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", "skip_consent": true } diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index b4afebaf..49caff98 100644 --- a/tests/specs/oidc-client-settings.spec.ts +++ b/tests/specs/oidc-client-settings.spec.ts @@ -2,6 +2,11 @@ import test, { expect, Page } from '@playwright/test'; import { oidcClients, userGroups } from '../data'; import { cleanupBackend } from '../utils/cleanup.util'; +const defaultTokenLifetimes = { + accessTokenDurationMinutes: 60, + refreshTokenDurationMinutes: 30 * 24 * 60 +}; + test.beforeEach(async () => await cleanupBackend()); test.describe('Create OIDC client', () => { @@ -78,7 +83,8 @@ test('Edit OIDC client', async ({ page }) => { await page.locator('[role="tab"][data-value="dark-logo"]').first().click(); await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png'); await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL); - await page.getByRole('button', { name: 'Save' }).click(); + const clientForm = page.getByLabel('Name').locator('xpath=ancestor::form'); + await clientForm.getByRole('button', { name: 'Save' }).click(); await expect(page.locator('[data-type="success"]')).toHaveText( 'OIDC client updated successfully' @@ -110,6 +116,98 @@ test('Displays OIDC client endpoints from discovery configuration', async ({ pag await expect(page.getByText(oidcConfiguration.jwks_uri, { exact: true })).toBeVisible(); }); +test('Update OIDC client token lifetimes', async ({ page }) => { + await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`); + + const card = page.getByTestId('token-lifetimes-card'); + const accessLifetime = card.getByLabel('Access token lifetime', { exact: true }); + const accessUnit = card.getByLabel('Access token lifetime unit'); + const refreshLifetime = card.getByLabel('Refresh token inactivity timeout', { exact: true }); + const refreshUnit = card.getByLabel('Refresh token inactivity timeout unit'); + + await expect(accessLifetime).toHaveValue('1'); + await expect(accessUnit).toHaveText('Hours'); + await expect(refreshLifetime).toHaveValue('30'); + await expect(refreshUnit).toHaveText('Days'); + + await accessUnit.click(); + await page.getByRole('option', { name: 'Minutes' }).click(); + await expect(accessLifetime).toHaveValue('60'); + await accessLifetime.fill('90'); + + await refreshUnit.click(); + await page.getByRole('option', { name: 'Hours' }).click(); + await expect(refreshLifetime).toHaveValue('720'); + await refreshLifetime.fill('336'); + + await card.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible(); + + await page.reload(); + await expect(card.getByLabel('Access token lifetime', { exact: true })).toHaveValue('90'); + await expect(card.getByLabel('Access token lifetime unit')).toHaveText('Minutes'); + await expect(card.getByLabel('Refresh token inactivity timeout', { exact: true })).toHaveValue( + '14' + ); + await expect(card.getByLabel('Refresh token inactivity timeout unit')).toHaveText('Days'); + + await card.getByLabel('Access token lifetime', { exact: true }).fill('0'); + await card.getByRole('button', { name: 'Save' }).click(); + await expect(card.getByText('Token lifetime must be at least 1 minute.')).toBeVisible(); + + await card.getByLabel('Access token lifetime', { exact: true }).fill('525601'); + await card.getByRole('button', { name: 'Save' }).click(); + await expect(card.getByText('Token lifetime cannot exceed 365 days.')).toBeVisible(); + + await card.getByLabel('Access token lifetime', { exact: true }).fill('1.5'); + await card.getByRole('button', { name: 'Save' }).click(); + await expect(card.getByText('Token lifetime must use whole-minute increments.')).toBeVisible(); + + await card.getByLabel('Access token lifetime', { exact: true }).fill('60'); + await card.getByLabel('Refresh token inactivity timeout', { exact: true }).fill('30'); + await card.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible(); +}); + +test('Update OIDC client federated credentials', async ({ page }) => { + const client = oidcClients.nextcloud; + await page.goto(`/settings/admin/oidc-clients/${client.id}`); + + const card = page.getByTestId('federated-credentials-card'); + await card.getByRole('button', { name: 'Create', exact: true }).click(); + await card.getByLabel('Issuer').fill('https://issuer.example.com'); + await card.getByLabel('Subject').fill('workload-client'); + await card.getByLabel('Audience').fill('https://pocket-id.example.com'); + + const cardUpdate = page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().endsWith(`/api/oidc/clients/${client.id}`) + ); + await card.getByRole('button', { name: 'Save' }).click(); + expect((await cardUpdate).ok()).toBeTruthy(); + + await page.reload(); + await expect(card.getByLabel('Issuer')).toHaveValue('https://issuer.example.com'); + await expect(card.getByLabel('Subject')).toHaveValue('workload-client'); + await expect(card.getByLabel('Audience')).toHaveValue('https://pocket-id.example.com'); + + // Saving the main client form must preserve credentials managed by the separate card + const description = page.getByLabel('Description'); + await description.fill('Updated without replacing federated credentials'); + const clientForm = description.locator('xpath=ancestor::form'); + const formUpdate = page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().endsWith(`/api/oidc/clients/${client.id}`) + ); + await clientForm.getByRole('button', { name: 'Save' }).click(); + expect((await formUpdate).ok()).toBeTruthy(); + + await page.reload(); + await expect(card.getByLabel('Issuer')).toHaveValue('https://issuer.example.com'); +}); + test('Create new OIDC client secret', async ({ page }) => { const oidcClient = oidcClients.nextcloud; await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`); @@ -148,6 +246,7 @@ test('Filter OIDC clients by PAR requirement', async ({ page, request }) => { // Enable PAR on the PAR test client await request.put(`/api/oidc/clients/${parClient.id}`, { data: { + ...defaultTokenLifetimes, name: parClient.name, callbackURLs: [parClient.callbackUrl], logoutCallbackURLs: [], diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index a75c9e32..08f64ea5 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -5,6 +5,11 @@ import { generateIdToken } from '../utils/jwt.util'; import * as oidcUtil from '../utils/oidc.util'; import passkeyUtil from '../utils/passkey.util'; +const defaultTokenLifetimes = { + accessTokenDurationMinutes: 60, + refreshTokenDurationMinutes: 30 * 24 * 60 +}; + test.beforeEach(async () => await cleanupBackend()); async function generateSeededOauthAccessToken( @@ -760,6 +765,7 @@ test('Device authorization flow forces reauthentication when client requires it' const client = oidcClients.nextcloud; await request.put(`/api/oidc/clients/${client.id}`, { data: { + ...defaultTokenLifetimes, name: client.name, callbackURLs: [client.callbackUrl], logoutCallbackURLs: [client.logoutCallbackUrl], @@ -885,6 +891,7 @@ test('Forces reauthentication when client requires it', async ({ page, request } await request.put(`/api/oidc/clients/${oidcClients.nextcloud.id}`, { data: { + ...defaultTokenLifetimes, name: oidcClients.nextcloud.name, callbackURLs: [oidcClients.nextcloud.callbackUrl], logoutCallbackURLs: [oidcClients.nextcloud.logoutCallbackUrl], @@ -1438,6 +1445,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => { await page.request.put(`/api/oidc/clients/${client.id}`, { headers: { 'Content-Type': 'application/json' }, data: { + ...defaultTokenLifetimes, name: client.name, callbackURLs: [client.callbackUrl], logoutCallbackURLs: [], @@ -1481,6 +1489,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => { await request.put(`/api/oidc/clients/${client.id}`, { headers: { 'Content-Type': 'application/json' }, data: { + ...defaultTokenLifetimes, name: client.name, callbackURLs: [client.callbackUrl], logoutCallbackURLs: [], @@ -1514,7 +1523,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => { await parToggle.click(); } - await page.getByRole('button', { name: /save/i }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).first().click(); await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible(); await page.reload(); @@ -1564,7 +1573,8 @@ test.describe('OIDC skip consent', () => { // Disabling it and saving must persist across a reload await toggle.click(); await expect(toggle).not.toBeChecked(); - await page.getByRole('button', { name: /save/i }).click(); + const clientForm = toggle.locator('xpath=ancestor::form'); + await clientForm.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible(); await page.reload();