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