mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-03 08:19:04 +02:00
feat: add ability to customize session duration of clients (#1641)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user