feat: JWT bearer assertions for client authentication (#566)

Co-authored-by: Kyle Mendell <ksm@ofkm.us>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Alessandro (Ale) Segala
2025-06-06 03:23:51 -07:00
committed by GitHub
parent 035b2c022b
commit 05bfe00924
38 changed files with 1464 additions and 293 deletions

View File

@@ -35,6 +35,17 @@ export const oidcClients = {
callbackUrl: 'http://immich/auth/callback',
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
},
federated: {
id: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
name: 'Federated',
callbackUrl: 'http://federated/auth/callback',
federatedJWT: {
issuer: 'https://external-idp.local',
audience: 'api://PocketID',
subject: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
},
accessCodes: ['federated']
},
pingvinShare: {
name: 'Pingvin Share',
callbackUrl: 'http://pingvin.share/auth/callback',

View File

@@ -7,6 +7,7 @@
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.21",
"dotenv": "^16.5.0",
"jose": "^6.0.11"
}
},
@@ -36,6 +37,19 @@
"undici-types": "~6.21.0"
}
},
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",

View File

@@ -3,6 +3,7 @@
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.21",
"jose": "^6.0.11"
"jose": "^6.0.11",
"dotenv": "^16.5.0"
}
}

View File

@@ -1,30 +1,31 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
import "dotenv/config";
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
outputDir: './.output',
timeout: 10000,
testDir: './specs',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI
? [['html', { outputFolder: '.report' }], ['github']]
: [['line'], ['html', { open: 'never', outputFolder: '.report' }]],
use: {
baseURL: process.env.APP_URL ?? 'http://localhost:1411',
video: 'retain-on-failure',
trace: 'on-first-retry'
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup']
}
]
outputDir: "./.output",
timeout: 10000,
testDir: "./specs",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI
? [["html", { outputFolder: ".report" }], ["github"]]
: [["line"], ["html", { open: "never", outputFolder: ".report" }]],
use: {
baseURL: process.env.APP_URL ?? "http://localhost:1411",
video: "retain-on-failure",
trace: "on-first-retry",
},
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: ".auth/user.json" },
dependencies: ["setup"],
},
],
});

View File

@@ -4,6 +4,8 @@ import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(cleanupBackend);
test.describe('LDAP Integration', () => {
test.skip(process.env.SKIP_LDAP_TESTS === "true", 'Skipping LDAP tests due to SKIP_LDAP_TESTS environment variable');
test('LDAP configuration is working properly', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');

View File

@@ -2,7 +2,7 @@ import test, { expect } from "@playwright/test";
import { oidcClients, refreshTokens, users } from "../data";
import { cleanupBackend } from "../utils/cleanup.util";
import { generateIdToken, generateOauthAccessToken } from "../utils/jwt.util";
import oidcUtil from "../utils/oidc.util";
import * as oidcUtil from "../utils/oidc.util";
import passkeyUtil from "../utils/passkey.util";
test.beforeEach(cleanupBackend);
@@ -449,3 +449,40 @@ test("Authorize new client with device authorization with user group not allowed
.filter({ hasText: "You're not allowed to access this service." })
).toBeVisible();
});
test("Federated identity fails with invalid client assertion", async ({
page,
}) => {
const client = oidcClients.federated;
const res = await oidcUtil.exchangeCode(page, {
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
grant_type: 'authorization_code',
redirect_uri: client.callbackUrl,
code: client.accessCodes[0],
client_id: client.id,
client_assertion:'not-an-assertion',
});
expect(res?.error).toBe('Invalid client assertion');
});
test("Authorize existing client with federated identity", async ({
page,
}) => {
const client = oidcClients.federated;
const clientAssertion = await oidcUtil.getClientAssertion(page, client.federatedJWT);
const res = await oidcUtil.exchangeCode(page, {
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
grant_type: 'authorization_code',
redirect_uri: client.callbackUrl,
code: client.accessCodes[0],
client_id: client.id,
client_assertion: clientAssertion,
});
expect(res.access_token).not.toBeNull;
expect(res.expires_in).not.toBeNull;
expect(res.token_type).toBe('Bearer');
});

View File

@@ -1,5 +1,6 @@
{
"compilerOptions": {
"baseUrl": "."
"baseUrl": ".",
"lib": ["ES2022"]
}
}

View File

@@ -1,12 +1,15 @@
import playwrightConfig from "../playwright.config";
export async function cleanupBackend() {
const response = await fetch(
playwrightConfig.use!.baseURL + "/api/test/reset",
{
method: "POST",
}
);
const url = new URL("/api/test/reset", playwrightConfig.use!.baseURL);
if (process.env.SKIP_LDAP_TESTS === "true") {
url.searchParams.append("skip-ldap", "true");
}
const response = await fetch(url, {
method: "POST",
});
if (!response.ok) {
throw new Error(

View File

@@ -1,7 +1,7 @@
import type { Page } from '@playwright/test';
async function getUserCode(page: Page, clientId: string, clientSecret: string) {
const response = await page.request
export async function getUserCode(page: Page, clientId: string, clientSecret: string): Promise<string> {
return page.request
.post('/api/oidc/device/authorize', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
@@ -12,11 +12,29 @@ async function getUserCode(page: Page, clientId: string, clientSecret: string) {
scope: 'openid profile email'
}
})
.then((r) => r.json());
return response.user_code;
.then((r) => r.json())
.then((r) => r.user_code);
}
export default {
getUserCode
};
export async function exchangeCode(page: Page, params: Record<string,string>): Promise<{access_token?: string, token_type?: string, expires_in?: number, error?: string}> {
return page.request
.post('/api/oidc/token', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
form: params,
})
.then((r) => r.json());
}
export async function getClientAssertion(page: Page, data: {issuer: string, audience: string, subject: string}): Promise<string> {
return page.request
.post('/api/externalidp/sign', {
data: {
iss: data.issuer,
aud: data.audience,
sub: data.subject,
},
})
.then((r) => r.text());
}