feat: add ability to customize session duration of clients (#1641)

This commit is contained in:
Elias Schneider
2026-08-03 23:23:29 +02:00
committed by GitHub
parent 1238bf0f37
commit 5d43c4aaeb
28 changed files with 752 additions and 77 deletions
+47 -4
View File
@@ -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"