mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
feat: add ability to customize session duration of clients (#1641)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user