feat(oauth): add support for Pushed Authorization Requests (RFC9126) (#1404)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Thibault NORMAND
2026-06-02 14:02:12 +02:00
committed by GitHub
co-authored by Elias Schneider
parent 2eada149af
commit 68a5abdcca
27 changed files with 765 additions and 54 deletions
+6
View File
@@ -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'
}
};
+30 -1
View File
@@ -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",
+3 -3
View File
@@ -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);
});
});
+37
View File
@@ -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}`);
+214
View File
@@ -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();
});
});
+34
View File
@@ -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 }