From 68dccc1378660d3d36b2e79d2488ed71c91b0846 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 18 Feb 2026 02:40:27 +0000 Subject: [PATCH] OAuth2 OpenID Connect provider support, API and DB refactoring (#3) Allows Paskia to authenticate the user to a client site. - User friendly client registration flow on the admin app - Redirect-based authentication flow (per spec) - Backchannel logout both ways to keep sessions synchronized - Groups integrated with Paskia's permission system - Adds email, preferred username and telephone fields on user profile - All new user basic info layout to show the new information, better looks - API and DB structures redesigned - Various unrelated fixes to theming and layout --- docs/API.md | 2 +- docs/Integration.md | 2 +- e2e/package-lock.json | 64 +- e2e/package.json | 16 +- e2e/playwright.config.js | 2 +- e2e/tests/10-passkey.spec.ts | 10 +- e2e/tests/20-api-auth.spec.ts | 48 +- e2e/tests/fixtures/passkey-helpers.ts | 74 ++- e2e/tests/global-setup.ts | 8 +- e2e/tests/global-teardown.ts | 2 +- e2e/tsconfig.json | 2 +- examples/index.html | 2 +- frontend/auth/App.vue | 3 +- frontend/auth/admin/AdminApp.vue | 295 +++++++-- frontend/auth/restricted/RestrictedApi.vue | 24 +- frontend/auth/restricted/index.html | 4 +- frontend/int/reset/ResetApp.vue | 12 +- frontend/package.json | 1 + frontend/src/admin/AdminDialogs.vue | 40 +- frontend/src/admin/AdminOidcDetail.vue | 283 +++++++++ frontend/src/admin/AdminOrgDetail.vue | 47 +- frontend/src/admin/AdminOverview.vue | 88 ++- frontend/src/admin/AdminUserDetail.vue | 60 +- frontend/src/assets/style.css | 22 +- frontend/src/components/CredentialList.vue | 18 +- frontend/src/components/HostProfileView.vue | 8 +- frontend/src/components/Modal.vue | 71 +-- frontend/src/components/ProfileView.vue | 142 +++-- .../src/components/RegistrationLinkModal.vue | 51 +- frontend/src/components/RemoteAuthPermit.vue | 1 - frontend/src/components/RemoteAuthRequest.vue | 2 +- frontend/src/components/RestrictedAuth.vue | 35 +- frontend/src/components/SessionList.vue | 65 +- frontend/src/components/UserBasicInfo.vue | 131 ++-- frontend/src/stores/auth.js | 6 +- frontend/src/utils/passkey.js | 9 +- frontend/vite.config.js | 13 + oidc.md | 86 +++ paskia-js/README.md | 2 +- paskia-js/package.json | 2 +- paskia-js/src/overlay.ts | 1 + paskia/authcode.py | 113 ++++ paskia/db/__init__.py | 21 +- paskia/db/bootstrap.py | 4 + paskia/db/lifecycle.py | 8 +- paskia/db/logging.py | 77 +-- paskia/db/migrations.py | 11 + paskia/db/operations.py | 302 +++++++-- paskia/db/structs.py | 125 +++- paskia/fastapi/admin.py | 376 ++++++++++-- paskia/fastapi/api.py | 61 +- paskia/fastapi/authz.py | 2 +- paskia/fastapi/mainapp.py | 49 +- paskia/fastapi/oid.py | 574 ++++++++++++++++++ paskia/fastapi/remote.py | 19 +- paskia/fastapi/session.py | 2 +- paskia/fastapi/user.py | 61 +- paskia/fastapi/ws.py | 191 +++++- paskia/fastapi/wschat.py | 10 +- paskia/globals.py | 5 +- paskia/oidc_notify.py | 118 ++++ paskia/util/apistructs.py | 87 +-- paskia/util/crypto.py | 52 ++ paskia/util/nameutil.py | 37 ++ paskia/util/oidjwt.py | 230 +++++++ paskia/util/sessionutil.py | 5 +- paskia/util/userinfo.py | 13 +- pyproject.toml | 2 +- tests/conftest.py | 63 +- tests/test_admin.py | 88 +-- tests/test_api.py | 51 +- 71 files changed, 3706 insertions(+), 805 deletions(-) create mode 100644 frontend/src/admin/AdminOidcDetail.vue create mode 100644 oidc.md create mode 100644 paskia/authcode.py create mode 100644 paskia/fastapi/oid.py create mode 100644 paskia/oidc_notify.py create mode 100644 paskia/util/crypto.py create mode 100644 paskia/util/nameutil.py create mode 100644 paskia/util/oidjwt.py diff --git a/docs/API.md b/docs/API.md index 896313a..918af0f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -15,7 +15,7 @@ For integrating Paskia with your app frontend, see [integration](Integration.md) | Method | Path | Used for | Notes | |---:|---|---|---| | GET | `/auth/api/settings` | Paskia configuration | Returns RP info + base paths + session cookie name | -| POST | `/auth/api/user-info` | Full user profile | Basic information, credentials, sessions, permissions | +| GET | `/auth/api/user-info` | Full user profile | Basic information, credentials, sessions, permissions | | POST | `/auth/api/logout` | Terminate session and delete session cookie | Signs out of the current site | | POST | `/auth/api/validate` | Validate and renew session cookie | Optional query: `perm=` (repeatable), `max_age=` | | GET | `/auth/api/forward` | Validate access (Caddy/Nginx) | 204 on success; 401/403 otherwise (HTML if requested) | diff --git a/docs/Integration.md b/docs/Integration.md index 9be5810..cc7abf3 100644 --- a/docs/Integration.md +++ b/docs/Integration.md @@ -91,7 +91,7 @@ if (response.status === 401 || response.status === 403) { Get current user details: ```js -const user = await apiJson('/auth/api/user-info', { method: 'POST' }) +const user = await apiJson('/auth/api/user-info', { method: 'GET' }) // Returns: { uuid, display_name, credentials, sessions, permissions, ... } ``` diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 4fc60fa..49a54ff 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -10,7 +10,7 @@ "devDependencies": { "@playwright/test": "^1.49.0", "@simplewebauthn/browser": "^13.1.2", - "@types/bun": "^1.3.3", + "@types/node": "*", "c8": "^10.1.3" } }, @@ -92,11 +92,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.57.0", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -107,17 +109,11 @@ }, "node_modules/@simplewebauthn/browser": { "version": "13.2.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz", + "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==", "dev": true, "license": "MIT" }, - "node_modules/@types/bun": { - "version": "1.3.3", - "dev": true, - "license": "MIT", - "dependencies": { - "bun-types": "1.3.3" - } - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -126,7 +122,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "dev": true, "license": "MIT", "dependencies": { @@ -176,14 +174,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/bun-types": { - "version": "1.3.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/c8": { "version": "10.1.3", "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", @@ -412,6 +402,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -426,6 +431,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -674,11 +680,13 @@ } }, "node_modules/playwright": { - "version": "1.57.0", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -691,7 +699,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -712,9 +722,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -894,6 +904,8 @@ }, "node_modules/undici-types": { "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, diff --git a/e2e/package.json b/e2e/package.json index 5189738..d563e7e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,18 +5,18 @@ "description": "E2E tests for Paskia using Playwright with Virtual Authenticator", "type": "module", "scripts": { - "test": "bunx playwright test", - "test:headed": "bunx playwright test --headed", - "test:debug": "bunx playwright test --debug", - "test:ui": "bunx playwright test --ui", - "test:coverage": "COVERAGE=1 bunx playwright test", - "report": "bunx playwright show-report", - "install:browsers": "bunx playwright install chromium" + "test": "npx playwright test", + "test:headed": "npx playwright test --headed", + "test:debug": "npx playwright test --debug", + "test:ui": "npx playwright test --ui", + "test:coverage": "COVERAGE=1 npx playwright test", + "report": "npx playwright show-report", + "install:browsers": "npx playwright install chromium" }, "devDependencies": { "@playwright/test": "^1.49.0", "@simplewebauthn/browser": "^13.1.2", - "@types/bun": "^1.3.3", + "@types/node": "*", "c8": "^10.1.3" } } diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index eab7aba..6fdfb9e 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test' * Playwright configuration for Paskia E2E tests. * Uses Chrome's Virtual Authenticator for automated passkey testing. * - * Run with: bun run test + * Run with: npm test */ export default defineConfig({ diff --git a/e2e/tests/10-passkey.spec.ts b/e2e/tests/10-passkey.spec.ts index 6457360..89acf70 100644 --- a/e2e/tests/10-passkey.spec.ts +++ b/e2e/tests/10-passkey.spec.ts @@ -148,10 +148,10 @@ test.describe('Passkey Authentication E2E', () => { const userInfo = await getUserInfo(page, baseUrl, sessionToken) - expect(userInfo.ctx.user.uuid).toBe(userUuid) - expect(userInfo.ctx.user.display_name).toBe('Admin User') + expect(userInfo.user.uuid).toBe(userUuid) + expect(userInfo.user.display_name).toBe('Admin User') expect(userInfo.credentials).toBeDefined() - expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1) + expect(Object.keys(userInfo.credentials).length).toBeGreaterThanOrEqual(1) // Navigate to profile and take screenshot const cookieName = getSessionCookieName() @@ -169,8 +169,8 @@ test.describe('Passkey Authentication E2E', () => { await page.screenshot({ path: 'test-results/profile-view.png' }) console.log('✓ Screenshot saved: test-results/profile-view.png') - console.log(`✓ User info retrieved: ${userInfo.ctx.user.display_name}`) - console.log(`✓ Credentials count: ${userInfo.credentials.length}`) + console.log(`✓ User info retrieved: ${userInfo.user.display_name}`) + console.log(`✓ Credentials count: ${Object.keys(userInfo.credentials).length}`) }) test('should authenticate with existing passkey', async ({ page, virtualAuthenticator }) => { diff --git a/e2e/tests/20-api-auth.spec.ts b/e2e/tests/20-api-auth.spec.ts index 6f268b7..d611ccd 100644 --- a/e2e/tests/20-api-auth.spec.ts +++ b/e2e/tests/20-api-auth.spec.ts @@ -10,6 +10,12 @@ import { logout, } from './fixtures/passkey-helpers' import type { Page, Frame } from '@playwright/test' +import { readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) /** * E2E tests for API mode authentication flows. @@ -55,8 +61,18 @@ async function clearSessionCookie(page: Page): Promise { /** * Set up the test page using the examples page directly. * The examples page already has iframe handling - we just add a Promise wrapper. + * We route the paskia-js module request to serve from the local dist. */ async function setupTestHarness(page: Page): Promise { + // Serve paskia.js from the local filesystem since the server doesn't serve /paskia-js/ + const paskiaJsPath = join(__dirname, '..', '..', 'paskia-js', 'dist', 'paskia.js') + await page.route('**/paskia-js/dist/paskia.js', async route => { + const body = readFileSync(paskiaJsPath, 'utf-8') + await route.fulfill({ + body, + contentType: 'application/javascript', + }) + }) // Navigate to the examples page which already has the auth iframe handling await page.goto(`${baseUrl}/auth/examples/`) } @@ -143,8 +159,8 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s * Wait for auth iframe to appear and return a reference to it. */ async function waitForAuthIframe(page: Page, timeout = 5000): Promise { - await page.waitForSelector('#auth-iframe', { timeout }) - const iframe = page.frameLocator('#auth-iframe') + await page.waitForSelector('#paskia-iframe', { timeout }) + const iframe = page.frameLocator('#paskia-iframe') // Wait for iframe content to load await iframe.locator('.view-root').waitFor({ timeout }) return page.frame({ url: /\/auth\/restricted\// })! @@ -154,14 +170,14 @@ async function waitForAuthIframe(page: Page, timeout = 5000): Promise { * Wait for auth iframe to disappear. */ async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise { - await page.waitForSelector('#auth-iframe', { state: 'detached', timeout }) + await page.waitForSelector('#paskia-iframe', { state: 'detached', timeout }) } /** * Click Back button in auth iframe. */ async function clickBackInIframe(page: Page): Promise { - const iframe = page.frameLocator('#auth-iframe') + const iframe = page.frameLocator('#paskia-iframe') await iframe.getByRole('button', { name: 'Back' }).click() } @@ -169,7 +185,7 @@ async function clickBackInIframe(page: Page): Promise { * Click Login button in auth iframe. */ async function clickLoginInIframe(page: Page): Promise { - const iframe = page.frameLocator('#auth-iframe') + const iframe = page.frameLocator('#paskia-iframe') await iframe.getByRole('button', { name: 'Login' }).click() } @@ -177,7 +193,7 @@ async function clickLoginInIframe(page: Page): Promise { * Click Verify button in auth iframe (for reauth mode). */ async function clickVerifyInIframe(page: Page): Promise { - const iframe = page.frameLocator('#auth-iframe') + const iframe = page.frameLocator('#paskia-iframe') await iframe.getByRole('button', { name: 'Verify' }).click() } @@ -185,7 +201,7 @@ async function clickVerifyInIframe(page: Page): Promise { * Click Logout button in auth iframe (for forbidden mode). */ async function clickLogoutInIframe(page: Page): Promise { - const iframe = page.frameLocator('#auth-iframe') + const iframe = page.frameLocator('#paskia-iframe') await iframe.getByRole('button', { name: 'Logout' }).click() } @@ -204,7 +220,7 @@ test.describe('API Mode - 401 Login Flow', () => { console.log('✓ Auth iframe appeared on 401') // Verify it's in login mode (not reauth) - const iframe = page.frameLocator('#auth-iframe') + const iframe = page.frameLocator('#paskia-iframe') await expect(iframe.locator('h1')).toContainText('🔐') await expect(iframe.getByRole('button', { name: 'Login' })).toBeVisible() @@ -268,7 +284,7 @@ test.describe('API Mode - 401 Login Flow', () => { // Wait for API call to complete and verify result const result = await apiCallPromise expect(result.status).toBe(200) - expect(result.data.ctx).toBeDefined() + expect(result.data.user).toBeDefined() console.log('✓ API call succeeded after authentication') // Save the session for other tests @@ -314,7 +330,7 @@ test.describe('API Mode - 401 Reauth Flow', () => { console.log('✓ Reauth iframe appeared (session older than max_age)') // Verify it's in reauth mode - const iframe = page.frameLocator('#auth-iframe') + const iframe = page.frameLocator('#paskia-iframe') await expect(iframe.locator('h1')).toContainText('Additional Authentication') await expect(iframe.getByRole('button', { name: 'Verify' })).toBeVisible() @@ -362,7 +378,7 @@ test.describe('API Mode - 401 Reauth Flow', () => { await waitForAuthIframe(page) console.log('✓ Reauth iframe appeared') - const iframe = page.frameLocator('#auth-iframe') + const iframe = page.frameLocator('#paskia-iframe') await expect(iframe.locator('h1')).toContainText('Additional Authentication') // Click Verify - virtual authenticator handles passkey @@ -394,7 +410,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => { const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e) // Check if auth iframe appeared - const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false) + const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false) if (!iframeAppeared) { // User might already have admin permission @@ -410,7 +426,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => { // Wait for view to stabilize and check mode await page.waitForTimeout(500) - const iframe = page.frameLocator('#auth-iframe') + const iframe = page.frameLocator('#paskia-iframe') const headingText = await iframe.locator('h1').textContent() console.log(` Heading: ${headingText}`) @@ -459,7 +475,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => { const apiCallPromise = makeApiCall(page, '/auth/api/forward?perm=auth:admin', 'GET').catch(e => e) // Check if auth iframe appeared - const iframeAppeared = await page.waitForSelector('#auth-iframe', { timeout: 3000 }).then(() => true).catch(() => false) + const iframeAppeared = await page.waitForSelector('#paskia-iframe', { timeout: 3000 }).then(() => true).catch(() => false) if (!iframeAppeared) { const result = await apiCallPromise @@ -470,7 +486,7 @@ test.describe('API Mode - 403 Forbidden Flow', () => { } await waitForAuthIframe(page) - const iframe = page.frameLocator('#auth-iframe') + const iframe = page.frameLocator('#paskia-iframe') await page.waitForTimeout(500) const headingText = await iframe.locator('h1').textContent() @@ -534,7 +550,7 @@ test.describe('API Mode - Direct API Response Format', () => { expect(data.auth).toBeDefined() expect(data.auth.iframe).toBeDefined() expect(data.auth.mode).toBe('login') - expect(data.auth.iframe).toContain('/auth/restricted/') + expect(data.auth.iframe).toContain('/auth/restricted/iframe') console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`) }) diff --git a/e2e/tests/fixtures/passkey-helpers.ts b/e2e/tests/fixtures/passkey-helpers.ts index c157a01..8fa61c8 100644 --- a/e2e/tests/fixtures/passkey-helpers.ts +++ b/e2e/tests/fixtures/passkey-helpers.ts @@ -44,7 +44,7 @@ export interface UserInfo { sign_count: number is_current_session: boolean }> - aaguid_info: Record + aaguid_info: Record sessions: Array<{ id: string credential: string @@ -193,7 +193,8 @@ export async function registerPasskey( baseUrl: string, options: { resetToken?: string; displayName?: string } = {} ): Promise { - return await page.evaluate(async ({ baseUrl, resetToken, displayName }) => { + // Step 1: Do WebSocket registration + exchange code in browser context + const wsResult = await page.evaluate(async ({ baseUrl, resetToken, displayName }) => { // Build WebSocket URL with query parameters let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register` const params: string[] = [] @@ -203,6 +204,7 @@ export async function registerPasskey( return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl) + let done = false ws.onopen = () => { console.log('WebSocket connected for registration') @@ -213,15 +215,31 @@ export async function registerPasskey( // Check for error response if (data.detail) { + done = true ws.close() reject(new Error(data.detail)) return } - // Check if this is the final success response - if (data.session_token) { + // Check if this is the final success response (exchange_code flow) + if (data.exchange_code) { + done = true ws.close() - resolve(data) + // Exchange the code for a session cookie + try { + const resp = await fetch(`${baseUrl}/auth/api/set-session`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${data.exchange_code}` }, + }) + if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`) + resolve({ + user: data.user, + credential: data.credential, + message: data.message || 'Registration successful', + }) + } catch (err: any) { + reject(new Error(`Code exchange failed: ${err.message}`)) + } return } @@ -293,12 +311,21 @@ export async function registerPasskey( } ws.onclose = (event) => { - if (!event.wasClean && event.code !== 1000) { + if (!done && !event.wasClean && event.code !== 1000) { reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) } } }) }, { baseUrl, resetToken: options.resetToken, displayName: options.displayName }) + + // Step 2: Extract the session token from the cookie set by the exchange + const cookies = await page.context().cookies() + const cookieName = getSessionCookieName() + const sessionCookie = cookies.find(c => c.name === cookieName) + return { + ...wsResult, + session_token: sessionCookie?.value || '', + } } /** @@ -309,11 +336,13 @@ export async function authenticatePasskey( page: Page, baseUrl: string ): Promise { - return await page.evaluate(async ({ baseUrl }) => { + // Step 1: Do WebSocket authentication + exchange code in browser context + const wsResult = await page.evaluate(async ({ baseUrl }) => { const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate` return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl) + let done = false ws.onopen = () => { console.log('WebSocket connected for authentication') @@ -324,15 +353,27 @@ export async function authenticatePasskey( // Check for error response if (data.detail) { + done = true ws.close() reject(new Error(data.detail)) return } - // Check if this is the final success response - if (data.session_token) { + // Check if this is the final success response (exchange_code flow) + if (data.exchange_code) { + done = true ws.close() - resolve(data) + // Exchange the code for a session cookie + try { + const resp = await fetch(`${baseUrl}/auth/api/set-session`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${data.exchange_code}` }, + }) + if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`) + resolve({ user: data.user }) + } catch (err: any) { + reject(new Error(`Code exchange failed: ${err.message}`)) + } return } @@ -395,12 +436,21 @@ export async function authenticatePasskey( } ws.onclose = (event) => { - if (!event.wasClean && event.code !== 1000) { + if (!done && !event.wasClean && event.code !== 1000) { reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) } } }) }, { baseUrl }) + + // Step 2: Extract the session token from the cookie set by the exchange + const cookies = await page.context().cookies() + const cookieName = getSessionCookieName() + const sessionCookie = cookies.find(c => c.name === cookieName) + return { + ...wsResult, + session_token: sessionCookie?.value || '', + } } /** @@ -429,7 +479,7 @@ export async function getUserInfo( sessionToken: string ): Promise { const cookieName = getSessionCookieName() - const response = await page.request.post(`${baseUrl}/auth/api/user-info`, { + const response = await page.request.get(`${baseUrl}/auth/api/user-info`, { headers: { 'Cookie': `${cookieName}=${sessionToken}`, }, diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts index 4865ea1..a268057 100644 --- a/e2e/tests/global-setup.ts +++ b/e2e/tests/global-setup.ts @@ -42,16 +42,16 @@ export default async function globalSetup() { const serverArgs = COLLECT_COVERAGE ? [ 'run', 'coverage', 'run', '--parallel-mode', - '-m', 'paskia.fastapi', 'localhost:4404', + '-m', 'paskia', '-l', 'localhost:4404', '--rp-id', 'localhost' ] : [ - 'run', 'paskia', 'localhost:4404', + 'run', 'paskia', '-l', 'localhost:4404', '--rp-id', 'localhost' ] - // Use a temporary jsonl file for test database - const testDbFile = join(testDataDir, 'test-db.jsonl') + // Use a fresh database file for tests + const testDbFile = join(testDataDir, 'test.paskiadb') // Start the server using Node's spawn const serverProcess = spawn('uv', serverArgs, { diff --git a/e2e/tests/global-teardown.ts b/e2e/tests/global-teardown.ts index 3a1c0bc..c8c53be 100644 --- a/e2e/tests/global-teardown.ts +++ b/e2e/tests/global-teardown.ts @@ -60,7 +60,7 @@ export default async function globalTeardown() { } // Clean up test database - const testDbFile = join(testDataDir, 'test-db.jsonl') + const testDbFile = join(testDataDir, 'test.paskiadb') if (existsSync(testDbFile)) { console.log(' Removing test database...') rmSync(testDbFile) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 484bcec..3f66bf7 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -8,7 +8,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "types": ["bun-types"] + "types": ["node"] }, "include": ["tests/**/*.ts", "playwright.config.ts"], "exclude": ["node_modules"] diff --git a/examples/index.html b/examples/index.html index 4a5bddf..dac6fe0 100644 --- a/examples/index.html +++ b/examples/index.html @@ -27,7 +27,7 @@

API Mode (not leaving the page)

For SPAs and fetch() calls - shows auth in an iframe overlay:

- + diff --git a/frontend/auth/App.vue b/frontend/auth/App.vue index 466c695..70a005c 100644 --- a/frontend/auth/App.vue +++ b/frontend/auth/App.vue @@ -47,7 +47,6 @@ const isHostMode = computed(() => { const configuredHost = normalizeHost(authHost) return currentHost !== configuredHost }) -const userUuid = computed(() => store.userInfo?.ctx.user.uuid) function terminateSession() { store.userInfo = null @@ -64,7 +63,7 @@ async function loadUserInfo() { try { const [validateData, userInfoData] = await Promise.all([ apiJson('/auth/api/validate', { method: 'POST' }), - apiJson('/auth/api/user-info', { method: 'POST' }) + apiJson('/auth/api/user-info', { method: 'GET' }) ]) store.userInfo = userInfoData store.ctx = validateData.ctx diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 0c4f3c0..17e7e28 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -9,10 +9,12 @@ import AccessDenied from '@/components/AccessDenied.vue' import AdminOverview from '@/admin/AdminOverview.vue' import AdminOrgDetail from '@/admin/AdminOrgDetail.vue' import AdminUserDetail from '@/admin/AdminUserDetail.vue' +import AdminOidcDetail from '@/admin/AdminOidcDetail.vue' import AdminDialogs from '@/admin/AdminDialogs.vue' import { useAuthStore } from '@/stores/auth' import { adminUiPath, makeUiHref } from '@/utils/settings' import { apiJson, SessionValidator } from 'paskia' +import { uuidv7 } from 'uuidv7' import { getDirection } from '@/utils/keynav' import { goBack } from '@/utils/helpers' @@ -24,9 +26,12 @@ const showBackMessage = ref(false) const error = ref(null) const orgs = ref([]) const permissions = ref([]) +const oidcClients = ref([]) const currentOrgId = ref(null) // UUID of selected org for detail view const currentUserId = ref(null) // UUID for user detail view +const currentOidcId = ref(null) // UUID for OIDC client detail view const userDetail = ref(null) // cached user detail object +const editingOidcClient = ref(null) // OIDC client being edited (with local changes) const authStore = useAuthStore() const addingOrgForPermission = ref(null) const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$' @@ -43,6 +48,7 @@ const breadcrumbsRef = ref(null) const adminOverviewRef = ref(null) const adminOrgDetailRef = ref(null) const adminUserDetailRef = ref(null) +const adminOidcDetailRef = ref(null) // Check if any modal/dialog is open (blocks arrow key navigation) const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value) @@ -80,10 +86,11 @@ const permissionSummary = computed(() => { const summary = {} for (const o of orgs.value) { const orgBase = { uuid: o.uuid, display_name: o.org.display_name } - const orgPerms = new Set(Object.keys(o.permissions)) + // o.permissions is a dict[UUID, Permission] + const orgPermUuids = new Set(Object.keys(o.permissions || {})) // Org-level permissions (direct) - only count if org can grant them - for (const pid of Object.keys(o.permissions)) { + for (const pid of Object.keys(o.permissions || {})) { if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } if (!summary[pid].orgSet.has(o.uuid)) { summary[pid].orgs.push(orgBase) @@ -92,10 +99,11 @@ const permissionSummary = computed(() => { } // Role-based permissions (inheritance) - only count if org can grant them - for (const [roleUuid, r] of Object.entries(o.roles)) { + for (const [roleUuid, r] of Object.entries(o.roles || {})) { + // r.permissions is dict[UUID, bool] for (const pid of Object.keys(r.permissions || {})) { // Only count if the org can grant this permission - if (!orgPerms.has(pid)) continue + if (!orgPermUuids.has(pid)) continue if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } if (!summary[pid].orgSet.has(o.uuid)) { @@ -120,16 +128,49 @@ function parseHash() { const h = window.location.hash || '' currentOrgId.value = null currentUserId.value = null + currentOidcId.value = null + editingOidcClient.value = null if (h.startsWith('#org/')) { currentOrgId.value = h.slice(5) } else if (h.startsWith('#user/')) { currentUserId.value = h.slice(6) + } else if (h.startsWith('#oidc:')) { + const oidcUuid = h.slice(6) + currentOidcId.value = oidcUuid + // Initialize editing client data + if (oidcUuid === 'new') { + // Generate client_id and secret for new client + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + const client_secret = btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + editingOidcClient.value = { + client_id: uuidv7(), + client_secret, + isNew: true, + name: '', + redirect_uris: [] + } + } else { + const client = oidcClients.value.find(c => c.uuid === oidcUuid) + if (client) { + editingOidcClient.value = { + ...client, + client_id: client.uuid, + client_secret: null, + isNew: false + } + } + } } } -async function loadOrgs() { - const data = await apiJson('/auth/api/admin/orgs') - orgs.value = Object.entries(data).map(([uuid, o]) => ({ uuid, ...o })) +async function loadAdminData() { + const data = await apiJson('/auth/api/admin/info') + // Convert dicts to arrays with uuid added + orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o })) + permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p })) + oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c })) } // Helper to get users for a role as sorted array of [uuid, user] @@ -153,10 +194,6 @@ function orgUserCount(org) { return Object.keys(org.users).length } -async function loadPermissions() { - permissions.value = Object.values(await apiJson('/auth/api/admin/permissions')) -} - async function loadUserInfo() { const data = await apiJson('/auth/api/validate', { method: 'POST' }) info.value = data @@ -167,7 +204,9 @@ function clearSensitiveState() { info.value = null orgs.value = [] permissions.value = [] + oidcClients.value = [] userDetail.value = null + editingOidcClient.value = null authenticated.value = false } @@ -192,7 +231,7 @@ async function load() { error.value = null try { // Load admin data first - apiJson will handle 401/403 with iframe authentication - await Promise.all([loadOrgs(), loadPermissions()]) + await loadAdminData() // If we get here, user has admin access - now fetch user info for display await loadUserInfo() @@ -214,13 +253,13 @@ async function load() { // Org actions function createOrg() { openDialog('org-create', {}) } -function updateOrg(org) { openDialog('org-update', { org, name: org.org.display_name }) } +function updateOrg(org) { openDialog('org-update', { org, name: org.display_name }) } function editUserName(user) { openDialog('user-update-name', { user, name: user.display_name }) } async function performOrgDeletion(orgUuid) { await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' }) - await Promise.all([loadOrgs(), loadPermissions()]) + await Promise.all([loadAdminData()]) } function deleteOrg(org) { @@ -240,9 +279,9 @@ function deleteOrg(org) { // Build detailed breakdown of users by role const roleParts = Object.entries(org.roles) - .map(([uuid, r]) => [roleUserCount(org, uuid), r.display_name]) - .filter(([count]) => count > 0) - .map(([count, name]) => `${count} ${name}`) + .map(([uuid, r]) => ({ role: r, count: roleUserCount(org, uuid) })) + .filter(x => x.count > 0) + .map(x => `${x.count} ${x.role.display_name}`) const affects = roleParts.join(', ') @@ -254,7 +293,7 @@ function deleteOrg(org) { function createUserInRole(org, role) { openDialog('user-create', { org, role }) } function deleteUser(user, userDetail) { - const credentialCount = Object.keys(userDetail?.credentials || {}).length + const credentialCount = userDetail?.credentials ? Object.keys(userDetail.credentials).length : 0 const userUuid = user.uuid const userName = user.display_name const orgUuid = user.org // org UUID is stored in selectedUser @@ -278,7 +317,7 @@ async function performUserDeletion(userUuid, userName, orgUuid) { try { await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' }) authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500) - await loadOrgs() + await loadAdminData() window.location.hash = `#org/${orgUuid}` } catch (e) { authStore.showMessage(e.message || 'Failed to delete user', 'error') @@ -292,7 +331,7 @@ async function moveUserToRole(userUuid, user, targetRoleUuid) { method: 'PATCH', body: { role_uuid: targetRoleUuid } }) - await loadOrgs() + await loadAdminData() } catch (e) { authStore.showMessage(e.message || 'Failed to update user role') } @@ -308,13 +347,13 @@ function onRoleDragOver(e) { e.dataTransfer.dropEffect = 'move' } -function onRoleDrop(e, org, roleUuid) { +function onRoleDrop(e, org, role) { e.preventDefault() try { const data = JSON.parse(e.dataTransfer.getData('text/plain')) if (data.org !== org.uuid) return // only within same org const user = org.users[data.user_uuid] - if (user) moveUserToRole(data.user_uuid, user, roleUuid) + if (user) moveUserToRole(data.user_uuid, user, role.uuid) } catch (_) { /* ignore */ } } @@ -323,22 +362,22 @@ function createRole(org) { openDialog('role-create', { org }) } function updateRole(role) { openDialog('role-update', { role, name: role.display_name }) } -function deleteRole(roleUuid, role) { +function deleteRole(role) { // UI only allows deleting empty roles, so no confirmation needed - apiJson(`/auth/api/admin/roles/${roleUuid}`, { method: 'DELETE' }) + apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' }) .then(() => { authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500) - loadOrgs() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to delete role', 'error') }) } -async function toggleRolePermission(roleUuid, role, pid, checked) { - // Optimistic update - const prevPermissions = { ...(role.permissions || {}) } - const newPermissions = { ...(role.permissions || {}) } +async function toggleRolePermission(role, pid, checked) { + // Optimistic update - role.permissions is dict[UUID, bool] + const prevPermissions = { ...role.permissions } + const newPermissions = { ...role.permissions } if (checked) { newPermissions[pid] = true } else { @@ -348,10 +387,10 @@ async function toggleRolePermission(roleUuid, role, pid, checked) { try { const method = checked ? 'POST' : 'DELETE' - await apiJson(`/auth/api/admin/roles/${roleUuid}/permissions/${pid}`, { + await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, { method }) - await loadOrgs() + await loadAdminData() } catch (e) { authStore.showMessage(e.message || 'Failed to update role permission') role.permissions = prevPermissions // revert @@ -362,7 +401,7 @@ async function toggleRolePermission(roleUuid, role, pid, checked) { async function performPermissionDeletion(permissionUuid) { const params = new URLSearchParams({ permission_uuid: permissionUuid }) await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' }) - await loadPermissions() + await loadAdminData() } function deletePermission(p) { @@ -400,6 +439,87 @@ function deletePermission(p) { } }) } +// OIDC Client actions +async function sha256Hex(text) { + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)) + return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('') +} + +function createOidcClient() { + // Navigate to new OIDC client page + window.location.hash = '#oidc:new' +} + +function openOidcClient(client) { + // Navigate to OIDC client detail page + window.location.hash = `#oidc:${client.uuid}` +} + +function resetOidcSecret(clientId) { + // Generate new secret locally; it will be sent to server on Save + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + const client_secret = btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + // Update editingOidcClient if we're on the detail page + if (editingOidcClient.value?.client_id === clientId) { + editingOidcClient.value = { ...editingOidcClient.value, client_secret } + } + // Also update dialog if open (for backwards compatibility) + if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) { + dialog.value.data.client_secret = client_secret + } +} + +function createPermissionForClient(clientId) { + openDialog('perm-create', { display_name: '', scope: '', domain: clientId }) +} + +function deleteOidcClient(client) { + openDialog('confirm', { + message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`, + action: async () => { + await performOidcClientDeletion(client.uuid, client.name) + // Navigate back to overview if we were on the detail page + if (currentOidcId.value === client.uuid) { + window.location.hash = '#overview' + } + } + }) +} + +async function performOidcClientDeletion(clientUuid, clientName) { + await apiJson(`/auth/api/admin/oidc-clients/${clientUuid}`, { method: 'DELETE' }) + authStore.showMessage(`OIDC client "${clientName}" deleted.`, 'success', 2500) + await loadAdminData() +} + +async function handleOidcSave(data) { + const { client_id, client_secret, name, redirect_uris, isNew } = data + + try { + if (client_secret) { + const secret_hash = await sha256Hex(client_secret) + if (isNew) { + await apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } }) + } else { + await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } }) + } + } else { + await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } }) + } + authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500) + await loadAdminData() + window.location.hash = '#overview' + } catch (e) { + authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') + } +} + +function handleOidcCancel() { + goOverview() +} + const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null) function openOrg(o) { @@ -431,21 +551,27 @@ const breadcrumbEntries = computed(() => { const entries = [ { label: 'My Profile', href: makeUiHref() } ] + // For org admins, combine Admin and their org + if (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) { + const org = orgs.value[0] + entries.push({ label: `Admin: ${org.org.display_name}`, href: `#org/${org.uuid}` }) + } else { + entries.push({ label: 'Admin', href: adminUiPath() }) + } // Determine organization for user view if selectedOrg not explicitly chosen. let orgForUser = null if (selectedUser.value) { orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null } const orgToShow = selectedOrg.value || orgForUser - if (orgToShow && isOrgAdmin.value && !isMasterAdmin.value) { - // For org admins, combine Admin and org name into one link - entries.push({ label: `Admin: ${orgToShow.org.display_name}`, href: `#org/${orgToShow.uuid}` }) - } else { - // For master admins or when not showing an org, separate Admin link - entries.push({ label: 'Admin', href: isMasterAdmin.value ? adminUiPath() : (orgs.value.length === 1 ? `#org/${orgs.value[0].uuid}` : adminUiPath()) }) - if (orgToShow) { - entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` }) - } + // Add org breadcrumb only if it's not already included in the Admin entry + const adminOrg = (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) ? orgs.value[0] : null + if (orgToShow && (!adminOrg || orgToShow.uuid !== adminOrg.uuid)) { + entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` }) + } + if (currentOidcId.value) { + const label = editingOidcClient.value?.isNew ? 'New Client' : (editingOidcClient.value?.name || 'OIDC Client') + entries.push({ label, href: `#oidc:${currentOidcId.value}` }) } if (selectedUser.value) { entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` }) @@ -468,23 +594,27 @@ function generateUserRegistrationLink(u) { } async function toggleOrgPermission(org, permId, checked) { - // Build next permission dict + // org.permissions is dict[UUID, Permission] const has = permId in org.permissions if (checked && has) return if (!checked && !has) return - const next = { ...org.permissions } - if (checked) { - next[permId] = true // Placeholder, real data comes from loadOrgs - } else { - delete next[permId] - } // Optimistic update const prev = { ...org.permissions } - org.permissions = next + if (checked) { + // Need to fetch the permission object to add it + const perm = permissions.value.find(p => p.uuid === permId) + if (perm) { + org.permissions = { ...org.permissions, [permId]: perm } + } + } else { + const next = { ...org.permissions } + delete next[permId] + org.permissions = next + } try { const params = new URLSearchParams({ permission_uuid: permId }) await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' }) - await loadOrgs() + await loadAdminData() } catch (e) { authStore.showMessage(e.message || 'Failed to update organization permission', 'error') org.permissions = prev // revert @@ -594,7 +724,7 @@ function handlePanelNavigateOut(direction) { } async function refreshUserDetail() { - await loadOrgs() + await loadAdminData() if (selectedUser.value) { try { userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`) @@ -620,7 +750,7 @@ async function submitDialog() { apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } }) .then(() => { authStore.showMessage(`Organization "${name}" created.`, 'success', 2500) - Promise.all([loadOrgs(), loadPermissions()]) + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to create organization', 'error') @@ -634,7 +764,7 @@ async function submitDialog() { apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } }) .then(() => { authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500) - loadOrgs() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to update organization', 'error') @@ -648,7 +778,7 @@ async function submitDialog() { apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } }) .then(() => { authStore.showMessage(`Role "${name}" created.`, 'success', 2500) - loadOrgs() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to create role', 'error') @@ -676,7 +806,7 @@ async function submitDialog() { apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } }) .then(() => { authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500) - loadOrgs() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to add user', 'error') @@ -687,7 +817,7 @@ async function submitDialog() { // Close dialog immediately, then perform async operation closeDialog() - apiJson(`/auth/api/admin/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } }) + apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } }) .then(() => { authStore.showMessage(`User renamed to "${name}".`, 'success', 2500) onUserNameSaved() @@ -722,7 +852,7 @@ async function submitDialog() { apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' }) .then(() => { authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500) - loadPermissions() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to update permission', 'error') @@ -738,12 +868,37 @@ async function submitDialog() { apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } }) .then(() => { authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500) - loadPermissions() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to create permission', 'error') }) return // Don't call closeDialog() again + } else if (t === 'oidc-edit') { + const { client_id, client_secret, isNew } = dialog.value.data + const name = dialog.value.data.name?.trim() + const uris = dialog.value.data.redirect_uris?.trim() + if (!name) throw new Error('Client name required') + + const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : [] + + // Close dialog immediately, then perform async operation + closeDialog() + + const req = client_secret + ? sha256Hex(client_secret).then(secret_hash => isNew + ? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } }) + : apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } })) + : apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } }) + req + .then(() => { + authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500) + loadAdminData() + }) + .catch(e => { + authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') + }) + return // Don't call closeDialog() again } else if (t === 'confirm') { const action = dialog.value.data.action // Close dialog first, then perform action (errors shown via showMessage) @@ -790,11 +945,12 @@ async function submitDialog() {
@@ -846,6 +1005,21 @@ async function submitDialog() { @on-user-drag-start="onUserDragStart" /> + +
@@ -857,13 +1031,14 @@ async function submitDialog() { :settings="authStore.settings" @submit-dialog="submitDialog" @close-dialog="closeDialog" + @reset-oidc-secret="resetOidcSecret" + @create-permission-for-client="createPermissionForClient" />
diff --git a/frontend/auth/restricted/RestrictedApi.vue b/frontend/auth/restricted/RestrictedApi.vue index 791b445..3fb1b4d 100644 --- a/frontend/auth/restricted/RestrictedApi.vue +++ b/frontend/auth/restricted/RestrictedApi.vue @@ -2,6 +2,7 @@ @@ -15,6 +16,9 @@ import RestrictedAuth from '@/components/RestrictedAuth.vue' // The token is a 5-word passphrase like "word1.word2.word3.word4.word5" const remoteAuthToken = ref(null) +// For OIDC flow, pass the raw query string to preserve exact param values +const oidcQueryString = window.location.search.includes('client_id=') ? window.location.search : null + function extractRemoteToken() { const path = window.location.pathname // Match /auth/{token} where token is a passphrase with dots @@ -32,7 +36,18 @@ function extractRemoteToken() { // Parse URL hash fragment const hashParams = new URLSearchParams(window.location.hash.slice(1)) -const authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login' + +// Determine auth mode based on URL path +// - /auth/restricted/oidc: OIDC flow, no session dependency +// - /auth/restricted/iframe: iframe embedding, mode from hash params +// - Other paths: forward auth, mode from hash params +let authMode +if (window.location.pathname === '/auth/restricted/oidc') { + authMode = 'oidc' +} else { + // Both iframe and forward auth use hash params for mode (forbidden/login/reauth) + authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login' +} function postToParent(message) { if (window.parent && window.parent !== window) { @@ -41,10 +56,15 @@ function postToParent(message) { } function handleAuthenticated(result) { + if (result.redirect_url) { + // OIDC flow: redirect to client with auth code + window.location.href = result.redirect_url + return + } postToParent({ type: 'auth-success', authenticated: true, - sessionToken: result.session_token + exchangeCode: result.exchange_code }) } diff --git a/frontend/auth/restricted/index.html b/frontend/auth/restricted/index.html index c6cae5a..1df4c7f 100644 --- a/frontend/auth/restricted/index.html +++ b/frontend/auth/restricted/index.html @@ -1,9 +1,9 @@ - + - + diff --git a/frontend/int/reset/ResetApp.vue b/frontend/int/reset/ResetApp.vue index b647ceb..745fd66 100644 --- a/frontend/int/reset/ResetApp.vue +++ b/frontend/int/reset/ResetApp.vue @@ -144,7 +144,7 @@ async function registerPasskey() { } try { - await setSessionCookie(result) + await exchangeCode(result) } catch (error) { loading.value = false const message = error?.message || 'Failed to establish session' @@ -156,15 +156,13 @@ async function registerPasskey() { setTimeout(() => { loading.value = false; goHome() }, 800) } -async function setSessionCookie(result) { - if (!result?.session_token) { - throw new Error('Registration response missing session_token') +async function exchangeCode(result) { + if (!result?.exchange_code) { + throw new Error('Registration response missing exchange_code') } return await apiJson('/auth/api/set-session', { method: 'POST', - headers: { - Authorization: `Bearer ${result.session_token}` - } + headers: { 'Authorization': `Bearer ${result.exchange_code}` } }) } diff --git a/frontend/package.json b/frontend/package.json index bd70b85..89670f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "pinia": "^3.0.3", "qrcode": "^1.5.4", "sirv": "^3.0.2", + "uuidv7": "^1.1.0", "vue": "^3.5.17" }, "devDependencies": { diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index 92faaf9..bcce288 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -2,6 +2,7 @@ import { computed } from 'vue' import Modal from '@/components/Modal.vue' import NameEditForm from '@/components/NameEditForm.vue' +import { useAuthStore } from '@/stores/auth' const props = defineProps({ dialog: Object, @@ -9,10 +10,20 @@ const props = defineProps({ settings: Object }) -const emit = defineEmits(['submitDialog', 'closeDialog']) +const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient']) const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name']) +const NO_SUBMIT_TYPES = new Set([]) const rpId = computed(() => props.settings?.rp_id || 'the configured domain') +const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`) + +// Copy-to-clipboard helper +const authStore = useAuthStore() +function copyText(value, label) { + navigator.clipboard.writeText(value).then(() => { + authStore.showMessage(`${label} copied to clipboard`, 'success', 1500) + }) +}
{{ dialog.error }}
-
@@ -352,13 +351,13 @@ defineExpose({ focusFirstElement }) :key="r.uuid" class="role-column" @dragover="$emit('onRoleDragOver', $event)" - @drop="e => $emit('onRoleDrop', e, selectedOrg, r.uuid)" + @drop="e => $emit('onRoleDrop', e, selectedOrg, r)" >
{{ r.display_name }} - +
diff --git a/frontend/src/admin/AdminOverview.vue b/frontend/src/admin/AdminOverview.vue index aa37b9e..840bad2 100644 --- a/frontend/src/admin/AdminOverview.vue +++ b/frontend/src/admin/AdminOverview.vue @@ -1,16 +1,18 @@ diff --git a/frontend/src/components/HostProfileView.vue b/frontend/src/components/HostProfileView.vue index e76d3a5..eb88626 100644 --- a/frontend/src/components/HostProfileView.vue +++ b/frontend/src/components/HostProfileView.vue @@ -13,6 +13,8 @@ :visits="authStore.userInfo?.visits || 0" :created-at="authStore.userInfo?.created_at" :last-seen="authStore.userInfo?.last_seen" + :email="ctx.user.email" + :telephone="ctx.user.telephone" :org-display-name="orgDisplayName" :role-name="roleDisplayName" :can-edit="false" @@ -78,9 +80,9 @@ const currentHost = window.location.host const userInfoSection = ref(null) const buttonRow = ref(null) -const ctx = computed(() => authStore.userInfo?.ctx || null) -const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '') -const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '') +const ctx = computed(() => authStore.userInfo || null) +const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '') +const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '') const headingTitle = computed(() => { const service = authStore.settings?.rp_name diff --git a/frontend/src/components/Modal.vue b/frontend/src/components/Modal.vue index 9362c49..f59db5e 100644 --- a/frontend/src/components/Modal.vue +++ b/frontend/src/components/Modal.vue @@ -1,12 +1,15 @@ diff --git a/frontend/src/components/RegistrationLinkModal.vue b/frontend/src/components/RegistrationLinkModal.vue index 4a1b234..d4a5fbe 100644 --- a/frontend/src/components/RegistrationLinkModal.vue +++ b/frontend/src/components/RegistrationLinkModal.vue @@ -1,6 +1,7 @@