mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-07 02:09:04 +02:00
feat(oauth): add support for Pushed Authorization Requests (RFC9126) (#1404)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
co-authored by
Elias Schneider
parent
2eada149af
commit
68a5abdcca
@@ -67,6 +67,12 @@ export const oidcClients = {
|
||||
callbackUrl: 'http://pingvin.share/auth/callback',
|
||||
secondCallbackUrl: 'http://pingvin.share/auth/callback2',
|
||||
launchURL: 'https://pingvin-share.local'
|
||||
},
|
||||
parClient: {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef0000000001',
|
||||
name: 'PAR Test Client',
|
||||
callbackUrl: 'http://par-client/auth/callback',
|
||||
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260518222000,
|
||||
"version": 20260601154900,
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
|
||||
"tables": {
|
||||
"api_keys": [
|
||||
@@ -82,6 +82,7 @@
|
||||
"logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
|
||||
"name": "Nextcloud",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
|
||||
},
|
||||
@@ -99,6 +100,7 @@
|
||||
"logout_callback_urls": "bnVsbA==",
|
||||
"name": "Immich",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
|
||||
},
|
||||
@@ -116,6 +118,7 @@
|
||||
"logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
|
||||
"name": "Tailscale",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a"
|
||||
},
|
||||
@@ -133,6 +136,7 @@
|
||||
"logout_callback_urls": "bnVsbA==",
|
||||
"name": "Federated",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
|
||||
},
|
||||
@@ -149,8 +153,27 @@
|
||||
"logout_callback_urls": "bnVsbA==",
|
||||
"name": "SCIM Client",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK"
|
||||
},
|
||||
{
|
||||
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"credentials": "e30=",
|
||||
"dark_image_type": null,
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
|
||||
"image_type": null,
|
||||
"is_group_restricted": false,
|
||||
"is_public": false,
|
||||
"launch_url": null,
|
||||
"logout_callback_urls": "bnVsbA==",
|
||||
"name": "PAR Test Client",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
|
||||
}
|
||||
],
|
||||
"oidc_clients_allowed_user_groups": [
|
||||
@@ -259,6 +282,12 @@
|
||||
"scope": "openid profile email",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"client_id": "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
|
||||
"last_used_at": "2024-01-01T00:00:00Z",
|
||||
"scope": "openid profile email",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||
"last_used_at": "2025-08-12T12:00:00Z",
|
||||
|
||||
@@ -11,7 +11,7 @@ test('Dashboard shows all clients in the correct order', async ({ page }) => {
|
||||
|
||||
await page.goto('/settings/apps');
|
||||
|
||||
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(5);
|
||||
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(6);
|
||||
|
||||
// Should be first
|
||||
const card1 = page.getByTestId('authorized-oidc-client-card').first();
|
||||
@@ -32,7 +32,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
|
||||
|
||||
const cards = page.getByTestId('authorized-oidc-client-card');
|
||||
|
||||
await expect(cards).toHaveCount(4);
|
||||
await expect(cards).toHaveCount(5);
|
||||
|
||||
const cardTexts = await cards.allTextContents();
|
||||
expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false);
|
||||
@@ -40,7 +40,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
|
||||
test('User can see all clients', async ({ page }) => {
|
||||
await page.goto('/settings/apps');
|
||||
const cards = page.getByTestId('authorized-oidc-client-card');
|
||||
await expect(cards).toHaveCount(5);
|
||||
await expect(cards).toHaveCount(6);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -118,6 +118,43 @@ test('Delete OIDC client', async ({ page }) => {
|
||||
await expect(page.getByRole('row', { name: oidcClient.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Filter OIDC clients by PAR requirement', async ({ page, request }) => {
|
||||
const parClient = oidcClients.parClient;
|
||||
|
||||
// Enable PAR on the PAR test client
|
||||
await request.put(`/api/oidc/clients/${parClient.id}`, {
|
||||
data: {
|
||||
name: parClient.name,
|
||||
callbackURLs: [parClient.callbackUrl],
|
||||
logoutCallbackURLs: [],
|
||||
isPublic: false,
|
||||
pkceEnabled: false,
|
||||
requiresReauthentication: false,
|
||||
requiresPushedAuthorizationRequests: true,
|
||||
credentials: { federatedIdentities: [] },
|
||||
isGroupRestricted: false
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/settings/admin/oidc-clients');
|
||||
|
||||
// Open PAR filter and select "Yes"
|
||||
await page.getByTestId('facet-par-trigger').click();
|
||||
await page.getByTestId('facet-par-option-true').click();
|
||||
|
||||
// Only the PAR client should be visible
|
||||
await expect(page.getByRole('row', { name: parClient.name })).toBeVisible();
|
||||
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).not.toBeVisible();
|
||||
|
||||
// Deselect "Yes" and select "No" to invert the filter
|
||||
await page.getByTestId('facet-par-option-true').click();
|
||||
await page.getByTestId('facet-par-option-false').click();
|
||||
|
||||
// PAR client should be hidden, others visible
|
||||
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).toBeVisible();
|
||||
await expect(page.getByRole('row', { name: parClient.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Update OIDC client allowed user groups', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
||||
|
||||
|
||||
@@ -886,3 +886,217 @@ async function routeCallbackPage(page: Page, callbackUrl: string): Promise<(url:
|
||||
|
||||
return callbackRouteMatcher;
|
||||
}
|
||||
|
||||
// ─── PAR (Pushed Authorization Requests - RFC 9126) ──────────────────────────
|
||||
|
||||
test.describe('Pushed Authorization Requests (PAR)', () => {
|
||||
const client = oidcClients.parClient;
|
||||
|
||||
test('PAR endpoint returns request_uri for valid confidential client', async ({ page }) => {
|
||||
const result = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl
|
||||
});
|
||||
|
||||
expect(result.request_uri).toMatch(/^urn:ietf:params:oauth:request_uri:/);
|
||||
expect(result.expires_in).toBe(90);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PAR full flow: push then authorize then exchange tokens', async ({ page }) => {
|
||||
// Step 1: Push authorization parameters
|
||||
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl,
|
||||
nonce: 'par-nonce-123'
|
||||
});
|
||||
expect(parResult.request_uri).toBeDefined();
|
||||
expect(parResult.error).toBeUndefined();
|
||||
|
||||
// Step 2: Navigate to /authorize using the request_uri
|
||||
const urlParams = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
request_uri: parResult.request_uri!
|
||||
});
|
||||
|
||||
const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
|
||||
page.goto(`/authorize?${urlParams.toString()}`)
|
||||
);
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
expect(code).toBeTruthy();
|
||||
|
||||
// Step 3: Exchange the authorization code for tokens
|
||||
const tokenResult = await oidcUtil.exchangeCode(page, {
|
||||
grant_type: 'authorization_code',
|
||||
code: code!,
|
||||
client_id: client.id,
|
||||
client_secret: client.secret,
|
||||
redirect_uri: client.callbackUrl
|
||||
});
|
||||
expect(tokenResult.access_token).toBeTruthy();
|
||||
expect(tokenResult.token_type).toBe('Bearer');
|
||||
expect(tokenResult.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PAR full flow shows consent screen when authorization is required', async ({ page }) => {
|
||||
// The parClient is pre-authorized for "openid profile email"; pushing a different
|
||||
// scope means consent is required and the consent screen must be shown rather than
|
||||
// silently authorizing.
|
||||
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl,
|
||||
scope: 'openid profile'
|
||||
});
|
||||
expect(parResult.request_uri).toBeDefined();
|
||||
|
||||
const urlParams = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
request_uri: parResult.request_uri!
|
||||
});
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
// Consent screen with the requested scope (resolved from the PAR) must be shown
|
||||
await expect(
|
||||
page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })
|
||||
).toBeVisible();
|
||||
|
||||
// Confirming proceeds with the authorization
|
||||
await expectCallbackRedirect(page, client.callbackUrl, () =>
|
||||
page.getByRole('button', { name: 'Sign in' }).click()
|
||||
);
|
||||
});
|
||||
|
||||
test('PAR request_uri is single-use', async ({ page }) => {
|
||||
// Push two requests — use the first via the browser, then try to reuse it
|
||||
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl
|
||||
});
|
||||
expect(parResult.request_uri).toBeDefined();
|
||||
|
||||
// First use — navigate to /authorize (must succeed and consume the request_uri)
|
||||
const urlParams = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
request_uri: parResult.request_uri!
|
||||
});
|
||||
const firstCallbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
|
||||
page.goto(`/authorize?${urlParams.toString()}`)
|
||||
);
|
||||
expect(firstCallbackUrl.searchParams.get('code')).toBeTruthy();
|
||||
|
||||
// Second use of the same request_uri should fail
|
||||
// Use the authorize API directly (requires auth cookie which we have)
|
||||
const response = await page.request.post('/api/oidc/authorize', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
clientID: client.id,
|
||||
requestURI: parResult.request_uri
|
||||
}
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('PAR endpoint rejects request without client credentials', async ({ page }) => {
|
||||
const result = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
// no clientSecret
|
||||
redirectUri: client.callbackUrl
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.request_uri).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PAR endpoint rejects public client', async ({ page }) => {
|
||||
// The parClient is confidential — test by setting isPublic via admin API first
|
||||
await page.request.put(`/api/oidc/clients/${client.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
name: client.name,
|
||||
callbackURLs: [client.callbackUrl],
|
||||
logoutCallbackURLs: [],
|
||||
isPublic: true,
|
||||
pkceEnabled: true,
|
||||
requiresReauthentication: false,
|
||||
requiresPushedAuthorizationRequests: false,
|
||||
credentials: { federatedIdentities: [] },
|
||||
isGroupRestricted: false
|
||||
}
|
||||
});
|
||||
|
||||
const result = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl
|
||||
});
|
||||
|
||||
expect(result.error).toBe('Pushed authorization requests are not supported for public clients');
|
||||
expect(result.request_uri).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PAR endpoint rejects invalid redirect_uri at push time', async ({ page }) => {
|
||||
const result = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: 'http://evil.example.com/steal'
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.request_uri).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Client with requiresPushedAuthorizationRequests rejects direct /authorize', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
// Enable the PAR requirement on the client
|
||||
await request.put(`/api/oidc/clients/${client.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
name: client.name,
|
||||
callbackURLs: [client.callbackUrl],
|
||||
logoutCallbackURLs: [],
|
||||
isPublic: false,
|
||||
pkceEnabled: false,
|
||||
requiresReauthentication: false,
|
||||
requiresPushedAuthorizationRequests: true,
|
||||
credentials: { federatedIdentities: [] },
|
||||
isGroupRestricted: false
|
||||
}
|
||||
});
|
||||
|
||||
// Attempt a normal authorization (without request_uri)
|
||||
const response = await page.request.post('/api/oidc/authorize', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
clientID: client.id,
|
||||
scope: 'openid profile',
|
||||
callbackURL: client.callbackUrl
|
||||
}
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('Admin UI: PAR toggle persists after save', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/oidc-clients/${client.id}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
|
||||
|
||||
// Enable the PAR toggle
|
||||
const parToggle = page.getByRole('switch', { name: 'Requires Pushed Authorization' });
|
||||
if (!(await parToggle.isChecked())) {
|
||||
await parToggle.click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
|
||||
const savedToggle = page.getByRole('switch', { name: 'Requires Pushed Authorization' });
|
||||
await expect(savedToggle).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,40 @@ export async function exchangeCode(
|
||||
.then((r) => r.json());
|
||||
}
|
||||
|
||||
export async function pushAuthorizationRequest(
|
||||
page: Page,
|
||||
params: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
scope?: string;
|
||||
redirectUri?: string;
|
||||
responseType?: string;
|
||||
codeChallenge?: string;
|
||||
codeChallengeMethod?: string;
|
||||
nonce?: string;
|
||||
state?: string;
|
||||
}
|
||||
): Promise<{ request_uri?: string; expires_in?: number; error?: string; error_description?: string }> {
|
||||
const form: Record<string, string> = {
|
||||
client_id: params.clientId,
|
||||
response_type: params.responseType ?? 'code',
|
||||
scope: params.scope ?? 'openid profile email'
|
||||
};
|
||||
if (params.redirectUri) form.redirect_uri = params.redirectUri;
|
||||
if (params.clientSecret) form.client_secret = params.clientSecret;
|
||||
if (params.codeChallenge) form.code_challenge = params.codeChallenge;
|
||||
if (params.codeChallengeMethod) form.code_challenge_method = params.codeChallengeMethod;
|
||||
if (params.nonce) form.nonce = params.nonce;
|
||||
if (params.state) form.state = params.state;
|
||||
|
||||
return page.request
|
||||
.post('/api/oidc/par', {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
form
|
||||
})
|
||||
.then((r) => r.json());
|
||||
}
|
||||
|
||||
export async function getClientAssertion(
|
||||
page: Page,
|
||||
data: { issuer: string; audience: string; subject: string }
|
||||
|
||||
Reference in New Issue
Block a user