mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-12 20:59:04 +02:00
feat: add ability to customize session duration of clients (#1641)
This commit is contained in:
@@ -24,6 +24,8 @@ type OidcClientDto struct {
|
|||||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||||
PkceSupported bool `json:"pkceSupported,omitempty"`
|
PkceSupported bool `json:"pkceSupported,omitempty"`
|
||||||
|
AccessTokenDurationMinutes int64 `json:"accessTokenDurationMinutes"`
|
||||||
|
RefreshTokenDurationMinutes int64 `json:"refreshTokenDurationMinutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientWithAllowedUserGroupsDto struct {
|
type OidcClientWithAllowedUserGroupsDto struct {
|
||||||
@@ -53,6 +55,8 @@ type OidcClientUpdateDto struct {
|
|||||||
LogoURL *string `json:"logoUrl"`
|
LogoURL *string `json:"logoUrl"`
|
||||||
DarkLogoURL *string `json:"darkLogoUrl"`
|
DarkLogoURL *string `json:"darkLogoUrl"`
|
||||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||||
|
AccessTokenDurationMinutes int64 `json:"accessTokenDurationMinutes" binding:"required,token_duration"`
|
||||||
|
RefreshTokenDurationMinutes int64 `json:"refreshTokenDurationMinutes" binding:"required,token_duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCreateDto struct {
|
type OidcClientCreateDto struct {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ory/fosite"
|
"github.com/ory/fosite"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin/binding"
|
"github.com/gin-gonic/gin/binding"
|
||||||
@@ -61,6 +62,9 @@ func init() {
|
|||||||
"resource_uri": func(fl validator.FieldLevel) bool {
|
"resource_uri": func(fl validator.FieldLevel) bool {
|
||||||
return ValidateResourceURI(fl.Field().String())
|
return ValidateResourceURI(fl.Field().String())
|
||||||
},
|
},
|
||||||
|
"token_duration": func(fl validator.FieldLevel) bool {
|
||||||
|
return model.IsValidTokenDurationMinutes(fl.Field().Int())
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for k, v := range validators {
|
for k, v := range validators {
|
||||||
err := engine.RegisterValidation(k, v)
|
err := engine.RegisterValidation(k, v)
|
||||||
|
|||||||
@@ -3,9 +3,38 @@ package dto
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
func TestValidateUsername(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ type OidcClientType string
|
|||||||
const (
|
const (
|
||||||
OidcClientTypeStandard OidcClientType = "standard"
|
OidcClientTypeStandard OidcClientType = "standard"
|
||||||
OidcClientTypeCIMD OidcClientType = "cimd"
|
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 {
|
type OidcClient struct {
|
||||||
@@ -49,6 +58,8 @@ type OidcClient struct {
|
|||||||
ClientType OidcClientType `gorm:"default:standard" sortable:"true" filterable:"true"`
|
ClientType OidcClientType `gorm:"default:standard" sortable:"true" filterable:"true"`
|
||||||
MetadataExpiresAt *datatype.DateTime
|
MetadataExpiresAt *datatype.DateTime
|
||||||
MetadataGrantTypes datatype.StringList
|
MetadataGrantTypes datatype.StringList
|
||||||
|
AccessTokenDurationMinutes int64 `gorm:"default:60"`
|
||||||
|
RefreshTokenDurationMinutes int64 `gorm:"default:43200"`
|
||||||
|
|
||||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||||
CreatedByID *string
|
CreatedByID *string
|
||||||
@@ -56,6 +67,11 @@ type OidcClient struct {
|
|||||||
UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
|
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 {
|
func (c OidcClient) HasLogo() bool {
|
||||||
return c.ImageType != nil && *c.ImageType != ""
|
return c.ImageType != nil && *c.ImageType != ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,12 +201,14 @@ func buildClientFromMetadata(doc *fosite.ClientMetadataDocument, rawURL string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := model.OidcClient{
|
client := model.OidcClient{
|
||||||
Base: model.Base{ID: rawURL},
|
Base: model.Base{ID: rawURL},
|
||||||
Name: doc.ClientName,
|
Name: doc.ClientName,
|
||||||
CallbackURLs: datatype.StringList(doc.RedirectURIs),
|
CallbackURLs: datatype.StringList(doc.RedirectURIs),
|
||||||
LogoutCallbackURLs: datatype.StringList(doc.PostLogoutRedirectURIs),
|
LogoutCallbackURLs: datatype.StringList(doc.PostLogoutRedirectURIs),
|
||||||
ClientType: model.OidcClientTypeCIMD,
|
ClientType: model.OidcClientTypeCIMD,
|
||||||
MetadataGrantTypes: datatype.StringList(grantTypes),
|
MetadataGrantTypes: datatype.StringList(grantTypes),
|
||||||
|
AccessTokenDurationMinutes: model.DefaultAccessTokenDurationMinutes,
|
||||||
|
RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch doc.TokenEndpointAuthMethod {
|
switch doc.TokenEndpointAuthMethod {
|
||||||
|
|||||||
@@ -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{"https://app.example.com/logout"}, []string(c.LogoutCallbackURLs))
|
||||||
assert.Equal(t, []string{"authorization_code"}, []string(c.MetadataGrantTypes))
|
assert.Equal(t, []string{"authorization_code"}, []string(c.MetadataGrantTypes))
|
||||||
assert.Empty(t, c.Credentials.FederatedIdentities)
|
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) {
|
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})
|
s := newMetadataStore(t, map[string]*http.Response{id: resp})
|
||||||
|
|
||||||
fresh := datatype.DateTime(time.Now().Add(time.Hour))
|
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)
|
require.NoError(t, s.db.Create(&seed).Error)
|
||||||
|
|
||||||
// A normal lookup still returns the cached value.
|
// A normal lookup still returns the cached value.
|
||||||
@@ -174,6 +185,8 @@ func TestRefreshMetadataClient(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "App", c.Name)
|
assert.Equal(t, "App", c.Name)
|
||||||
assert.True(t, c.IsMetadataDocument())
|
assert.True(t, c.IsMetadataDocument())
|
||||||
|
assert.Equal(t, int64(2*60), c.AccessTokenDurationMinutes)
|
||||||
|
assert.Equal(t, int64(7*24*60), c.RefreshTokenDurationMinutes)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package oidc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ory/fosite"
|
"github.com/ory/fosite"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
@@ -86,3 +87,36 @@ func (c Client) GetResponseModes() []fosite.ResponseModeType {
|
|||||||
fosite.ResponseModeFormPost,
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
package oidc
|
package oidc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ory/fosite"
|
"github.com/ory/fosite"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Interface assertions
|
// Interface assertions
|
||||||
var (
|
var (
|
||||||
_ fosite.Client = (*Client)(nil)
|
_ fosite.Client = (*Client)(nil)
|
||||||
_ fosite.ResponseModeClient = (*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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
func (b *ClientPreviewBuilder) newPreviewRequest(ctx context.Context, client model.OidcClient, userID string, scopes fosite.Arguments, authenticationMethod string) *fosite.Request {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
runtimeClient := Client{OidcClient: client}
|
||||||
session := NewAuthenticatedSession(userID, authenticationMethod, now, now)
|
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 := fosite.NewRequest()
|
||||||
request.RequestedAt = now
|
request.RequestedAt = now
|
||||||
request.Client = Client{OidcClient: client}
|
request.Client = runtimeClient
|
||||||
request.RequestedScope = scopes
|
request.RequestedScope = scopes
|
||||||
request.GrantedScope = scopes
|
request.GrantedScope = scopes
|
||||||
request.RequestedAudience = fosite.Arguments{client.ID}
|
request.RequestedAudience = fosite.Arguments{client.ID}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -40,8 +41,9 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
|
|||||||
}).Error)
|
}).Error)
|
||||||
|
|
||||||
preview, err := builder.BuildClientPreview(t.Context(), model.OidcClient{
|
preview, err := builder.BuildClientPreview(t.Context(), model.OidcClient{
|
||||||
Base: model.Base{ID: clientID},
|
Base: model.Base{ID: clientID},
|
||||||
Name: "Test Client",
|
Name: "Test Client",
|
||||||
|
AccessTokenDurationMinutes: 2 * 60,
|
||||||
}, userID, []string{"openid", "email"}, "phr")
|
}, userID, []string{"openid", "email"}, "phr")
|
||||||
require.NoError(t, err)
|
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
|
// 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.ElementsMatch(t, []string{clientID, "https://issuer.example.com"}, stringSliceClaim(t, preview.AccessToken["aud"]))
|
||||||
require.NotContains(t, preview.AccessToken, "type")
|
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"])
|
require.Equal(t, userID, preview.IDToken["sub"])
|
||||||
// ID tokens carry the "type" marker (so the end-session endpoint can reject access tokens
|
// ID tokens carry the "type" marker (so the end-session endpoint can reject access tokens
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
|
|||||||
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, db.Create(&model.OidcClient{
|
require.NoError(t, db.Create(&model.OidcClient{
|
||||||
Base: model.Base{ID: clientID},
|
Base: model.Base{ID: clientID},
|
||||||
Name: "Client Credentials Client",
|
Name: "Client Credentials Client",
|
||||||
Secret: string(hashed),
|
Secret: string(hashed),
|
||||||
IsPublic: false,
|
IsPublic: false,
|
||||||
|
AccessTokenDurationMinutes: 2 * 60,
|
||||||
}).Error)
|
}).Error)
|
||||||
|
|
||||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: key}, Config{
|
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.Contains(t, jwtAudience(claims), clientID, "access token must be audience-bound to the client")
|
||||||
require.Equal(t, "client-"+clientID, claims["sub"])
|
require.Equal(t, "client-"+clientID, claims["sub"])
|
||||||
require.Equal(t, clientID, claims["client_id"])
|
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
|
// 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")
|
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) {
|
t.Run("disabled user is rejected on refresh", func(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
const clientID, userID = "client-disabled", "user-disabled"
|
const clientID, userID = "client-disabled", "user-disabled"
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ const (
|
|||||||
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
|
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
|
||||||
GrantTypeClientCredentials = "client_credentials"
|
GrantTypeClientCredentials = "client_credentials"
|
||||||
|
|
||||||
AccessTokenDuration = time.Hour
|
AccessTokenDuration = time.Duration(model.DefaultAccessTokenDurationMinutes) * time.Minute
|
||||||
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
|
RefreshTokenDuration = time.Duration(model.DefaultRefreshTokenDurationMinutes) * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type OidcService struct {
|
type OidcService struct {
|
||||||
@@ -148,7 +148,9 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
|
|||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
ID: input.ID,
|
ID: input.ID,
|
||||||
},
|
},
|
||||||
CreatedByID: new(userID),
|
CreatedByID: new(userID),
|
||||||
|
AccessTokenDurationMinutes: model.DefaultAccessTokenDurationMinutes,
|
||||||
|
RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes,
|
||||||
}
|
}
|
||||||
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
|
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
|
||||||
|
|
||||||
@@ -213,6 +215,8 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
|
|||||||
"SkipConsent",
|
"SkipConsent",
|
||||||
"LaunchURL",
|
"LaunchURL",
|
||||||
"IsGroupRestricted",
|
"IsGroupRestricted",
|
||||||
|
"AccessTokenDurationMinutes",
|
||||||
|
"RefreshTokenDurationMinutes",
|
||||||
).
|
).
|
||||||
Updates(&client).Error
|
Updates(&client).Error
|
||||||
} else {
|
} else {
|
||||||
@@ -253,6 +257,8 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
|
|||||||
client.SkipConsent = input.SkipConsent
|
client.SkipConsent = input.SkipConsent
|
||||||
client.LaunchURL = input.LaunchURL
|
client.LaunchURL = input.LaunchURL
|
||||||
client.IsGroupRestricted = input.IsGroupRestricted
|
client.IsGroupRestricted = input.IsGroupRestricted
|
||||||
|
client.AccessTokenDurationMinutes = input.AccessTokenDurationMinutes
|
||||||
|
client.RefreshTokenDurationMinutes = input.RefreshTokenDurationMinutes
|
||||||
|
|
||||||
// Preserve fields that are sourced from the client metadata document
|
// Preserve fields that are sourced from the client metadata document
|
||||||
if client.IsMetadataDocument() {
|
if client.IsMetadataDocument() {
|
||||||
|
|||||||
@@ -528,9 +528,11 @@ func TestOidcService_CreateClient_withDescription(t *testing.T) {
|
|||||||
description := "A test client description"
|
description := "A test client description"
|
||||||
input := dto.OidcClientCreateDto{
|
input := dto.OidcClientCreateDto{
|
||||||
OidcClientUpdateDto: dto.OidcClientUpdateDto{
|
OidcClientUpdateDto: dto.OidcClientUpdateDto{
|
||||||
Name: "Test Client",
|
Name: "Test Client",
|
||||||
Description: description,
|
Description: description,
|
||||||
CallbackURLs: []string{"https://example.com/callback"},
|
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{
|
input := dto.OidcClientCreateDto{
|
||||||
OidcClientUpdateDto: dto.OidcClientUpdateDto{
|
OidcClientUpdateDto: dto.OidcClientUpdateDto{
|
||||||
Name: "Test Client",
|
Name: "Test Client",
|
||||||
CallbackURLs: []string{"https://example.com/callback"},
|
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
|
// Update with a description
|
||||||
description := "Updated description"
|
description := "Updated description"
|
||||||
input := dto.OidcClientUpdateDto{
|
input := dto.OidcClientUpdateDto{
|
||||||
Name: "Test Client",
|
Name: "Test Client",
|
||||||
Description: description,
|
Description: description,
|
||||||
CallbackURLs: []string{"https://example.com/callback"},
|
CallbackURLs: []string{"https://example.com/callback"},
|
||||||
|
AccessTokenDurationMinutes: model.DefaultAccessTokenDurationMinutes,
|
||||||
|
RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.UpdateClient(t.Context(), client.ID, input)
|
_, 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)
|
require.NoError(t, db.Create(&client).Error)
|
||||||
|
|
||||||
launchURL := "https://app.example.com"
|
launchURL := "https://app.example.com"
|
||||||
|
accessDuration := int64(2 * 60)
|
||||||
|
refreshDuration := int64(7 * 24 * 60)
|
||||||
input := dto.OidcClientUpdateDto{
|
input := dto.OidcClientUpdateDto{
|
||||||
Name: "Overridden Client",
|
Name: "Overridden Client",
|
||||||
Description: "Locally managed description",
|
Description: "Locally managed description",
|
||||||
@@ -666,6 +674,8 @@ func TestOidcService_UpdateClient_CIMDPreservesMetadataFields(t *testing.T) {
|
|||||||
SkipConsent: true,
|
SkipConsent: true,
|
||||||
LaunchURL: &launchURL,
|
LaunchURL: &launchURL,
|
||||||
IsGroupRestricted: true,
|
IsGroupRestricted: true,
|
||||||
|
AccessTokenDurationMinutes: accessDuration,
|
||||||
|
RefreshTokenDurationMinutes: refreshDuration,
|
||||||
Credentials: dto.OidcClientCredentialsDto{
|
Credentials: dto.OidcClientCredentialsDto{
|
||||||
FederatedIdentities: []dto.OidcClientFederatedIdentityDto{{
|
FederatedIdentities: []dto.OidcClientFederatedIdentityDto{{
|
||||||
Issuer: "https://override.example.com",
|
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.SkipConsent, fetched.SkipConsent)
|
||||||
assert.Equal(t, input.LaunchURL, fetched.LaunchURL)
|
assert.Equal(t, input.LaunchURL, fetched.LaunchURL)
|
||||||
assert.Equal(t, input.IsGroupRestricted, fetched.IsGroupRestricted)
|
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) {
|
func TestOidcService_UpdateClient_CIMDDoesNotOverwriteConcurrentMetadataRefresh(t *testing.T) {
|
||||||
@@ -715,7 +727,11 @@ func TestOidcService_UpdateClient_CIMDDoesNotOverwriteConcurrentMetadataRefresh(
|
|||||||
END;
|
END;
|
||||||
`).Error)
|
`).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)
|
_, err = s.UpdateClient(t.Context(), client.ID, input)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN access_token_duration_minutes;
|
||||||
|
ALTER TABLE oidc_clients DROP COLUMN refresh_token_duration_minutes;
|
||||||
+2
@@ -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;
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN access_token_duration_minutes;
|
||||||
|
ALTER TABLE oidc_clients DROP COLUMN refresh_token_duration_minutes;
|
||||||
+2
@@ -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;
|
||||||
@@ -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",
|
"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",
|
"id_token": "ID Token",
|
||||||
"access_token": "Access 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",
|
"userinfo": "Userinfo",
|
||||||
"id_token_payload": "ID Token Payload",
|
"id_token_payload": "ID Token Payload",
|
||||||
"access_token_payload": "Access Token Payload",
|
"access_token_payload": "Access Token Payload",
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as ButtonGroup from '$lib/components/ui/button-group';
|
||||||
|
import * as Field from '$lib/components/ui/field';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type { FormInput } from '$lib/utils/form-util';
|
||||||
|
|
||||||
|
type DurationUnit = 'minutes' | 'hours' | 'days';
|
||||||
|
|
||||||
|
const minutesPerUnit: Record<DurationUnit, number> = {
|
||||||
|
minutes: 1,
|
||||||
|
hours: 60,
|
||||||
|
days: 24 * 60
|
||||||
|
};
|
||||||
|
const minimumMinutes = 1;
|
||||||
|
const maximumMinutes = 365 * 24 * 60;
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
input = $bindable()
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
input: FormInput<number>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function preferredUnit(minutes: number): DurationUnit {
|
||||||
|
if (minutes % minutesPerUnit.days === 0) return 'days';
|
||||||
|
if (minutes % minutesPerUnit.hours === 0) return 'hours';
|
||||||
|
return 'minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(value: number): string {
|
||||||
|
return Number(value.toFixed(10)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
let unit = $state<DurationUnit>(preferredUnit(input.value));
|
||||||
|
let amount = $state(formatAmount(input.value / minutesPerUnit[unit]));
|
||||||
|
|
||||||
|
function updateAmount(event: Event) {
|
||||||
|
amount = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
input.value = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUnit(value: string | undefined) {
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
unit = value as DurationUnit;
|
||||||
|
if (Number.isFinite(input.value)) {
|
||||||
|
amount = formatAmount(input.value / minutesPerUnit[unit]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitLabel(value: DurationUnit): string {
|
||||||
|
switch (value) {
|
||||||
|
case 'minutes':
|
||||||
|
return m.minutes();
|
||||||
|
case 'hours':
|
||||||
|
return m.hours();
|
||||||
|
case 'days':
|
||||||
|
return m.days();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Field.Field>
|
||||||
|
<div>
|
||||||
|
<Field.Label for={id}>{label}</Field.Label>
|
||||||
|
<Field.Description>{description}</Field.Description>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ButtonGroup.Root class="w-full">
|
||||||
|
<Input
|
||||||
|
{id}
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
min={minimumMinutes / minutesPerUnit[unit]}
|
||||||
|
max={maximumMinutes / minutesPerUnit[unit]}
|
||||||
|
step={minimumMinutes / minutesPerUnit[unit]}
|
||||||
|
aria-invalid={!!input.error}
|
||||||
|
oninput={updateAmount}
|
||||||
|
/>
|
||||||
|
<Select.Root type="single" value={unit} onValueChange={updateUnit}>
|
||||||
|
<Select.Trigger
|
||||||
|
class="w-32"
|
||||||
|
aria-label={m.duration_unit_for({ name: label })}
|
||||||
|
aria-invalid={!!input.error}
|
||||||
|
>
|
||||||
|
{unitLabel(unit)}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Group>
|
||||||
|
{#each ['minutes', 'hours', 'days'] as option (option)}
|
||||||
|
<Select.Item value={option}>{unitLabel(option as DurationUnit)}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Group>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</ButtonGroup.Root>
|
||||||
|
{#if input.error}
|
||||||
|
<Field.Error>{input.error}</Field.Error>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Field.Field>
|
||||||
@@ -46,8 +46,15 @@ export type OidcClient = OidcClientMetaData & {
|
|||||||
launchURL?: string;
|
launchURL?: string;
|
||||||
isGroupRestricted: boolean;
|
isGroupRestricted: boolean;
|
||||||
pkceSupported: boolean;
|
pkceSupported: boolean;
|
||||||
|
accessTokenDurationMinutes: number;
|
||||||
|
refreshTokenDurationMinutes: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OidcClientTokenLifetimes = Pick<
|
||||||
|
OidcClient,
|
||||||
|
'accessTokenDurationMinutes' | 'refreshTokenDurationMinutes'
|
||||||
|
>;
|
||||||
|
|
||||||
export type OidcClientWithAllowedUserGroups = OidcClient & {
|
export type OidcClientWithAllowedUserGroups = OidcClient & {
|
||||||
allowedUserGroups: UserGroup[];
|
allowedUserGroups: UserGroup[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,11 @@
|
|||||||
import OidcService from '$lib/services/oidc-service';
|
import OidcService from '$lib/services/oidc-service';
|
||||||
import ScimService from '$lib/services/scim-service';
|
import ScimService from '$lib/services/scim-service';
|
||||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
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 type { ScimServiceProviderCreate } from '$lib/types/scim.type';
|
||||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
@@ -29,6 +33,8 @@
|
|||||||
import OidcForm from '../oidc-client-form.svelte';
|
import OidcForm from '../oidc-client-form.svelte';
|
||||||
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
|
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
|
||||||
import ApiAccessCard from './api-access-card.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';
|
import ScimResourceProviderForm from './scim-resource-provider-form.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -110,6 +116,23 @@
|
|||||||
return success;
|
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() {
|
async function enableGroupRestriction() {
|
||||||
client.isGroupRestricted = true;
|
client.isGroupRestricted = true;
|
||||||
await oidcService
|
await oidcService
|
||||||
@@ -334,6 +357,10 @@
|
|||||||
<OidcForm mode="update" existingClient={client} callback={updateClient} />
|
<OidcForm mode="update" existingClient={client} callback={updateClient} />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
<OidcClientTokenLifetimesCard {client} callback={updateTokenLifetimes} />
|
||||||
|
|
||||||
|
<OidcClientFederatedCredentialsCard {client} callback={updateFederatedCredentials} />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="user-groups" id="allowed-user-groups">
|
<Tabs.Content value="user-groups" id="allowed-user-groups">
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type { OidcClient, OidcClientCredentials } from '$lib/types/oidc.type';
|
||||||
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
|
import { createForm } from '$lib/utils/form-util';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import FederatedIdentitiesInput from '../federated-identities-input.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
client,
|
||||||
|
callback
|
||||||
|
}: {
|
||||||
|
client: OidcClient;
|
||||||
|
callback: (credentials: OidcClientCredentials) => Promise<boolean>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
const isCIMDClient = $derived(client.clientType === 'cimd');
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
credentials: z.object({
|
||||||
|
federatedIdentities: z.array(
|
||||||
|
z.object({
|
||||||
|
issuer: z.url(),
|
||||||
|
subject: z.string().optional(),
|
||||||
|
audience: z.string().optional(),
|
||||||
|
jwks: z.url().optional().or(z.literal('')),
|
||||||
|
replayProtection: z.boolean().default(true)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const { inputs, errors, ...form } = createForm(formSchema, {
|
||||||
|
credentials: {
|
||||||
|
federatedIdentities:
|
||||||
|
client.credentials?.federatedIdentities?.map((identity) => ({ ...identity })) ?? []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasFederatedIdentities = $derived($inputs.credentials.value.federatedIdentities.length > 0);
|
||||||
|
|
||||||
|
function getFederatedIdentityErrors(errors: z.ZodError<any> | undefined) {
|
||||||
|
return errors?.issues
|
||||||
|
.filter((error) =>
|
||||||
|
['credentials', 'federatedIdentities'].every(
|
||||||
|
(segment, index) => error.path[index] === segment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((error) => ({ ...error, path: error.path.slice(2) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFederatedIdentity() {
|
||||||
|
$inputs.credentials.value.federatedIdentities = [
|
||||||
|
...$inputs.credentials.value.federatedIdentities,
|
||||||
|
{
|
||||||
|
issuer: '',
|
||||||
|
subject: '',
|
||||||
|
audience: '',
|
||||||
|
jwks: '',
|
||||||
|
replayProtection: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (isCIMDClient) return;
|
||||||
|
|
||||||
|
const data = form.validate();
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
await callback(data.credentials).finally(() => (isLoading = false));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form novalidate onsubmit={preventDefault(onSubmit)}>
|
||||||
|
<Card.Root data-testid="federated-credentials-card">
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Card.Title>{m.federated_client_credentials()}</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
{m.federated_client_credentials_description()}
|
||||||
|
<a
|
||||||
|
class="underline underline-offset-4"
|
||||||
|
href="https://pocket-id.org/docs/guides/oidc-client-authentication"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{m.docs()}
|
||||||
|
</a>
|
||||||
|
</Card.Description>
|
||||||
|
</div>
|
||||||
|
{#if !hasFederatedIdentities}
|
||||||
|
<Button disabled={isCIMDClient} onclick={addFederatedIdentity}>
|
||||||
|
{m.create()}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
{#if hasFederatedIdentities}
|
||||||
|
<div transition:slide>
|
||||||
|
<Card.Content>
|
||||||
|
<FederatedIdentitiesInput
|
||||||
|
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
|
||||||
|
errors={getFederatedIdentityErrors($errors)}
|
||||||
|
disabled={isCIMDClient}
|
||||||
|
/>
|
||||||
|
</Card.Content>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !isCIMDClient && hasFederatedIdentities}
|
||||||
|
<Card.Footer class="justify-end">
|
||||||
|
<Button type="submit" disabled={isLoading}>{m.save()}</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
{/if}
|
||||||
|
</Card.Root>
|
||||||
|
</form>
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import DurationInput from '$lib/components/form/duration-input.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type { OidcClient, OidcClientTokenLifetimes } from '$lib/types/oidc.type';
|
||||||
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
|
import { createForm } from '$lib/utils/form-util';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
|
let {
|
||||||
|
client,
|
||||||
|
callback
|
||||||
|
}: {
|
||||||
|
client: OidcClient;
|
||||||
|
callback: (lifetimes: OidcClientTokenLifetimes) => Promise<boolean>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
const durationSchema = z
|
||||||
|
.number()
|
||||||
|
.min(1, { message: m.token_lifetime_minimum() })
|
||||||
|
.max(365 * 24 * 60, { message: m.token_lifetime_maximum() })
|
||||||
|
.refine((minutes) => Number.isInteger(minutes), {
|
||||||
|
message: m.token_lifetime_whole_minutes()
|
||||||
|
});
|
||||||
|
const formSchema = z.object({
|
||||||
|
accessTokenDurationMinutes: durationSchema,
|
||||||
|
refreshTokenDurationMinutes: durationSchema
|
||||||
|
});
|
||||||
|
const { inputs, ...form } = createForm(formSchema, {
|
||||||
|
accessTokenDurationMinutes: client.accessTokenDurationMinutes,
|
||||||
|
refreshTokenDurationMinutes: client.refreshTokenDurationMinutes
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
const data = form.validate();
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
await callback(data).finally(() => (isLoading = false));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form novalidate onsubmit={preventDefault(onSubmit)}>
|
||||||
|
<Card.Root data-testid="token-lifetimes-card">
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>{m.token_lifetimes()}</Card.Title>
|
||||||
|
<Card.Description>{m.token_lifetimes_description()}</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="md:grid md:grid-cols-2 gap-10 space-y-5 md:space-y-0">
|
||||||
|
<DurationInput
|
||||||
|
id="access-token-lifetime"
|
||||||
|
label={m.access_token_lifetime()}
|
||||||
|
description={m.access_token_lifetime_description()}
|
||||||
|
bind:input={$inputs.accessTokenDurationMinutes}
|
||||||
|
/>
|
||||||
|
<DurationInput
|
||||||
|
id="refresh-token-lifetime"
|
||||||
|
label={m.refresh_token_inactivity_timeout()}
|
||||||
|
description={m.refresh_token_inactivity_timeout_description()}
|
||||||
|
bind:input={$inputs.refreshTokenDurationMinutes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer class="justify-end">
|
||||||
|
<Button type="submit" disabled={isLoading}>{m.save()}</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
</form>
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
federatedIdentities: OidcClientFederatedIdentity[];
|
federatedIdentities: OidcClientFederatedIdentity[];
|
||||||
errors?: z.core.$ZodIssue[];
|
errors?: z.core.$ZodIssue[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -60,15 +59,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps}>
|
<div {...restProps}>
|
||||||
<FormInput
|
<FormInput {disabled}>
|
||||||
label={m.federated_client_credentials()}
|
<div class="flex flex-col gap-4">
|
||||||
description={m.federated_client_credentials_description()}
|
|
||||||
docsLink="https://pocket-id.org/docs/guides/oidc-client-authentication"
|
|
||||||
{disabled}
|
|
||||||
>
|
|
||||||
<div class="space-y-4">
|
|
||||||
{#each federatedIdentities as identity, i (identity)}
|
{#each federatedIdentities as identity, i (identity)}
|
||||||
<div class="space-y-3 rounded-lg border p-4">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<Field.Label>Identity {i + 1}</Field.Label>
|
<Field.Label>Identity {i + 1}</Field.Label>
|
||||||
{#if federatedIdentities.length > 0}
|
{#if federatedIdentities.length > 0}
|
||||||
@@ -79,7 +73,7 @@
|
|||||||
aria-label="Remove federated identity"
|
aria-label="Remove federated identity"
|
||||||
{disabled}
|
{disabled}
|
||||||
>
|
>
|
||||||
<LucideMinus class="size-4" />
|
<LucideMinus data-icon="inline-start" />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -159,14 +153,14 @@
|
|||||||
</FormInput>
|
</FormInput>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
class="mt-3"
|
class="mt-7"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={addFederatedIdentity}
|
onclick={addFederatedIdentity}
|
||||||
type="button"
|
type="button"
|
||||||
{disabled}
|
{disabled}
|
||||||
>
|
>
|
||||||
<LucidePlus class="mr-1 size-4" />
|
<LucidePlus data-icon="inline-start" />
|
||||||
{federatedIdentities.length === 0
|
{federatedIdentities.length === 0
|
||||||
? m.add_federated_client_credential()
|
? m.add_federated_client_credential()
|
||||||
: m.add_another_federated_client_credential()}
|
: m.add_another_federated_client_credential()}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
import { LucideChevronDown, LucideMoon, LucideSun } from '@lucide/svelte';
|
import { LucideChevronDown, LucideMoon, LucideSun } from '@lucide/svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import FederatedIdentitiesInput from './federated-identities-input.svelte';
|
|
||||||
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
||||||
import OidcClientImageInput from './oidc-client-image-input.svelte';
|
import OidcClientImageInput from './oidc-client-image-input.svelte';
|
||||||
|
|
||||||
@@ -56,12 +55,11 @@
|
|||||||
existingClient?.requiresPushedAuthorizationRequests || false,
|
existingClient?.requiresPushedAuthorizationRequests || false,
|
||||||
skipConsent: existingClient?.skipConsent || false,
|
skipConsent: existingClient?.skipConsent || false,
|
||||||
launchURL: existingClient?.launchURL || '',
|
launchURL: existingClient?.launchURL || '',
|
||||||
credentials: {
|
|
||||||
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
|
||||||
},
|
|
||||||
logoUrl: '',
|
logoUrl: '',
|
||||||
darkLogoUrl: '',
|
darkLogoUrl: '',
|
||||||
pkceSupported: existingClient?.pkceSupported || false
|
pkceSupported: existingClient?.pkceSupported || false,
|
||||||
|
accessTokenDurationMinutes: existingClient?.accessTokenDurationMinutes ?? 60,
|
||||||
|
refreshTokenDurationMinutes: existingClient?.refreshTokenDurationMinutes ?? 30 * 24 * 60
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -87,21 +85,20 @@
|
|||||||
launchURL: optionalUrl,
|
launchURL: optionalUrl,
|
||||||
logoUrl: optionalUrl,
|
logoUrl: optionalUrl,
|
||||||
darkLogoUrl: optionalUrl,
|
darkLogoUrl: optionalUrl,
|
||||||
credentials: z.object({
|
accessTokenDurationMinutes: z
|
||||||
federatedIdentities: z.array(
|
.number()
|
||||||
z.object({
|
.min(1)
|
||||||
issuer: z.url(),
|
.max(365 * 24 * 60)
|
||||||
subject: z.string().optional(),
|
.int(),
|
||||||
audience: z.string().optional(),
|
refreshTokenDurationMinutes: z
|
||||||
jwks: z.url().optional().or(z.literal('')),
|
.number()
|
||||||
replayProtection: z.boolean().default(true)
|
.min(1)
|
||||||
})
|
.max(365 * 24 * 60)
|
||||||
)
|
.int()
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormSchema = typeof formSchema;
|
type FormSchema = typeof formSchema;
|
||||||
const { inputs, errors, ...form } = createForm<FormSchema>(formSchema, client);
|
const { inputs, ...form } = createForm<FormSchema>(formSchema, client);
|
||||||
|
|
||||||
const pkcePromptNeeded = $derived(!$inputs.pkceEnabled.value && client.pkceSupported);
|
const pkcePromptNeeded = $derived(!$inputs.pkceEnabled.value && client.pkceSupported);
|
||||||
|
|
||||||
@@ -112,6 +109,7 @@
|
|||||||
|
|
||||||
const success = await callback({
|
const success = await callback({
|
||||||
...data,
|
...data,
|
||||||
|
credentials: existingClient?.credentials ?? { federatedIdentities: [] },
|
||||||
logo: $inputs.logoUrl?.value ? undefined : logo,
|
logo: $inputs.logoUrl?.value ? undefined : logo,
|
||||||
logoUrl: $inputs.logoUrl?.value,
|
logoUrl: $inputs.logoUrl?.value,
|
||||||
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,
|
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,
|
||||||
@@ -177,15 +175,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFederatedIdentityErrors(errors: z.ZodError<any> | undefined) {
|
|
||||||
return errors?.issues
|
|
||||||
.filter((e) => e.path[0] == 'credentials' && e.path[1] == 'federatedIdentities')
|
|
||||||
.map((e) => {
|
|
||||||
e.path.splice(0, 2);
|
|
||||||
return e;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet callbackUrlDescription()}
|
{#snippet callbackUrlDescription()}
|
||||||
@@ -336,11 +325,6 @@
|
|||||||
bind:input={$inputs.id}
|
bind:input={$inputs.id}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<FederatedIdentitiesInput
|
|
||||||
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
|
|
||||||
errors={getFederatedIdentityErrors($errors)}
|
|
||||||
disabled={isCIMDClient}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"provider": "sqlite",
|
"provider": "sqlite",
|
||||||
"version": 20260731120000,
|
"version": 20260802120000,
|
||||||
"tableOrder": [
|
"tableOrder": [
|
||||||
"users",
|
"users",
|
||||||
"user_groups",
|
"user_groups",
|
||||||
@@ -87,6 +87,7 @@
|
|||||||
],
|
],
|
||||||
"oidc_clients": [
|
"oidc_clients": [
|
||||||
{
|
{
|
||||||
|
"access_token_duration_minutes": 60,
|
||||||
"callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
"callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||||
"client_type": "standard",
|
"client_type": "standard",
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
@@ -107,10 +108,12 @@
|
|||||||
"pkce_supported": false,
|
"pkce_supported": false,
|
||||||
"requires_pushed_authorization_requests": false,
|
"requires_pushed_authorization_requests": false,
|
||||||
"requires_reauthentication": false,
|
"requires_reauthentication": false,
|
||||||
|
"refresh_token_duration_minutes": 43200,
|
||||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
|
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
|
||||||
"skip_consent": false
|
"skip_consent": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"access_token_duration_minutes": 60,
|
||||||
"callback_urls": "WyJodHRwOi8vaW1taWNoLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
"callback_urls": "WyJodHRwOi8vaW1taWNoLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||||
"client_type": "standard",
|
"client_type": "standard",
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
@@ -131,10 +134,12 @@
|
|||||||
"pkce_supported": false,
|
"pkce_supported": false,
|
||||||
"requires_pushed_authorization_requests": false,
|
"requires_pushed_authorization_requests": false,
|
||||||
"requires_reauthentication": false,
|
"requires_reauthentication": false,
|
||||||
|
"refresh_token_duration_minutes": 43200,
|
||||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe",
|
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe",
|
||||||
"skip_consent": false
|
"skip_consent": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"access_token_duration_minutes": 60,
|
||||||
"callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
"callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||||
"client_type": "standard",
|
"client_type": "standard",
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
@@ -155,10 +160,12 @@
|
|||||||
"pkce_supported": false,
|
"pkce_supported": false,
|
||||||
"requires_pushed_authorization_requests": false,
|
"requires_pushed_authorization_requests": false,
|
||||||
"requires_reauthentication": false,
|
"requires_reauthentication": false,
|
||||||
|
"refresh_token_duration_minutes": 43200,
|
||||||
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a",
|
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a",
|
||||||
"skip_consent": false
|
"skip_consent": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"access_token_duration_minutes": 60,
|
||||||
"callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
"callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||||
"client_type": "standard",
|
"client_type": "standard",
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
@@ -179,10 +186,12 @@
|
|||||||
"pkce_supported": false,
|
"pkce_supported": false,
|
||||||
"requires_pushed_authorization_requests": false,
|
"requires_pushed_authorization_requests": false,
|
||||||
"requires_reauthentication": false,
|
"requires_reauthentication": false,
|
||||||
|
"refresh_token_duration_minutes": 43200,
|
||||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe",
|
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe",
|
||||||
"skip_consent": false
|
"skip_consent": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"access_token_duration_minutes": 60,
|
||||||
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
|
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
|
||||||
"client_type": "standard",
|
"client_type": "standard",
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
@@ -202,10 +211,12 @@
|
|||||||
"pkce_supported": false,
|
"pkce_supported": false,
|
||||||
"requires_pushed_authorization_requests": false,
|
"requires_pushed_authorization_requests": false,
|
||||||
"requires_reauthentication": false,
|
"requires_reauthentication": false,
|
||||||
|
"refresh_token_duration_minutes": 43200,
|
||||||
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK",
|
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK",
|
||||||
"skip_consent": false
|
"skip_consent": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"access_token_duration_minutes": 60,
|
||||||
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
|
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
|
||||||
"client_type": "standard",
|
"client_type": "standard",
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
@@ -226,10 +237,12 @@
|
|||||||
"pkce_supported": false,
|
"pkce_supported": false,
|
||||||
"requires_pushed_authorization_requests": false,
|
"requires_pushed_authorization_requests": false,
|
||||||
"requires_reauthentication": false,
|
"requires_reauthentication": false,
|
||||||
|
"refresh_token_duration_minutes": 43200,
|
||||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
|
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
|
||||||
"skip_consent": false
|
"skip_consent": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"access_token_duration_minutes": 60,
|
||||||
"callback_urls": "WyJodHRwOi8vc2tpcC1jb25zZW50LmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
"callback_urls": "WyJodHRwOi8vc2tpcC1jb25zZW50LmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||||
"client_type": "standard",
|
"client_type": "standard",
|
||||||
"created_at": "2025-11-25T12:39:02Z",
|
"created_at": "2025-11-25T12:39:02Z",
|
||||||
@@ -250,6 +263,7 @@
|
|||||||
"pkce_supported": false,
|
"pkce_supported": false,
|
||||||
"requires_pushed_authorization_requests": false,
|
"requires_pushed_authorization_requests": false,
|
||||||
"requires_reauthentication": false,
|
"requires_reauthentication": false,
|
||||||
|
"refresh_token_duration_minutes": 43200,
|
||||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
|
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
|
||||||
"skip_consent": true
|
"skip_consent": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import test, { expect, Page } from '@playwright/test';
|
|||||||
import { oidcClients, userGroups } from '../data';
|
import { oidcClients, userGroups } from '../data';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
|
||||||
|
const defaultTokenLifetimes = {
|
||||||
|
accessTokenDurationMinutes: 60,
|
||||||
|
refreshTokenDurationMinutes: 30 * 24 * 60
|
||||||
|
};
|
||||||
|
|
||||||
test.beforeEach(async () => await cleanupBackend());
|
test.beforeEach(async () => await cleanupBackend());
|
||||||
|
|
||||||
test.describe('Create OIDC client', () => {
|
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.locator('[role="tab"][data-value="dark-logo"]').first().click();
|
||||||
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png');
|
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png');
|
||||||
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
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(
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||||
'OIDC client updated successfully'
|
'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();
|
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 }) => {
|
test('Create new OIDC client secret', async ({ page }) => {
|
||||||
const oidcClient = oidcClients.nextcloud;
|
const oidcClient = oidcClients.nextcloud;
|
||||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
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
|
// Enable PAR on the PAR test client
|
||||||
await request.put(`/api/oidc/clients/${parClient.id}`, {
|
await request.put(`/api/oidc/clients/${parClient.id}`, {
|
||||||
data: {
|
data: {
|
||||||
|
...defaultTokenLifetimes,
|
||||||
name: parClient.name,
|
name: parClient.name,
|
||||||
callbackURLs: [parClient.callbackUrl],
|
callbackURLs: [parClient.callbackUrl],
|
||||||
logoutCallbackURLs: [],
|
logoutCallbackURLs: [],
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { generateIdToken } from '../utils/jwt.util';
|
|||||||
import * as oidcUtil from '../utils/oidc.util';
|
import * as oidcUtil from '../utils/oidc.util';
|
||||||
import passkeyUtil from '../utils/passkey.util';
|
import passkeyUtil from '../utils/passkey.util';
|
||||||
|
|
||||||
|
const defaultTokenLifetimes = {
|
||||||
|
accessTokenDurationMinutes: 60,
|
||||||
|
refreshTokenDurationMinutes: 30 * 24 * 60
|
||||||
|
};
|
||||||
|
|
||||||
test.beforeEach(async () => await cleanupBackend());
|
test.beforeEach(async () => await cleanupBackend());
|
||||||
|
|
||||||
async function generateSeededOauthAccessToken(
|
async function generateSeededOauthAccessToken(
|
||||||
@@ -760,6 +765,7 @@ test('Device authorization flow forces reauthentication when client requires it'
|
|||||||
const client = oidcClients.nextcloud;
|
const client = oidcClients.nextcloud;
|
||||||
await request.put(`/api/oidc/clients/${client.id}`, {
|
await request.put(`/api/oidc/clients/${client.id}`, {
|
||||||
data: {
|
data: {
|
||||||
|
...defaultTokenLifetimes,
|
||||||
name: client.name,
|
name: client.name,
|
||||||
callbackURLs: [client.callbackUrl],
|
callbackURLs: [client.callbackUrl],
|
||||||
logoutCallbackURLs: [client.logoutCallbackUrl],
|
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}`, {
|
await request.put(`/api/oidc/clients/${oidcClients.nextcloud.id}`, {
|
||||||
data: {
|
data: {
|
||||||
|
...defaultTokenLifetimes,
|
||||||
name: oidcClients.nextcloud.name,
|
name: oidcClients.nextcloud.name,
|
||||||
callbackURLs: [oidcClients.nextcloud.callbackUrl],
|
callbackURLs: [oidcClients.nextcloud.callbackUrl],
|
||||||
logoutCallbackURLs: [oidcClients.nextcloud.logoutCallbackUrl],
|
logoutCallbackURLs: [oidcClients.nextcloud.logoutCallbackUrl],
|
||||||
@@ -1438,6 +1445,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
|||||||
await page.request.put(`/api/oidc/clients/${client.id}`, {
|
await page.request.put(`/api/oidc/clients/${client.id}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
data: {
|
data: {
|
||||||
|
...defaultTokenLifetimes,
|
||||||
name: client.name,
|
name: client.name,
|
||||||
callbackURLs: [client.callbackUrl],
|
callbackURLs: [client.callbackUrl],
|
||||||
logoutCallbackURLs: [],
|
logoutCallbackURLs: [],
|
||||||
@@ -1481,6 +1489,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
|||||||
await request.put(`/api/oidc/clients/${client.id}`, {
|
await request.put(`/api/oidc/clients/${client.id}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
data: {
|
data: {
|
||||||
|
...defaultTokenLifetimes,
|
||||||
name: client.name,
|
name: client.name,
|
||||||
callbackURLs: [client.callbackUrl],
|
callbackURLs: [client.callbackUrl],
|
||||||
logoutCallbackURLs: [],
|
logoutCallbackURLs: [],
|
||||||
@@ -1514,7 +1523,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
|
|||||||
await parToggle.click();
|
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 expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
@@ -1564,7 +1573,8 @@ test.describe('OIDC skip consent', () => {
|
|||||||
// Disabling it and saving must persist across a reload
|
// Disabling it and saving must persist across a reload
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(toggle).not.toBeChecked();
|
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 expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user