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
+15 -1
View File
@@ -1,6 +1,6 @@
{
"provider": "sqlite",
"version": 20260731120000,
"version": 20260802120000,
"tableOrder": [
"users",
"user_groups",
@@ -87,6 +87,7 @@
],
"oidc_clients": [
{
"access_token_duration_minutes": 60,
"callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
"client_type": "standard",
"created_at": "2025-11-25T12:39:02Z",
@@ -107,10 +108,12 @@
"pkce_supported": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"refresh_token_duration_minutes": 43200,
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
"skip_consent": false
},
{
"access_token_duration_minutes": 60,
"callback_urls": "WyJodHRwOi8vaW1taWNoLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
"client_type": "standard",
"created_at": "2025-11-25T12:39:02Z",
@@ -131,10 +134,12 @@
"pkce_supported": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"refresh_token_duration_minutes": 43200,
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe",
"skip_consent": false
},
{
"access_token_duration_minutes": 60,
"callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
"client_type": "standard",
"created_at": "2025-11-25T12:39:02Z",
@@ -155,10 +160,12 @@
"pkce_supported": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"refresh_token_duration_minutes": 43200,
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a",
"skip_consent": false
},
{
"access_token_duration_minutes": 60,
"callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
"client_type": "standard",
"created_at": "2025-11-25T12:39:02Z",
@@ -179,10 +186,12 @@
"pkce_supported": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"refresh_token_duration_minutes": 43200,
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe",
"skip_consent": false
},
{
"access_token_duration_minutes": 60,
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
"client_type": "standard",
"created_at": "2025-11-25T12:39:02Z",
@@ -202,10 +211,12 @@
"pkce_supported": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"refresh_token_duration_minutes": 43200,
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK",
"skip_consent": false
},
{
"access_token_duration_minutes": 60,
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
"client_type": "standard",
"created_at": "2025-11-25T12:39:02Z",
@@ -226,10 +237,12 @@
"pkce_supported": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"refresh_token_duration_minutes": 43200,
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
"skip_consent": false
},
{
"access_token_duration_minutes": 60,
"callback_urls": "WyJodHRwOi8vc2tpcC1jb25zZW50LmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
"client_type": "standard",
"created_at": "2025-11-25T12:39:02Z",
@@ -250,6 +263,7 @@
"pkce_supported": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"refresh_token_duration_minutes": 43200,
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
"skip_consent": true
}
+100 -1
View File
@@ -2,6 +2,11 @@ import test, { expect, Page } from '@playwright/test';
import { oidcClients, userGroups } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
const defaultTokenLifetimes = {
accessTokenDurationMinutes: 60,
refreshTokenDurationMinutes: 30 * 24 * 60
};
test.beforeEach(async () => await cleanupBackend());
test.describe('Create OIDC client', () => {
@@ -78,7 +83,8 @@ test('Edit OIDC client', async ({ page }) => {
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png');
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
await page.getByRole('button', { name: 'Save' }).click();
const clientForm = page.getByLabel('Name').locator('xpath=ancestor::form');
await clientForm.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'OIDC client updated successfully'
@@ -110,6 +116,98 @@ test('Displays OIDC client endpoints from discovery configuration', async ({ pag
await expect(page.getByText(oidcConfiguration.jwks_uri, { exact: true })).toBeVisible();
});
test('Update OIDC client token lifetimes', async ({ page }) => {
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
const card = page.getByTestId('token-lifetimes-card');
const accessLifetime = card.getByLabel('Access token lifetime', { exact: true });
const accessUnit = card.getByLabel('Access token lifetime unit');
const refreshLifetime = card.getByLabel('Refresh token inactivity timeout', { exact: true });
const refreshUnit = card.getByLabel('Refresh token inactivity timeout unit');
await expect(accessLifetime).toHaveValue('1');
await expect(accessUnit).toHaveText('Hours');
await expect(refreshLifetime).toHaveValue('30');
await expect(refreshUnit).toHaveText('Days');
await accessUnit.click();
await page.getByRole('option', { name: 'Minutes' }).click();
await expect(accessLifetime).toHaveValue('60');
await accessLifetime.fill('90');
await refreshUnit.click();
await page.getByRole('option', { name: 'Hours' }).click();
await expect(refreshLifetime).toHaveValue('720');
await refreshLifetime.fill('336');
await card.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await page.reload();
await expect(card.getByLabel('Access token lifetime', { exact: true })).toHaveValue('90');
await expect(card.getByLabel('Access token lifetime unit')).toHaveText('Minutes');
await expect(card.getByLabel('Refresh token inactivity timeout', { exact: true })).toHaveValue(
'14'
);
await expect(card.getByLabel('Refresh token inactivity timeout unit')).toHaveText('Days');
await card.getByLabel('Access token lifetime', { exact: true }).fill('0');
await card.getByRole('button', { name: 'Save' }).click();
await expect(card.getByText('Token lifetime must be at least 1 minute.')).toBeVisible();
await card.getByLabel('Access token lifetime', { exact: true }).fill('525601');
await card.getByRole('button', { name: 'Save' }).click();
await expect(card.getByText('Token lifetime cannot exceed 365 days.')).toBeVisible();
await card.getByLabel('Access token lifetime', { exact: true }).fill('1.5');
await card.getByRole('button', { name: 'Save' }).click();
await expect(card.getByText('Token lifetime must use whole-minute increments.')).toBeVisible();
await card.getByLabel('Access token lifetime', { exact: true }).fill('60');
await card.getByLabel('Refresh token inactivity timeout', { exact: true }).fill('30');
await card.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
});
test('Update OIDC client federated credentials', async ({ page }) => {
const client = oidcClients.nextcloud;
await page.goto(`/settings/admin/oidc-clients/${client.id}`);
const card = page.getByTestId('federated-credentials-card');
await card.getByRole('button', { name: 'Create', exact: true }).click();
await card.getByLabel('Issuer').fill('https://issuer.example.com');
await card.getByLabel('Subject').fill('workload-client');
await card.getByLabel('Audience').fill('https://pocket-id.example.com');
const cardUpdate = page.waitForResponse(
(response) =>
response.request().method() === 'PUT' &&
response.url().endsWith(`/api/oidc/clients/${client.id}`)
);
await card.getByRole('button', { name: 'Save' }).click();
expect((await cardUpdate).ok()).toBeTruthy();
await page.reload();
await expect(card.getByLabel('Issuer')).toHaveValue('https://issuer.example.com');
await expect(card.getByLabel('Subject')).toHaveValue('workload-client');
await expect(card.getByLabel('Audience')).toHaveValue('https://pocket-id.example.com');
// Saving the main client form must preserve credentials managed by the separate card
const description = page.getByLabel('Description');
await description.fill('Updated without replacing federated credentials');
const clientForm = description.locator('xpath=ancestor::form');
const formUpdate = page.waitForResponse(
(response) =>
response.request().method() === 'PUT' &&
response.url().endsWith(`/api/oidc/clients/${client.id}`)
);
await clientForm.getByRole('button', { name: 'Save' }).click();
expect((await formUpdate).ok()).toBeTruthy();
await page.reload();
await expect(card.getByLabel('Issuer')).toHaveValue('https://issuer.example.com');
});
test('Create new OIDC client secret', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
@@ -148,6 +246,7 @@ test('Filter OIDC clients by PAR requirement', async ({ page, request }) => {
// Enable PAR on the PAR test client
await request.put(`/api/oidc/clients/${parClient.id}`, {
data: {
...defaultTokenLifetimes,
name: parClient.name,
callbackURLs: [parClient.callbackUrl],
logoutCallbackURLs: [],
+12 -2
View File
@@ -5,6 +5,11 @@ import { generateIdToken } from '../utils/jwt.util';
import * as oidcUtil from '../utils/oidc.util';
import passkeyUtil from '../utils/passkey.util';
const defaultTokenLifetimes = {
accessTokenDurationMinutes: 60,
refreshTokenDurationMinutes: 30 * 24 * 60
};
test.beforeEach(async () => await cleanupBackend());
async function generateSeededOauthAccessToken(
@@ -760,6 +765,7 @@ test('Device authorization flow forces reauthentication when client requires it'
const client = oidcClients.nextcloud;
await request.put(`/api/oidc/clients/${client.id}`, {
data: {
...defaultTokenLifetimes,
name: client.name,
callbackURLs: [client.callbackUrl],
logoutCallbackURLs: [client.logoutCallbackUrl],
@@ -885,6 +891,7 @@ test('Forces reauthentication when client requires it', async ({ page, request }
await request.put(`/api/oidc/clients/${oidcClients.nextcloud.id}`, {
data: {
...defaultTokenLifetimes,
name: oidcClients.nextcloud.name,
callbackURLs: [oidcClients.nextcloud.callbackUrl],
logoutCallbackURLs: [oidcClients.nextcloud.logoutCallbackUrl],
@@ -1438,6 +1445,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
await page.request.put(`/api/oidc/clients/${client.id}`, {
headers: { 'Content-Type': 'application/json' },
data: {
...defaultTokenLifetimes,
name: client.name,
callbackURLs: [client.callbackUrl],
logoutCallbackURLs: [],
@@ -1481,6 +1489,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
await request.put(`/api/oidc/clients/${client.id}`, {
headers: { 'Content-Type': 'application/json' },
data: {
...defaultTokenLifetimes,
name: client.name,
callbackURLs: [client.callbackUrl],
logoutCallbackURLs: [],
@@ -1514,7 +1523,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
await parToggle.click();
}
await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('button', { name: 'Save', exact: true }).first().click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await page.reload();
@@ -1564,7 +1573,8 @@ test.describe('OIDC skip consent', () => {
// Disabling it and saving must persist across a reload
await toggle.click();
await expect(toggle).not.toBeChecked();
await page.getByRole('button', { name: /save/i }).click();
const clientForm = toggle.locator('xpath=ancestor::form');
await clientForm.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await page.reload();