OAuth2 OpenID Connect provider support etc. #3

Merged
LeoVasanko merged 65 commits from oidconnect into main 2026-02-18 02:40:27 +00:00
71 changed files with 3706 additions and 805 deletions
+1 -1
View File
@@ -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) |
+1 -1
View File
@@ -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, ... }
```
+38 -26
View File
@@ -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"
},
+8 -8
View File
@@ -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"
}
}
+1 -1
View File
@@ -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({
+5 -5
View File
@@ -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 }) => {
+32 -16
View File
@@ -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<void> {
/**
* 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<void> {
// 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<Frame> {
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<Frame> {
* Wait for auth iframe to disappear.
*/
async function waitForAuthIframeHidden(page: Page, timeout = 5000): Promise<void> {
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<void> {
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<void> {
* Click Login button in auth iframe.
*/
async function clickLoginInIframe(page: Page): Promise<void> {
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<void> {
* Click Verify button in auth iframe (for reauth mode).
*/
async function clickVerifyInIframe(page: Page): Promise<void> {
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<void> {
* Click Logout button in auth iframe (for forbidden mode).
*/
async function clickLogoutInIframe(page: Page): Promise<void> {
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}`)
})
+62 -12
View File
@@ -44,7 +44,7 @@ export interface UserInfo {
sign_count: number
is_current_session: boolean
}>
aaguid_info: Record<string, { name: string; icon_light?: string; icon_dark?: string }>
aaguid_info: Record<string, { name: string; icon?: string; icon_dark?: string }>
sessions: Array<{
id: string
credential: string
@@ -193,7 +193,8 @@ export async function registerPasskey(
baseUrl: string,
options: { resetToken?: string; displayName?: string } = {}
): Promise<RegistrationResult> {
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<any>((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<AuthenticationResult> {
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<any>((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<UserInfo> {
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}`,
},
+4 -4
View File
@@ -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, {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -8,7 +8,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["bun-types"]
"types": ["node"]
},
"include": ["tests/**/*.ts", "playwright.config.ts"],
"exclude": ["node_modules"]
+1 -1
View File
@@ -27,7 +27,7 @@
<div class="section">
<h2>API Mode (not leaving the page)</h2>
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
<button onclick="apiCall('/auth/api/user-info', 'POST')">📋 Get User Info</button>
<button onclick="apiCall('/auth/api/user-info', 'GET')">📋 Get User Info</button>
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
<button onclick="logout()">🚪 Logout</button>
+1 -2
View File
@@ -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
+235 -60
View File
@@ -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() {
<div class="section-body admin-section-body">
<div class="admin-panels">
<AdminOverview
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)"
v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
ref="adminOverviewRef"
:info="info"
:orgs="orgs"
:permissions="permissions"
:oidc-clients="oidcClients"
:navigation-disabled="hasActiveModal"
:permission-summary="permissionSummary"
@create-org="createOrg"
@@ -805,6 +961,9 @@ async function submitDialog() {
@open-dialog="openDialog"
@delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay"
@create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient"
@navigate-out="handlePanelNavigateOut"
/>
@@ -846,6 +1005,21 @@ async function submitDialog() {
@on-user-drag-start="onUserDragStart"
/>
<AdminOidcDetail
v-else-if="currentOidcId && editingOidcClient"
ref="adminOidcDetailRef"
:client="editingOidcClient"
:permissions="permissions"
:is-new="editingOidcClient.isNew"
:navigation-disabled="hasActiveModal"
@save="handleOidcSave"
@cancel="handleOidcCancel"
@delete="deleteOidcClient"
@reset-secret="resetOidcSecret"
@create-permission="createPermissionForClient"
@navigate-out="handlePanelNavigateOut"
/>
</div>
</div>
</section>
@@ -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"
/>
</div>
</template>
<style scoped>
.view-admin { padding-bottom: var(--space-3xl); }
.admin-section { margin-top: var(--space-xl); }
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
</style>
+22 -2
View File
@@ -2,6 +2,7 @@
<RestrictedAuth
:mode="authMode"
:remote-auth-token="remoteAuthToken"
:oidc-query-string="oidcQueryString"
@authenticated="handleAuthenticated"
@back="handleBack"
/>
@@ -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
})
}
+2 -2
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="en" style="background: transparent">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>{let t=localStorage.getItem('paskia-theme');if(t!=='light'&&t!=='dark')t=new URLSearchParams(location.hash.slice(1)).get('theme');(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')}</script>
<script>{let t=localStorage.getItem('paskia-theme');if(t!=='light'&&t!=='dark')t=new URLSearchParams(location.hash.slice(1)).get('theme');(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>
+5 -7
View File
@@ -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}` }
})
}
+1
View File
@@ -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": {
+35 -5
View File
@@ -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)
})
}
</script>
<template>
@@ -25,6 +36,7 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -76,19 +88,19 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
<input ref="displayNameInput" v-model="dialog.data.display_name" required />
</label>
<label>Permission Scope
<input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
<input v-model="dialog.data.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
</label>
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
<label>Domain Scope
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" />
<input v-model="dialog.data.domain" data-form-type="other" />
</label>
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
</template>
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-secondary"
@@ -105,10 +117,28 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button>
</div>
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
<button
type="button"
class="btn-primary"
@click="$emit('closeDialog')"
>
Close
</button>
</div>
</form>
</Modal>
</template>
<style scoped>
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; }
</style>
+283
View File
@@ -0,0 +1,283 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
client: Object,
permissions: Array,
isNew: { type: Boolean, default: false },
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['save', 'cancel', 'delete', 'resetSecret', 'createPermission', 'navigateOut'])
const authStore = useAuthStore()
const headerRef = ref(null)
// Helper function to build URLs
function authSitePath(path) {
const url = new URL(authStore.settings.auth_site_url)
url.pathname = path
return url.toString()
}
// Local form state
const name = ref('')
const redirectUris = ref('')
const clientSecret = ref(null)
// Computed
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
const discoveryUrl = computed(() => authSitePath('/.well-known/openid-configuration'))
const iconUrl = computed(() => authSitePath('/favicon.ico'))
// Groups (permissions) scoped to this client
const clientGroups = computed(() => {
if (!props.client || !props.permissions) return []
const clientUuid = props.client.uuid || props.client.client_id
return props.permissions.filter(p => p.domain === clientUuid).sort((a, b) => a.scope.localeCompare(b.scope))
})
// Initialize form data from props
watch(() => props.client, (c) => {
if (c) {
name.value = c.name || ''
redirectUris.value = Array.isArray(c.redirect_uris) ? c.redirect_uris.join('\n') : (c.redirect_uris || '')
clientSecret.value = c.client_secret || null
}
}, { immediate: true })
// Copy-to-clipboard helper
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
function handleResetSecret() {
emit('resetSecret', clientId.value)
}
// When parent resets secret, update local state
watch(() => props.client?.client_secret, (newSecret) => {
if (newSecret) {
clientSecret.value = newSecret
}
})
function handleSave() {
const trimmedName = name.value.trim()
if (!trimmedName) {
authStore.showMessage('Client name is required', 'error')
return
}
const uris = redirectUris.value.trim()
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
emit('save', {
client_id: clientId.value,
client_secret: clientSecret.value,
name: trimmedName,
redirect_uris,
isNew: props.isNew
})
}
function handleDelete() {
emit('delete', props.client)
}
function handleCreatePermission() {
emit('createPermission', clientId.value)
}
function handleCancel() {
emit('cancel')
}
// Keyboard navigation
function handleHeaderKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(headerRef.value, event.target, direction, { itemSelector: 'button, a' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
}
}
function focusFirstElement() {
const firstFocusable = headerRef.value?.querySelector('button, a, input')
if (firstFocusable) firstFocusable.focus()
}
defineExpose({ focusFirstElement })
</script>
<template>
<div class="oidc-detail">
<form @submit.prevent="handleSave" class="oidc-form">
<!-- Client credentials section -->
<section class="section-block">
<div class="section-header">
<h2>Client Configuration</h2>
<p class="section-description">Configure these values in the client application.</p>
</div>
<div class="section-body">
<dl class="oidc-dl">
<dt>Authentication Name</dt>
<dd>
<output @click="copyText(authStore.settings.rp_name, 'Authentication Name')" title="Click to copy">{{ authStore.settings.rp_name }}</output>
<span class="small muted"> (Login With, may affect URLs optional)</span>
</dd>
<dt>Client ID</dt>
<dd><output @click="copyText(clientId, 'Client ID')" title="Click to copy">{{ clientId }}</output></dd>
<dt>Client Secret <button v-if="!clientSecret" type="button" class="icon-btn" @click="handleResetSecret" title="Revoke and re-generate secret">🔄</button></dt>
<dd>
<output v-if="clientSecret" @click="copyText(clientSecret, 'Client Secret')" title="Click to copy">{{ clientSecret }}</output>
<span v-else class="small muted">(only stored in hashed form)</span>
</dd>
<dt>Auto Discovery URL</dt>
<dd><output @click="copyText(discoveryUrl, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ discoveryUrl }}</output></dd>
<dt>Icon URL</dt>
<dd>
<output @click="copyText(iconUrl, 'Icon URL')" title="Click to copy">{{ iconUrl }}</output>
<span class="small muted"> (optional)</span>
</dd>
<template v-if="clientGroups.length">
<dt>Groups Claim Name</dt>
<dd>
<output @click="copyText('groups', 'Groups Claim Name')" title="Click to copy">groups</output>
</dd>
</template>
<dt>Groups <button type="button" class="icon-btn" @click="handleCreatePermission" title="Add permission scoped to this client"></button></dt>
<dd class="oidc-groups">
<template v-if="clientGroups.length">
<output v-for="group in clientGroups" :key="group.uuid" class="oidc-group" @click="copyText(group.scope, 'Group Value')" :title="group.display_name">{{ group.scope }}</output>
</template>
<span v-else class="small muted">(no permissions defined)</span>
</dd>
</dl>
<span class="warning-text">
<strong v-if="clientSecret"> {{ isNew ? 'Save the secret now it cannot be retrieved later.' : 'Saving will prevent access with the old secret.' }}</strong>
<span v-else> The client may use groups to check for required permissions.</span>
</span>
</div>
</section>
<!-- Editable fields -->
<section class="section-block">
<div class="section-header">
<h2>Paskia Configuration</h2>
</div>
<div class="section-body">
<label>Client Name
<input v-model="name" required />
</label>
<label>Redirect URIs
<p class="small muted">This should be provided by the client application.</p>
<textarea v-model="redirectUris" placeholder="(autodiscover one on first use)" rows="3"></textarea>
</label>
</div>
</section>
<!-- Actions -->
<div class="oidc-actions">
<button type="button" class="btn-secondary" @click="handleCancel">Cancel</button>
<button v-if="!isNew" type="button" class="btn-danger" @click="handleDelete">Delete Client</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
</template>
<style scoped>
.oidc-detail {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.oidc-header {
margin-bottom: 0;
}
.oidc-header h2 {
margin: 0;
}
.oidc-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.oidc-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 1rem;
align-items: baseline;
margin: var(--space-sm) 0;
}
.oidc-dl dt {
font-size: 0.85rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.oidc-dl dd {
margin: 0;
overflow: hidden;
display: flex;
align-items: baseline;
gap: 0.5em;
}
.oidc-dl output {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.warning-text {
display: block;
font-size: 0.9rem;
min-height: 1.4em;
}
.oidc-group { display: block; }
.oidc-group { white-space: normal; word-break: break-all; }
.section-body label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
.oidc-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
margin-top: var(--space-md);
}
</style>
+23 -24
View File
@@ -16,16 +16,22 @@ const permMatrixRef = ref(null)
const rolesGridRef = ref(null)
const sortedRoles = computed(() => {
return Object.entries(props.selectedOrg.roles)
.map(([uuid, r]) => ({ uuid, ...r }))
.sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) {
return nameA.localeCompare(nameB)
}
return a.uuid.localeCompare(b.uuid)
})
// o.roles is dict[UUID, Role], convert to array for sorting with uuid added
return Object.entries(props.selectedOrg.roles).map(([uuid, r]) => ({ uuid, ...r })).sort((a, b) => {
const nameA = a.display_name.toLowerCase()
const nameB = b.display_name.toLowerCase()
if (nameA !== nameB) {
return nameA.localeCompare(nameB)
}
return a.uuid.localeCompare(b.uuid)
})
})
// Get org's grantable permissions as full permission objects (with UUIDs)
const orgPermissions = computed(() => {
// props.selectedOrg.permissions is dict[UUID, Permission]
const uuidSet = new Set(Object.keys(props.selectedOrg.permissions || {}))
return props.permissions.filter(p => uuidSet.has(p.uuid))
})
// Get users for a role as sorted array of { uuid, ...user }
@@ -44,19 +50,12 @@ function roleUserCount(roleUuid) {
return Object.values(props.selectedOrg.users).filter(u => u.role === roleUuid).length
}
// Get org's grantable permissions as full permission objects (with UUIDs)
const orgPermissions = computed(() => {
return Object.entries(props.selectedOrg.permissions)
.map(([uuid, p]) => ({ uuid, ...p }))
.sort((a, b) => a.scope.localeCompare(b.scope))
})
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function toggleRolePermission(roleUuid, role, pid, checked) {
emit('toggleRolePermission', roleUuid, role, pid, checked)
function toggleRolePermission(role, pid, checked) {
emit('toggleRolePermission', role, pid, checked)
}
// Handle org title header keynav
@@ -102,7 +101,7 @@ function handleMatrixKeydown(event) {
// Calculate grid dimensions
const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length
const rows = Object.keys(props.selectedOrg.permissions).length
const currentRow = Math.floor(currentIndex / cols)
const currentCol = currentIndex % cols
@@ -238,7 +237,7 @@ function handleRoleHeaderKeydown(event, roleIndex) {
if (checkboxes?.length) {
// Focus the checkbox in the corresponding column
const cols = sortedRoles.value.length
const rows = props.selectedOrg.permissions.length
const rows = Object.keys(props.selectedOrg.permissions).length
const targetIndex = (rows - 1) * cols + roleIndex
if (checkboxes[targetIndex]) checkboxes[targetIndex].focus()
else checkboxes[checkboxes.length - 1].focus()
@@ -337,7 +336,7 @@ defineExpose({ focusFirstElement })
<input
type="checkbox"
:checked="p.uuid in (r.permissions || {})"
@change="e => toggleRolePermission(r.uuid, r, p.uuid, e.target.checked)"
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
/>
</div>
<div class="matrix-cell add-role-cell" />
@@ -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)"
>
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
<strong class="role-name" :title="r.uuid">
<span>{{ r.display_name }}</span>
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role"></button>
<button v-if="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r.uuid, r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role"></button>
<button v-if="roleUserCount(r.uuid) === 0" @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role"></button>
</strong>
<div class="role-actions">
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user"></button>
+86 -2
View File
@@ -1,16 +1,18 @@
<script setup>
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
info: Object,
orgs: Array,
permissions: Array,
oidcClients: Array,
permissionSummary: Object,
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut'])
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'navigateOut'])
// Template refs for navigation
const orgSection = ref(null)
@@ -19,11 +21,41 @@ const orgTableRef = ref(null)
const permMatrixRef = ref(null)
const permActionsRef = ref(null)
const permTableRef = ref(null)
const oidcActionsRef = ref(null)
const oidcTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
}))
// Map OIDC client UUIDs to display names for permission domain column
const oidcClientNames = computed(() => {
const map = {}
for (const c of props.oidcClients || []) map[c.uuid] = c.name
return map
})
function domainDisplay(domain) {
if (!domain) return '—'
return oidcClientNames.value[domain] || domain
}
// Map OIDC client UUIDs to their group permissions (sorted by scope)
const clientGroups = computed(() => {
const map = {}
for (const p of props.permissions || []) {
if (p.domain) {
if (!map[p.domain]) map[p.domain] = []
map[p.domain].push(p)
}
}
// Sort each group array by scope
for (const key in map) {
map[key].sort((a, b) => a.scope.localeCompare(b.scope))
}
return map
})
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
// Derive admin status from permissions (info contains ctx from validate response)
@@ -35,6 +67,7 @@ function permissionDisplayName(scope) {
}
function getRoleNames(org) {
// org.roles is dict[UUID, Role]
return Object.values(org.roles)
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
@@ -343,7 +376,7 @@ defineExpose({ focusFirstElement })
<span class="id-text">{{ p.scope }}</span>
</div>
</td>
<td class="perm-domain">{{ p.domain || '—' }}</td>
<td class="perm-domain">{{ domainDisplay(p.domain) }}</td>
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
<td class="perm-actions center">
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
@@ -352,6 +385,52 @@ defineExpose({ focusFirstElement })
</tbody>
</table>
</div>
<div v-if="isMasterAdmin" class="oidc-clients-section">
<div class="section-header">
<h2>OAuth2 / OpenID Connect</h2>
<p class="section-description">
Allow external websites and applications to securely authenticate users through this system.
The clients are remote sites or applications that we allow to use Paskia for Single Sign-On.
</p>
</div>
<div ref="oidcActionsRef">
<button @click="$emit('createOidcClient')">+ Add Site</button>
</div>
<table class="org-table" ref="oidcTableRef">
<thead>
<tr>
<th scope="col">Client</th>
<th scope="col">Groups</th>
<th scope="col" class="center">Sessions</th>
<th scope="col" class="center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="!oidcClients || oidcClients.length === 0">
<td colspan="4" class="center muted">No OIDC clients configured</td>
</tr>
<tr v-for="client in oidcClients" :key="client.uuid">
<td class="perm-name-cell">
<div class="perm-title">
<a :href="'#oidc:' + client.uuid" @click.prevent="$emit('openOidcClient', client)">{{ client.name }}</a>
</div>
<div class="perm-id-info">
<span class="id-text">{{ client.uuid }}</span>
</div>
</td>
<td class="client-groups">
<span v-if="clientGroups[client.uuid]?.length">{{ clientGroups[client.uuid].map(g => g.scope).join(' ') }}</span>
<span v-else class="muted"></span>
</td>
<td class="center">{{ client.active_sessions || 0 }}</td>
<td class="center">
<button @click="$emit('deleteOidcClient', client)" class="icon-btn delete-icon" aria-label="Delete OIDC client" title="Delete OIDC client"></button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
@@ -371,4 +450,9 @@ defineExpose({ focusFirstElement })
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
.perm-actions { text-align: center; }
/* OIDC Clients Section */
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
</style>
+42 -18
View File
@@ -20,9 +20,15 @@ const props = defineProps({
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
const authStore = useAuthStore()
const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
// Convert credentials dict to array with uuid attached as 'credential'
const credentials = computed(() =>
Object.entries(props.userDetail?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
// Template refs for navigation
const userInfoRef = ref(null)
const regActionsRef = ref(null)
@@ -44,7 +50,7 @@ function handleEditName() {
async function handleDelete(credential) {
try {
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.uuid}`, { method: 'DELETE' })
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
if (data.status === 'ok') {
emit('onUserNameSaved') // Reuse to refresh user detail
} else {
@@ -56,15 +62,29 @@ async function handleDelete(credential) {
}
async function handleTerminateSession(session) {
const credentialUuid = session?.credential
if (!credentialUuid) return
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try {
await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credentialUuid}`, { method: 'DELETE' })
emit('refreshUserDetail')
authStore.showMessage('Credential deleted', 'success', 2500)
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionKey}`, { method: 'DELETE' })
if (data.status === 'ok') {
if (data.current_session_terminated) {
sessionStorage.clear()
location.reload()
return
}
emit('refreshUserDetail') // Refresh without showing rename message
authStore.showMessage('Session terminated', 'success', 2500)
} else {
authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
}
} catch (err) {
console.error('Delete credential error', err)
authStore.showMessage(err.message || 'Failed to delete credential', 'error')
console.error('Terminate session error', err)
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally {
const next = { ...terminatingSessions.value }
delete next[sessionKey]
terminatingSessions.value = next
}
}
@@ -165,23 +185,26 @@ defineExpose({ focusFirstElement })
<div ref="userInfoRef" @keydown="handleUserInfoKeydown">
<UserBasicInfo
v-if="userDetail && !userDetail.error"
:name="userDetail.user.display_name"
:name="userDetail.user.display_name || selectedUser.display_name"
:visits="userDetail.user.visits"
:created-at="userDetail.user.created_at"
:last-seen="userDetail.user.last_seen"
:email="userDetail.user.email"
:telephone="userDetail.user.telephone"
:loading="loading"
:org-display-name="selectedOrg?.org?.display_name"
:role-name="selectedUser?.role_display_name"
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/display-name`"
:org-display-name="userDetail.org.display_name"
:role-name="userDetail.role.display_name"
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
@saved="$emit('onUserNameSaved')"
@edit-name="handleEditName"
@edit="handleEditName"
>
<div class="admin-actions">
<button
class="btn-primary"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
>{{ Object.keys(userDetail?.credentials || {}).length ? 'Recovery Link' : 'Registration Link' }}</button>
title="Generate a one-time link for this user"
>{{ userDetail?.credentials && Object.keys(userDetail.credentials).length > 0 ? 'Recovery Link' : 'Registration Link' }}</button>
<button
class="btn-danger"
@click="handleDeleteUser"
@@ -200,11 +223,11 @@ defineExpose({ focusFirstElement })
<div class="section-body">
<CredentialList
ref="credentialListRef"
:credentials="Object.entries(userDetail.credentials).map(([uuid, c]) => ({ ...c, uuid })).sort((a, b) => new Date(a.created_at) - new Date(b.created_at))"
:credentials="credentials"
:aaguid-info="userDetail.aaguid_info"
:allow-delete="true"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSession?.credential || null"
:hovered-session-credential-uuid="hoveredSession?.credential"
:navigation-disabled="hasActiveModal"
@delete="handleDelete"
@credential-hover="hoveredCredentialUuid = $event"
@@ -214,7 +237,8 @@ defineExpose({ focusFirstElement })
</section>
<SessionList
ref="sessionListRef"
:sessions="userDetail.sessions"
:sessions="userDetail.sessions || {}"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
:empty-message="'This user has no active sessions.'"
@@ -230,7 +254,7 @@ defineExpose({ focusFirstElement })
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
:user-name="userDetail?.user.display_name || selectedUser.display_name"
:user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')"
@copied="onLinkCopied"
/>
+20 -2
View File
@@ -279,6 +279,10 @@ button:disabled {
filter: opacity(0.6);
}
output[title="Click to copy"] {
cursor: pointer;
}
.btn-primary {
background: linear-gradient(to bottom, oklab(1 0 0 / 0.15), transparent 60%) var(--color-accent);
color: var(--color-accent-contrast);
@@ -317,9 +321,11 @@ button:disabled {
box-shadow: var(--shadow-soft);
}
input:not([type]),
input[type="text"],
input[type="search"],
input[type="email"],
input[type="tel"],
textarea,
select {
font: inherit;
@@ -332,6 +338,18 @@ select {
transition: border-color var(--transition-base), box-shadow var(--transition-base);
}
input:not([type]):focus,
input[type="text"]:focus,
input[type="search"]:focus,
input[type="email"]:focus,
input[type="tel"]:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: var(--focus-ring);
}
label {
display: flex;
flex-direction: column;
@@ -502,7 +520,7 @@ th {
left: 0;
right: 0;
margin: 0 auto;
z-index: 1200;
z-index: 2000;
width: fit-content;
min-width: min(520px, calc(100% - 2rem));
max-width: calc(100% - 2rem);
@@ -807,7 +825,7 @@ th {
display: grid;
border-radius: var(--radius-md);
background: var(--color-surface);
padding: 1.1rem 1.25rem;
padding: 1rem;
}
.user-details {
+9 -9
View File
@@ -5,16 +5,16 @@
<template v-else>
<div
v-for="credential in credentials"
:key="credential.uuid"
:key="credential.credential"
:class="['credential-item', {
'current-session': credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid,
'is-hovered': hoveredCredentialUuid === credential.uuid,
'is-linked-session': hoveredSessionCredentialUuid === credential.uuid
'is-hovered': hoveredCredentialUuid === credential.credential,
'is-linked-session': hoveredSessionCredentialUuid === credential.credential
}]"
tabindex="-1"
@mousedown.prevent
@click.capture="handleCardClick"
@focusin="handleCredentialFocus(credential.uuid)"
@focusin="handleCredentialFocus(credential.credential)"
@focusout="handleCredentialBlur($event)"
@keydown="handleItemKeydown($event, credential)"
>
@@ -33,8 +33,8 @@
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
<div class="item-actions">
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredCredentialUuid === credential.uuid" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSessionCredentialUuid === credential.uuid" class="badge badge-current">Linked</span>
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
<button
v-if="allowDelete"
@click="$emit('delete', credential)"
@@ -147,9 +147,9 @@ const getCredentialAuthName = (credential) => {
const getCredentialAuthIcon = (credential) => {
const info = props.aaguidInfo?.[credential.aaguid]
if (!info) return null
const isDarkMode = document.documentElement.classList.contains('dark')
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
return info[iconKey] || info.icon || null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
// Fall back to icon if icon_dark is not available
return (isDarkMode && info.icon_dark) || info.icon || null
}
</script>
+5 -3
View File
@@ -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
+27 -44
View File
@@ -1,12 +1,15 @@
<template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<slot />
</dialog>
<div class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
<slot />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const props = defineProps({
// Optional: provide a fallback element to focus if original element is gone
@@ -17,7 +20,7 @@ const props = defineProps({
focusSiblingSelector: { type: String, default: '' }
})
defineEmits(['close'])
const emit = defineEmits(['close'])
// Dialog element reference
const dialog = ref(null)
@@ -76,6 +79,13 @@ const restoreFocus = () => {
}
const handleDialogKeydown = (event) => {
// ESC to close (previously handled by <dialog> natively)
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
const direction = getDirection(event)
if (!direction) return
@@ -111,11 +121,11 @@ onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Show the dialog as a modal
holdGlobalBackdrop()
// Focus the most appropriate element
nextTick(() => {
if (dialog.value) {
dialog.value.showModal()
// Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback)
@@ -131,14 +141,16 @@ onMounted(() => {
})
onUnmounted(() => {
releaseGlobalBackdrop()
// Restore focus when modal closes
restoreFocus()
})
</script>
<style scoped>
dialog {
background: var(--color-surface);
.modal-panel {
background: var(--color-dialog);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
@@ -147,65 +159,36 @@ dialog {
width: min(500px, 90vw);
max-height: 90vh;
overflow-y: auto;
position: fixed;
inset: 0;
margin: auto;
height: fit-content;
}
dialog::backdrop {
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
dialog :deep(.modal-title),
dialog :deep(h3) {
.modal-panel :deep(.modal-title),
.modal-panel :deep(h3) {
margin: 0 0 var(--space-md);
font-size: 1.25rem;
font-weight: 600;
color: var(--color-heading);
}
dialog :deep(form) {
.modal-panel :deep(form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
dialog :deep(.modal-form) {
.modal-panel :deep(.modal-form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
dialog :deep(.modal-form label) {
.modal-panel :deep(.modal-form label) {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
dialog :deep(.modal-form input),
dialog :deep(.modal-form textarea) {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 1rem;
line-height: 1.4;
min-height: 2.5rem;
}
dialog :deep(.modal-form input:focus),
dialog :deep(.modal-form textarea:focus) {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px #c7d2fe;
}
dialog :deep(.modal-actions) {
.modal-panel :deep(.modal-actions) {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
+92 -50
View File
@@ -6,7 +6,7 @@
</div>
<header class="view-header">
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
<p class="view-lede">Account dashboard to manage your profile and authentications.</p>
</header>
</div>
@@ -15,13 +15,18 @@
v-if="authStore.userInfo?.user"
ref="userBasicInfo"
:name="authStore.userInfo.user.display_name"
:email="authStore.userInfo.user.email"
:preferred_username="authStore.userInfo.user.preferred_username"
:telephone="authStore.userInfo.user.telephone"
:visits="authStore.userInfo.user.visits"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name"
:org-display-name="authStore.ctx?.org.display_name"
:role-name="authStore.ctx?.role.display_name"
update-endpoint="/auth/api/user/info"
@saved="authStore.loadUserInfo()"
@edit-name="openNameDialog"
@edit="openEditDialog"
@keydown="handleUserInfoKeydown"
>
<div class="remote-auth-inline">
@@ -35,7 +40,7 @@
@device-info-visible="showDeviceInfo = $event"
/>
</div>
<p class="remote-auth-description">Provided by another device requesting remote auth.</p>
<p class="remote-auth-description">Login from another device</p>
</UserBasicInfo>
</section>
@@ -51,7 +56,7 @@
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
:loading="authStore.isLoading"
:hovered-credential-uuid="hoveredCredentialUuid"
:hovered-session-credential-uuid="hoveredSessionCredential"
:hovered-session-credential-uuid="hoveredSession?.credential"
:navigation-disabled="hasActiveModal"
allow-delete
@delete="handleDelete"
@@ -68,11 +73,12 @@
<SessionList
ref="sessionList"
:sessions="sessions"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
:section-class="useWideLayout ? '' : 'section-block--constrained'"
@terminate="terminateSession"
@session-hover="handleSessionHover"
@session-hover="hoveredSession = $event"
@navigate-out="handleSessionNavigateOut"
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
/>
@@ -100,15 +106,28 @@
</div>
</section>
<Modal v-if="showNameDialog" @close="showNameDialog = false">
<h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form">
<NameEditForm
label="Display Name"
v-model="newName"
:busy="saving"
@cancel="showNameDialog = false"
/>
<Modal v-if="showEditDialog" @close="showEditDialog = false">
<h3>Edit Profile</h3>
<form @submit.prevent="saveProfile" class="modal-form">
<div class="profile-edit-form">
<label for="edit-display-name">Display Name
<input id="edit-display-name" type="text" v-model="editName" :disabled="saving" required />
</label>
<label for="edit-email">Email
<input id="edit-email" type="email" v-model="editEmail" :disabled="saving" />
</label>
<label for="edit-username">Preferred Username
<input id="edit-username" type="text" v-model="editUsername" :disabled="saving" placeholder="username" />
</label>
<label for="edit-telephone">Telephone
<input id="edit-telephone" type="tel" v-model="editTelephone" :disabled="saving" />
</label>
<div v-if="editError" class="error small">{{ editError }}</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" @click="showEditDialog = false" :disabled="saving">Cancel</button>
<button type="submit" class="btn-primary" :disabled="saving" data-nav-primary>Save</button>
</div>
</div>
</form>
</Modal>
@@ -128,7 +147,6 @@ import CredentialList from '@/components/CredentialList.vue'
import ThemeSelector from '@/components/ThemeSelector.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
@@ -141,13 +159,16 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
const authStore = useAuthStore()
const updateInterval = ref(null)
const showNameDialog = ref(false)
const showEditDialog = ref(false)
const showRegLink = ref(false)
const newName = ref('')
const editName = ref('')
const editEmail = ref('')
const editUsername = ref('')
const editTelephone = ref('')
const saving = ref(false)
const editError = ref('')
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const hoveredSessionCredential = ref(null)
const showDeviceInfo = ref(false)
const pairingEntry = ref(null)
const credentialList = ref(null)
@@ -159,9 +180,17 @@ const userBasicInfo = ref(null)
const userInfoSection = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' })
watch(showEditDialog, (open) => {
if (!open) return
const user = authStore.userInfo?.user
editName.value = user?.display_name ?? ''
editEmail.value = user?.email ?? ''
editUsername.value = user?.preferred_username ?? ''
editTelephone.value = user?.telephone ?? ''
editError.value = ''
})
onMounted(() => {
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
@@ -169,11 +198,6 @@ onMounted(() => {
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
const handleSessionHover = (session) => {
hoveredSession.value = session
hoveredSessionCredential.value = session?.credential || null
}
const addNewCredential = async () => {
try {
await passkey.register(null, null, () => {
@@ -307,7 +331,7 @@ const handleLogoutButtonKeydown = (event) => {
}
const handleDelete = async (credential) => {
const credentialId = credential?.uuid
const credentialId = credential?.credential
if (!credentialId) return
try {
await authStore.deleteCredential(credentialId)
@@ -317,37 +341,41 @@ const handleDelete = async (credential) => {
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
const paskiaVersion = computed(() => authStore.settings?.version || '')
const credentials = computed(() => {
const creds = authStore.userInfo?.credentials || {}
return Object.entries(creds).map(([uuid, c]) => ({ ...c, uuid })).sort((a, b) => new Date(a.created_at) - new Date(b.created_at))
})
const sessions = computed(() => authStore.userInfo?.sessions || [])
const sessions = computed(() => authStore.userInfo?.sessions || {})
const currentSessionHost = computed(() => {
const currentSession = sessions.value.find(session => session.is_current)
const currentSession = Object.values(sessions.value).find(session => session.is_current)
return currentSession?.host || 'this host'
})
const terminatingSessions = ref({})
const terminateSession = async (session) => {
if (session.is_current) {
await logout()
} else {
try { await authStore.deleteCredential(session.credential) }
catch (error) { authStore.showMessage(error.message || 'Failed to delete credential', 'error', 5000) }
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try { await authStore.terminateSession(sessionKey) }
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
finally {
const next = { ...terminatingSessions.value }
delete next[sessionKey]
terminatingSessions.value = next
}
}
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
const logout = async () => { await authStore.logout() }
const openNameDialog = () => { newName.value = authStore.userInfo?.user.display_name ?? ''; showNameDialog.value = true }
const openEditDialog = () => { showEditDialog.value = true }
const isAdmin = computed(() => {
const perms = authStore.ctx?.permissions
return perms?.includes('auth:admin') || perms?.includes('auth:org:admin')
})
const hasMultipleSessions = computed(() => sessions.value.length > 1)
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
const credentials = computed(() =>
Object.entries(authStore.userInfo?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
)
const useWideLayout = computed(() => {
// Check if any single site has more than 8 sessions
const groups = {}
for (const session of sessions.value) {
for (const session of Object.values(sessions.value)) {
const host = session.host || ''
if (!groups[host]) groups[host] = []
groups[host].push(session)
@@ -361,17 +389,30 @@ const useWideLayout = computed(() => {
})
const breadcrumbEntries = computed(() => { const entries = [{ label: 'My Profile', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const saveName = async () => {
const name = newName.value.trim()
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
const saveProfile = async () => {
const name = editName.value.trim()
if (!name) { editError.value = 'Name cannot be empty'; return }
const user = authStore.userInfo.user
const emailVal = editEmail.value.trim() || null
const usernameVal = editUsername.value.trim() || null
const telephoneVal = editTelephone.value.trim() || null
try {
editError.value = ''
saving.value = true
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })
showNameDialog.value = false
await authStore.loadUserInfo()
authStore.showMessage('Name updated successfully!', 'success', 3000)
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
finally { saving.value = false }
const body = {}
if (name !== user.display_name) body.display_name = name
if (emailVal !== (user.email || null)) body.email = emailVal
if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
if (Object.keys(body).length) {
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
await authStore.loadUserInfo()
authStore.showMessage('Profile updated!', 'success', 3000)
}
showEditDialog.value = false
} catch (e) {
editError.value = e.message || 'Failed to update profile'
} finally { saving.value = false }
}
</script>
@@ -386,4 +427,5 @@ const saveName = async () => {
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
.profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
</style>
@@ -1,6 +1,7 @@
<template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div v-if="linkUrl" class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div class="reg-header-row">
<h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">{{ tokenType === 'account recovery' ? 'Recovery' : 'Registration' }} for {{ userName }}</span><span v-else>Add Another Device</span>
@@ -14,7 +15,6 @@
</p>
<QRCodeDisplay
v-if="linkUrl"
:url="linkUrl"
:show-link="true"
@copied="onCopied"
@@ -29,14 +29,15 @@
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
<button class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div>
</div>
</dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from 'paskia'
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
@@ -78,16 +79,13 @@ async function generateLink() {
expiresAt.value = data.expires ? new Date(data.expires) : null
tokenType.value = data.token_type || null
// Show the dialog as modal
await nextTick()
if (dialog.value) {
dialog.value.showModal()
holdGlobalBackdrop()
// Focus primary button (or first button if no primary) after content renders
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
}
// Focus primary button (or first button if no primary) after content renders
await nextTick()
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
} else {
emit('close')
}
@@ -102,7 +100,12 @@ function onCopied() {
}
const handleDialogKeydown = (event) => {
// ESC is handled automatically by <dialog>
// ESC to close
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
// Handle other key navigation
const direction = getDirection(event)
if (!direction) return
@@ -148,6 +151,7 @@ onMounted(() => {
})
onUnmounted(() => {
if (linkUrl.value) releaseGlobalBackdrop()
// Restore focus when modal closes
const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) {
@@ -157,23 +161,6 @@ onUnmounted(() => {
</script>
<style scoped>
dialog {
border: none;
background: transparent;
padding: 0;
max-width: none;
width: fit-content;
height: fit-content;
position: fixed;
inset: 0;
margin: auto;
}
dialog::backdrop {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
}
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity: 1; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
@@ -771,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
.input-wrapper {
position: relative;
display: flex;
width: 280px;
max-width: 100%;
}
@@ -196,7 +196,7 @@ async function startRemoteAuth() {
} else if (msg.status === 'authenticated') {
// Success
completed.value = true
emit('authenticated', { session_token: msg.session_token })
emit('authenticated', { exchange_code: msg.exchange_code })
break
} else if (msg.status === 'denied') {
// Explicitly denied by the authenticating device
+25 -10
View File
@@ -66,7 +66,11 @@ const props = defineProps({
mode: {
type: String,
default: 'login',
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
validator: (value) => ['login', 'reauth', 'forbidden', 'oidc'].includes(value)
},
oidcQueryString: {
type: String,
default: null
}
})
@@ -163,7 +167,7 @@ async function authenticateUser() {
loading.value = true
showMessage('Starting authentication…', 'info')
let result
try { result = await passkey.authenticate() } catch (error) {
try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) {
loading.value = false
const message = error?.message || 'Passkey authentication cancelled'
const cancelled = message === 'Passkey authentication cancelled'
@@ -171,7 +175,13 @@ async function authenticateUser() {
emit('auth-error', { message, cancelled })
return
}
try { await setSessionCookie(result) } catch (error) {
// OIDC flow: no session cookie, just emit the redirect_url
if (result.redirect_url) {
loading.value = false
emit('authenticated', result)
return
}
try { await exchangeCode(result) } catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
@@ -202,13 +212,13 @@ function openProfile() {
if (profileWindow) profileWindow.focus()
}
async function setSessionCookie(result) {
if (!result?.session_token) {
console.error('setSessionCookie called with missing session_token:', result)
throw new Error('Authentication response missing session_token')
async function exchangeCode(result) {
if (!result?.exchange_code) {
console.error('exchangeCode called with missing exchange_code:', result)
throw new Error('Authentication response missing exchange_code')
}
return await fetchJson('/auth/api/set-session', {
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` }
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }
})
}
@@ -223,7 +233,7 @@ function switchToLocal() {
async function handleRemoteAuthenticated(result) {
showMessage('Authenticated from another device!', 'success', 2000)
try {
await setSessionCookie(result)
await exchangeCode(result)
} catch (error) {
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
@@ -265,7 +275,12 @@ watch(initializing, (newVal) => {
onMounted(async () => {
await fetchSettings()
await validateSession()
// OIDC mode doesn't depend on session state - skip validation
if (props.mode !== 'oidc') {
await validateSession()
} else {
currentView.value = 'login'
}
initializing.value = false
// Add click handler for inline links
+34 -31
View File
@@ -6,20 +6,21 @@
</div>
<div class="section-body">
<div>
<template v-if="Array.isArray(sessions) && sessions.length">
<div v-for="(group, host) in groupedSessions" :key="host" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, host)">
<template v-if="sessionsArray.length">
<div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
<span class="session-group-icon">🌐</span>
<a v-if="host" :href="hostUrl(host)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ host }}</a>
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
<template v-if="group.isOIDC">{{ group.displayName }}</template>
<a v-else-if="key" :href="hostUrl(key)" tabindex="-1" target="_blank" rel="noopener noreferrer">{{ key }}</a>
<template v-else>Unbound host</template>
</span>
<div class="session-list">
<div
v-for="(session, index) in group.sessions"
:key="index"
v-for="session in group.sessions"
:key="session.key"
:class="['session-item', {
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
'is-hovered': hoveredSession === session,
'is-hovered': hoveredSession?.key === session.key,
'is-linked-credential': hoveredCredentialUuid === session.credential
}]"
tabindex="-1"
@@ -33,13 +34,14 @@
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
<div class="item-actions">
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredSession === session" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSession?.key === session.key" class="badge badge-current">Selected</span>
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
<button
@click="$emit('terminate', session)"
class="btn-card-delete"
:title="'Delete associated passkey'"
:disabled="isTerminating(session.key)"
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
tabindex="-1"
></button>
</div>
@@ -68,9 +70,10 @@ import { hostIP } from '@/utils/helpers'
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
const props = defineProps({
sessions: { type: Array, default: () => [] },
sessions: { type: Object, default: () => ({}) },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) },
hoveredCredentialUuid: { type: String, default: null },
navigationDisabled: { type: Boolean, default: false },
sectionClass: { type: String, default: '' },
@@ -105,6 +108,8 @@ const handleCardClick = (event) => {
}
}
const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
const handleGroupKeydown = (event, host) => {
const group = event.currentTarget
const sessionList = group.querySelector('.session-list')
@@ -146,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal)
handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session)
if (!isTerminating(session.key)) emit('terminate', session)
})
if (event.defaultPrevented) return
@@ -205,9 +210,14 @@ const copyIp = async (ip) => {
const displayIp = ip => hostIP(ip) ?? ip
// Convert sessions dict to array with key attached
const sessionsArray = computed(() =>
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
)
const currentHostIP = computed(() => {
if (hoveredIp.value) return hostIP(hoveredIp.value)
const current = props.sessions.find(s => s.is_current)
const current = sessionsArray.value.find(s => s.is_current)
return current ? hostIP(current.ip) : null
})
@@ -215,27 +225,20 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
const groupedSessions = computed(() => {
const groups = {}
for (const session of props.sessions) {
const host = session.host || ''
if (!groups[host]) {
groups[host] = { sessions: [], isCurrentSite: false }
}
groups[host].sessions.push(session)
if (session.is_current_host) {
groups[host].isCurrentSite = true
for (const session of sessionsArray.value) {
const groupKey = session.client || session.host || ''
if (!groups[groupKey]) {
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
}
groups[groupKey].sessions.push(session)
if (session.is_current_host) groups[groupKey].isCurrentSite = true
}
// Sort sessions within each group by last_renewed descending
for (const host in groups) {
groups[host].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
}
// Sort groups by host name (natural sort)
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const sortedHosts = Object.keys(groups).sort(collator.compare)
const sortedGroups = {}
for (const host of sortedHosts) {
sortedGroups[host] = groups[host]
}
return sortedGroups
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
return collator.compare(a.displayName, b.displayName) || collator.compare(a.sessions[0]?.client || '', b.sessions[0]?.client || '')
})
return Object.fromEntries(sorted)
})
</script>
+75 -56
View File
@@ -1,23 +1,38 @@
<template>
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
<h3 class="user-name-heading">
<span class="icon">👤</span>
<span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name"></button>
</span>
</h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub">
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div>
</div>
<div class="user-details">
<span class="date-label"><strong>Visits:</strong></span>
<span class="date-value">{{ visits || 0 }}</span>
<span class="date-label"><strong>Registered:</strong></span>
<span class="date-value">{{ formatDate(createdAt) }}</span>
<span class="date-label"><strong>Last seen:</strong></span>
<span class="date-value">{{ formatDate(lastSeen) }}</span>
<div class="user-info-content">
<div class="user-picture">
<span>👤</span>
</div>
<h3 class="user-name-heading">
<span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span>
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('edit')" title="Edit profile"></button>
</span>
</h3>
<div v-if="orgDisplayName || roleName" class="org-role-sub">
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div>
</div>
<div class="info-fields-block">
<div v-if="preferred_username" class="contact-item">🆔 {{ preferred_username }}</div>
<a v-if="email" :href="`mailto:${email}`" class="contact-link"> {{ email }}</a>
<a v-if="telephone" :href="`tel:${telephone}`" class="contact-link">📞 {{ telephone }}</a>
</div>
<div class="info-line">
<span v-if="visits">
<span class="info-date">{{ formatDate(createdAt) }}</span>
<span class="info-punct"> </span>
<span class="info-date">{{ formatDate(lastSeen) }}</span>
<span class="info-punct"> ×</span>
<span class="info-count">{{ visits }}</span>
</span>
<span v-else>
<span class="info-label">Created </span>
<span class="info-date">{{ formatDate(createdAt) }}</span>
<span class="info-punct"> Never signed in</span>
</span>
</div>
</div>
<div v-if="$slots.default" class="user-info-extra">
<slot></slot>
@@ -26,12 +41,15 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
name: { type: String, required: true },
email: { type: String, default: null },
preferred_username: { type: String, default: null },
telephone: { type: String, default: null },
visits: { type: [Number, String], default: 0 },
createdAt: { type: [String, Number, Date], default: null },
lastSeen: { type: [String, Number, Date], default: null },
@@ -42,7 +60,7 @@ const props = defineProps({
roleName: { type: String, default: '' }
})
const emit = defineEmits(['saved', 'editName'])
const emit = defineEmits(['saved', 'edit'])
const authStore = useAuthStore()
const userLoaded = computed(() => !!props.name)
@@ -50,55 +68,56 @@ const userLoaded = computed(() => !!props.name)
<style scoped>
.user-info.has-extra {
grid-template-columns: auto 1fr 2fr;
grid-template-columns: minmax(0, 1fr) 14rem;
grid-template-areas:
"heading heading extra"
"org org extra"
"label1 value1 extra"
"label2 value2 extra"
"label3 value3 extra";
"content extra";
gap: 1.5rem;
}
.user-info:not(.has-extra) {
grid-template-columns: auto 1fr;
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3";
"content";
}
@media (max-width: 720px) {
.user-info.has-extra {
grid-template-columns: auto 1fr;
grid-template-columns: 1fr;
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3"
"extra extra";
"content"
"extra";
}
}
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
.info-label:nth-of-type(1) { grid-area: label1; }
.info-value:nth-of-type(2) { grid-area: value1; }
.info-label:nth-of-type(3) { grid-area: label2; }
.info-value:nth-of-type(4) { grid-area: value2; }
.info-label:nth-of-type(5) { grid-area: label3; }
.info-value:nth-of-type(6) { grid-area: value3; }
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
.user-name-row.editing { flex: 1 1 auto; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
.user-name-heading .name-input { width: auto; }
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
.user-info-content {
grid-area: content;
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"picture heading fields"
"picture org fields"
". info info";
gap: 0 1rem;
min-width: 0;
}
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
.info-fields-block { grid-area: fields; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.contact-item { display: block; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-link { color: var(--color-text); text-decoration: none; display: block; transition: transform 0.1s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-link:hover { transform: scale(1.01); }
.info-line { grid-area: info; line-height: 1.4; font-size: 0.9em; }
.info-date { color: var(--color-text) !important; }
.info-label { color: var(--color-text) !important; }
.info-punct { color: var(--color-text-muted) !important; }
.info-count { color: var(--color-text-muted) !important; }
.user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
+3 -3
View File
@@ -87,7 +87,7 @@ export const useAuthStore = defineStore('auth', {
},
async loadUserInfo() {
try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
updateThemeFromSession(this.ctx)
console.log('User info loaded:', this.userInfo)
} catch (error) {
@@ -104,9 +104,9 @@ export const useAuthStore = defineStore('auth', {
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
await this.loadUserInfo()
},
async terminateSession(sessionId) {
async terminateSession(sessionKey) {
try {
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
const payload = await apiJson(`/auth/api/user/session/${sessionKey}`, { method: 'DELETE' })
if (payload?.current_session_terminated) {
sessionStorage.clear()
location.reload()
+7 -2
View File
@@ -54,8 +54,13 @@ export async function register(resetToken = null, displayName = null, onstartreg
}
}
export async function authenticate() {
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
export async function authenticate(queryString = null) {
// Build URL, optionally appending raw query string (e.g. for OIDC params)
let url = await makeUrl('/auth/ws/authenticate')
if (queryString) {
url += queryString.startsWith('?') ? queryString : `?${queryString}`
}
const ws = await aWebSocket(url)
try {
let res = await ws.receive_json()
if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`)
+13
View File
@@ -16,6 +16,7 @@ export default defineConfig(({ command }) => ({
fastapiVue({ paths: [
"/auth/api",
"/auth/ws",
"/.well-known/openid-configuration",
// Passphrase links: /auth/word1.word2.word3.word4.word5
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
// Passphrase links: /word1.word2.word3.word4.word5
@@ -46,6 +47,18 @@ export default defineConfig(({ command }) => ({
})
}
},
{
name: 'restricted-endpoints-rewrite',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
// Rewrite /auth/restricted/iframe and /auth/restricted/oidc to /auth/restricted/
if (req.url === '/auth/restricted/iframe' || req.url === '/auth/restricted/oidc') {
req.url = '/auth/restricted/'
}
next()
})
}
},
{
name: 'serve-examples',
configureServer(server) {
+86
View File
@@ -0,0 +1,86 @@
# OIDC Provider Implementation
OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication.
## Data Models
**User** — Added: `email`, `preferred_username`
**Session** — Added: `client_uuid` (None = native, set = OIDC)
- `key: bytes` — hashed DB key, never stored raw
- `secret``hash_secret("session", secret)` → DB lookup
- OIDC `sid``base64url.encode(hash_secret("oidc", session.key))`
**OIDClient**`uuid, client_secret_hash, name, redirect_uris`
## Auth Codes (In-Memory Only)
60-second lifetime, auto-cleaned:
```python
from paskia.authcode import AuthCode, OIDC, codes
class AuthCode(msgspec.Struct):
session_key: str # Session DB key
created: datetime
oidc: OIDC | None # Only for OIDC mode
class OIDC(msgspec.Struct):
redirect_uri, scope, nonce, code_challenge, code_challenge_method: str
```
Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)`
## Authorization Flows
### OIDC (Authorization Code)
1. `GET /auth/restricted/oidc?client_id=UUID&redirect_uri=...&scope=openid&nonce=...&code_challenge=...`
2. Frontend → WebSocket: `/auth/ws/authenticate?client_id=...&redirect_uri=...&...`
3. Validate client/redirect_uri, authenticate via passkey
4. `db.oidc_login()``(secret, session_key)`
5. Create `AuthCode(session_key, oidc=OIDC(...))` → code
6. Return: `{"redirect_url": "{redirect_uri}?code={code}&state={state}"}`
7. Client exchanges code at `/auth/oidc/token` with `code_verifier` (PKCE S256)
**Token:** `access_token, id_token, refresh_token={secret}, expires_in=3600`
**ID token:** `sub, sid (base64url), name, preferred_username, email, groups`
### Native (Cookie)
1. WebSocket: `/auth/ws/authenticate` (no OIDC params)
2. Authenticate via passkey
3. `db.login()` → secret
4. Create `AuthCode(session_key=secret, oidc=None)` → exchange_code
5. Return: `{"user": "UUID", "exchange_code": "..."}`
6. `POST /auth/api/exchange` with code → sets cookie
## Refresh & Logout
**Refresh:** `POST /auth/oidc/token` with `grant_type=refresh_token&refresh_token={secret}&client_id=...&client_secret=...`
- Looks up session, validates client match
- Extends expiry +24h (sliding window)
- Returns new tokens with same `sid`
**Back-channel logout:** `POST /auth/oidc/backchannel-logout` with `logout_token={jwt}`
- Verify signature, extract `sid` or `sub`
- Delete matched sessions
- Return 200 OK
Discovery: `backchannel_logout_supported: true`
## Endpoints
- `GET /.well-known/openid-configuration` — Discovery
- `GET /auth/oidc/keys` — Keys (EdDSA)
- `POST /auth/oidc/token` — Exchange/refresh
- `GET /auth/oidc/userinfo` — User (bearer token)
- `POST /auth/oidc/backchannel-logout` — Logout
- `POST /auth/api/exchange` — Native auth code → cookie
## Files
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/globals.py](paskia/globals.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)
+1 -1
View File
@@ -68,7 +68,7 @@ The JSON variants set headers automatically, with body and response in JSON.
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/iframe#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
```js
import { showAuthIframe, AuthCancelledError } from 'paskia'
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paskia",
"version": "0.1.3",
"version": "0.1.4",
"description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko",
"license": "Unlicense",
+1
View File
@@ -57,6 +57,7 @@ export class AuthCancelledError extends Error {
}
export function holdGlobalBackdrop(): void {
injectStyles()
backdropHolders++
document.body.classList.add('paskia-backdrop')
}
+113
View File
@@ -0,0 +1,113 @@
"""
Authorization code management for OIDC and cookie exchange flows.
Codes are short-lived (60 seconds) and stored in-memory only.
Two separate stores maintain full isolation between OIDC and cookie flows.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
from datetime import UTC, datetime, timedelta
import msgspec
_logger = logging.getLogger(__name__)
# Auth codes expire after this duration
AUTH_CODE_LIFETIME = timedelta(seconds=60)
class OIDCCode(msgspec.Struct):
"""An OIDC authorization code pending token exchange.
PKCE uses S256 only when provided (verified at token exchange).
"""
session_key: str
created: datetime
redirect_uri: str
scope: str
nonce: str | None = None
code_challenge: str | None = None
class CookieCode(msgspec.Struct):
"""A cookie exchange code for setting session cookie after WebSocket auth."""
session_key: str
created: datetime
# Separate stores for each code type
oidc_codes: dict[str, OIDCCode] = {}
cookie_codes: dict[str, CookieCode] = {}
# Background cleanup task
_cleanup_task: asyncio.Task | None = None
async def start():
"""Start the cleanup background task."""
global _cleanup_task
if _cleanup_task is None:
_cleanup_task = asyncio.create_task(_cleanup_loop())
async def stop():
"""Stop the cleanup background task."""
global _cleanup_task
if _cleanup_task:
_cleanup_task.cancel()
try:
await _cleanup_task
except asyncio.CancelledError:
pass
_cleanup_task = None
async def _cleanup_loop():
while True:
try:
await asyncio.sleep(30) # Check every 30 seconds
_cleanup_expired()
except asyncio.CancelledError:
break
except Exception:
_logger.exception("Error in auth code cleanup loop")
def _cleanup_expired():
oldest = datetime.now(UTC) - AUTH_CODE_LIFETIME
for code, auth_code in list(oidc_codes.items()):
if auth_code.created < oldest:
del oidc_codes[code]
for code, auth_code in list(cookie_codes.items()):
if auth_code.created < oldest:
del cookie_codes[code]
def store_oidc(code: OIDCCode) -> str:
"""Store an OIDC authorization code and return the code string."""
token = secrets.token_urlsafe(12)
oidc_codes[token] = code
return token
def consume_oidc(token: str) -> OIDCCode | None:
"""Consume an OIDC code, returning it if valid. Atomic removal."""
return oidc_codes.pop(token, None)
def store_cookie(code: CookieCode) -> str:
"""Store a cookie exchange code and return the code string."""
token = secrets.token_urlsafe(12)
cookie_codes[token] = code
return token
def consume_cookie(token: str) -> CookieCode | None:
"""Consume a cookie exchange code, returning it if valid. Atomic removal."""
return cookie_codes.pop(token, None)
+17 -4
View File
@@ -33,13 +33,14 @@ from paskia.db.operations import (
add_permission_to_role,
create_credential,
create_credential_session,
create_oid_client,
create_org,
create_permission,
create_reset_token,
create_role,
create_session,
create_user,
delete_credential,
delete_oid_client,
delete_org,
delete_permission,
delete_reset_token,
@@ -47,22 +48,27 @@ from paskia.db.operations import (
delete_session,
delete_sessions_for_user,
delete_user,
is_username_taken,
login,
oidc_login,
remove_permission_from_org,
remove_permission_from_role,
reset_oid_client_secret,
set_session_host,
update_config,
update_credential_sign_count,
update_oid_client,
update_org_name,
update_permission,
update_role_name,
update_session,
update_user_display_name,
update_user_info,
update_user_role,
update_user_theme,
)
from paskia.db.structs import (
DB,
Client,
Config,
Credential,
Org,
@@ -85,6 +91,7 @@ __all__ = [
"Config",
"Credential",
"DB",
"Client",
"Org",
"Permission",
"ResetToken",
@@ -119,7 +126,6 @@ __all__ = [
"create_permission",
"create_reset_token",
"create_role",
"create_session",
"create_user",
"delete_credential",
"delete_org",
@@ -130,6 +136,7 @@ __all__ = [
"delete_sessions_for_user",
"delete_user",
"login",
"oidc_login",
"remove_permission_from_org",
"remove_permission_from_role",
"set_session_host",
@@ -140,6 +147,12 @@ __all__ = [
"update_role_name",
"update_session",
"update_user_display_name",
"update_user_info",
"update_user_role",
"update_user_theme",
"is_username_taken",
# OIDC
"create_oid_client",
"update_oid_client",
"reset_oid_client_secret",
"delete_oid_client",
]
+4
View File
@@ -8,6 +8,7 @@ import uuid7
import paskia.db.operations as _ops
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
from paskia.util.crypto import secret_key
def bootstrap(
@@ -120,4 +121,7 @@ def bootstrap(
if config is not None:
_ops._db.config = config
# Generate OIDC signing key
_ops._db.oidc.key = secret_key()
return reset_passphrase
+7 -1
View File
@@ -7,6 +7,7 @@ import os
from datetime import UTC, datetime
import paskia.db.operations as _ops
from paskia.authsession import EXPIRES
_logger = logging.getLogger(__name__)
@@ -27,8 +28,13 @@ def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
count = 0
limit = now - EXPIRES
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
if expired_sessions:
from paskia import oidc_notify # noqa: PLC0415
oidc_notify.schedule_notifications(expired_sessions)
with _ops._db.transaction("expiry"):
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.expiry < now]
for k in expired_sessions:
del _ops._db.sessions[k]
count += 1
+22 -55
View File
@@ -49,11 +49,6 @@ def _is_uuid(value: str) -> bool:
return bool(_UUID_PATTERN.match(value))
def _uuid_suffix(uuid_str: str) -> str:
"""Get the last section of a UUID (after the last hyphen)."""
return uuid_str.rsplit("-", 1)[-1]
class UuidResolver:
"""Resolve UUIDs to display names or short suffixes.
@@ -148,14 +143,8 @@ class UuidResolver:
return None
def _use_color() -> bool:
"""Check if we should use color output."""
return sys.stderr.isatty()
def _format_value(
value: Any,
use_color: bool,
max_len: int = 60,
resolver: UuidResolver | None = None,
) -> str:
@@ -195,16 +184,14 @@ def _format_value(
if all_true:
parts.append(key_display)
else:
val_display = _format_value(v, use_color, max_len=30, resolver=resolver)
val_display = _format_value(v, max_len=30, resolver=resolver)
parts.append(f"{key_display}: {val_display}")
return "{" + ", ".join(parts) + "}"
if isinstance(value, list):
if not value:
return "[]"
parts = [
_format_value(v, use_color, max_len=30, resolver=resolver) for v in value
]
parts = [_format_value(v, max_len=30, resolver=resolver) for v in value]
return "[" + ", ".join(parts) + "]"
# Fallback for other types
@@ -214,9 +201,7 @@ def _format_value(
return text
def _format_path(
path: list[str], use_color: bool, resolver: UuidResolver | None = None
) -> str:
def _format_path(path: list[str], resolver: UuidResolver | None = None) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default.
If resolver is provided, UUIDs in the path are replaced with display names.
@@ -228,8 +213,6 @@ def _format_path(
if resolver:
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
if not use_color:
return ".".join(path)
if len(path) == 1:
return f"{_PATH_FINAL}{path[0]}{_RESET}"
prefix = ".".join(path[:-1])
@@ -324,7 +307,6 @@ def _format_change_lines(
change_type: str,
path: list[str],
value: Any,
use_color: bool,
resolver: UuidResolver | None = None,
) -> list[str]:
"""Format a single change as one or more lines.
@@ -332,6 +314,12 @@ def _format_change_lines(
If resolver is provided, UUIDs are replaced with display names.
"""
# Helper to format a value, checking for censored paths
def fmt_value(v: Any, child_path: list[str]) -> str:
if child_path[-2:] == ["oidc", "key"]:
return f"{_DIM}<hidden>{_RESET}"
return _format_value(v, resolver=resolver)
# Helper to format path with UUID replacement
def fmt_path(p: list[str]) -> list[str]:
if resolver:
@@ -341,8 +329,6 @@ def _format_change_lines(
formatted_path = fmt_path(path)
if change_type == "delete":
if not use_color:
return [f" {'.'.join(formatted_path)}"]
if len(formatted_path) == 1:
return [f" {_DELETE}{formatted_path[0]}{_RESET}"]
prefix = ".".join(formatted_path[:-1])
@@ -355,9 +341,7 @@ def _format_change_lines(
if isinstance(value, dict) and value:
lines = []
# First line: path with green final element and grey =
if not use_color:
lines.append(f" {'.'.join(formatted_path)} =")
elif len(formatted_path) == 1:
if len(formatted_path) == 1:
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET}")
else:
prefix = ".".join(formatted_path[:-1])
@@ -370,21 +354,16 @@ def _format_change_lines(
formatted_items = []
for k, v in value.items():
k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
v_str = _format_value(v, use_color, resolver=resolver)
v_str = fmt_value(v, path + [k])
formatted_items.append((k_display, v_str))
max_key_len = max(len(k) for k, _ in formatted_items)
field_width = max(max_key_len, 12) # minimum 12 chars
for k_display, v_str in formatted_items:
padding = " " * (field_width - len(k_display))
if use_color:
lines.append(f" {k_display}{_DIM}:{_RESET}{padding} {v_str}")
else:
lines.append(f" {k_display}:{padding} {v_str}")
lines.append(f" {k_display}{_DIM}:{_RESET}{padding} {v_str}")
return lines
else:
value_str = _format_value(value, use_color, resolver=resolver)
if not use_color:
return [f" {'.'.join(formatted_path)} = {value_str}"]
value_str = fmt_value(value, path)
if len(formatted_path) == 1:
return [
f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET} {value_str}"
@@ -396,11 +375,9 @@ def _format_change_lines(
]
# update: Existing item being updated - normal path colors
value_str = _format_value(value, use_color, resolver=resolver)
path_str = _format_path(path, use_color, resolver=resolver)
if use_color:
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
return [f" {path_str} = {value_str}"]
value_str = fmt_value(value, path)
path_str = _format_path(path, resolver=resolver)
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
def format_diff(
@@ -417,7 +394,6 @@ def format_diff(
Returns a list of formatted lines (without newlines).
UUIDs are replaced with display names (using previous state for lookups).
"""
use_color = _use_color()
changes: list[tuple[str, list[str], Any]] = []
_collect_changes(diff, [], changes, previous)
@@ -430,27 +406,18 @@ def format_diff(
# Format each change
lines = []
for change_type, path, value in changes:
lines.extend(
_format_change_lines(change_type, path, value, use_color, resolver)
)
lines.extend(_format_change_lines(change_type, path, value, resolver))
return lines
def format_action_header(action: str, user_display: str | None = None) -> str:
"""Format the action header line."""
use_color = _use_color()
if use_color:
action_str = f"{_ACTION}{action}{_RESET}"
if user_display:
user_str = f"{_USER}{user_display}{_RESET}"
return f"{action_str} by {user_str}"
return action_str
else:
if user_display:
return f"{action} by {user_display}"
return action
action_str = f"{_ACTION}{action}{_RESET}"
if user_display:
user_str = f"{_USER}{user_display}{_RESET}"
return f"{action_str} by {user_str}"
return action_str
def log_change(
+11
View File
@@ -5,8 +5,11 @@ Migrations are applied during database load based on the version field.
Each migration should be idempotent and only run when needed.
"""
import base64
from collections.abc import Awaitable, Callable
from paskia.util.crypto import secret_key
def migrate_v1(d: dict, **kwargs) -> None:
"""Remove Org.created_at fields."""
@@ -26,6 +29,14 @@ def migrate_v3(d: dict, **kwargs) -> None:
user_data.setdefault("visits", 0)
def migrate_v4(d: dict, **kwargs) -> None:
"""OpenID Connect support and hardened session keys."""
# Session keys changed to hashes, drop old sessions
d["sessions"] = {}
# Create OIDC structure with a generated new key
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
+251 -51
View File
@@ -7,9 +7,11 @@ Write operations: Functions that validate and commit, or raise ValueError.
"""
import logging
import secrets
from datetime import UTC, datetime, timedelta
from uuid import UUID
import base64url
import uuid7
from paskia.config import SESSION_LIFETIME
@@ -18,6 +20,7 @@ from paskia.db.jsonl import (
)
from paskia.db.structs import (
DB,
Client,
Config,
Credential,
Org,
@@ -28,9 +31,14 @@ from paskia.db.structs import (
SessionContext,
User,
)
from paskia.util.crypto import hash_secret
from paskia.util.nameutil import slugify_name
_logger = logging.getLogger(__name__)
# Sentinel for distinguishing "not provided" from None
_UNSET = object()
# Global database instance (empty until init() loads data)
_db = DB(config=Config(rp_id="uninitialized.invalid"))
_store = JsonlStore(_db)
@@ -38,6 +46,17 @@ _db._store = _store
_initialized = False
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
"""Check if a preferred_username is already taken by another user."""
if not username:
return False
for user in _db.users.values():
if user.preferred_username == username:
if exclude_uuid is None or user.uuid != exclude_uuid:
return True
return False
# -------------------------------------------------------------------------
# Write operations (validate, modify, commit or raise ValueError)
# -------------------------------------------------------------------------
@@ -255,30 +274,103 @@ def update_user_display_name(
The acting user should be logged via ctx.
For self-service (user updating own name), pass user's ctx.
For admin operations, pass admin's ctx.
If the user's preferred_username is currently None, this will auto-fill it
with a slugified version of the display name (if unique and non-empty).
"""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db.users:
raise ValueError(f"User {uuid} not found")
display_name = (display_name or "").strip()
if not display_name:
raise ValueError("Display name cannot be empty")
user = _db.users[uuid]
with _db.transaction("update_user_display_name", ctx):
_db.users[uuid].display_name = display_name
user.display_name = display_name
# Auto-fill preferred_username if not already set
if user.preferred_username is None:
slug = slugify_name(display_name)
if slug and not is_username_taken(slug, exclude_uuid=uuid):
user.preferred_username = slug
def update_user_theme(
def update_user_info(
uuid: UUID,
theme: str,
*,
display_name: str | object = _UNSET,
theme: str | object = _UNSET,
email: str | None | object = _UNSET,
preferred_username: str | None | object = _UNSET,
telephone: str | None | object = _UNSET,
ctx: SessionContext | None = None,
) -> None:
"""Update user theme preference ('' for auto, 'light', 'dark')."""
"""Update user profile information.
Pass only the fields you want to update. Use None to clear optional fields.
This does NOT auto-fill preferred_username - use update_user_display_name
for the registration flow where auto-fill is desired.
Args:
uuid: User UUID
display_name: User display name (cannot be empty)
theme: Theme preference ('' for auto, 'light', 'dark')
email: Email address (None to clear)
preferred_username: Username for OIDC claims (None to clear, must be unique)
telephone: Phone number (None to clear)
ctx: Session context for audit logging
"""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db.users:
raise ValueError(f"User {uuid} not found")
if theme not in ("", "light", "dark"):
raise ValueError(f"Invalid theme: {theme}")
with _db.transaction("update_user_theme", ctx):
_db.users[uuid].theme = theme
user = _db.users[uuid]
# Validate all fields before transaction
if display_name is not _UNSET:
display_name = (display_name or "").strip()
if not display_name:
raise ValueError("Display name cannot be empty")
if theme is not _UNSET:
if theme not in ("", "light", "dark"):
raise ValueError(f"Invalid theme: {theme}")
if email is not _UNSET and email is not None:
email = (email or "").strip()
if not email:
email = None
elif "@" not in email or len(email) > 254:
raise ValueError("Invalid email format")
if preferred_username is not _UNSET and preferred_username is not None:
preferred_username = (preferred_username or "").strip()
if not preferred_username:
raise ValueError("Preferred username cannot be empty (use None to clear)")
if len(preferred_username) > 128:
raise ValueError("preferred_username too long")
if is_username_taken(preferred_username, exclude_uuid=uuid):
raise ValueError("Username already taken")
if telephone is not _UNSET and telephone is not None:
telephone = (telephone or "").strip()
if not telephone:
telephone = None
elif len(telephone) > 32:
raise ValueError("telephone too long")
with _db.transaction("update_user_info", ctx):
if display_name is not _UNSET:
user.display_name = display_name
if theme is not _UNSET:
user.theme = theme
if email is not _UNSET:
user.email = email
if preferred_username is not _UNSET:
user.preferred_username = preferred_username
if telephone is not _UNSET:
user.telephone = telephone
def update_user_role(
@@ -350,43 +442,12 @@ def delete_credential(
cred.delete()
def create_session(
user_uuid: UUID,
credential_uuid: UUID,
host: str,
ip: str,
user_agent: str,
duration: timedelta = SESSION_LIFETIME,
*,
ctx: SessionContext | None = None,
) -> str:
"""Create a new session. Returns the session key."""
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
now = datetime.now(UTC)
session = Session.create(
user=user_uuid,
credential=credential_uuid,
host=host,
ip=ip,
user_agent=user_agent,
expiry=now + duration,
)
if session.key in _db.sessions:
raise ValueError("Session already exists")
with _db.transaction("create_session", ctx):
session.store(now)
return session.key
def update_session(
key: str,
key: bytes,
host: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
expiry: datetime | None = None,
validated: datetime | None = None,
*,
ctx: SessionContext | None = None,
) -> None:
@@ -401,11 +462,13 @@ def update_session(
s.ip = ip
if user_agent is not None:
s.user_agent = user_agent
if expiry is not None:
s.expiry = expiry
if validated is not None:
s.validated = validated
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
def set_session_host(
key: bytes, host: str, *, ctx: SessionContext | None = None
) -> None:
"""Set the host for a session (first-time binding)."""
update_session(key, host=host, ctx=ctx)
@@ -421,6 +484,9 @@ def delete_session(
"""
if key not in _db.sessions:
raise ValueError("Session not found")
from paskia import oidc_notify # noqa: PLC0415
oidc_notify.schedule_notifications([key])
with _db.transaction(action, ctx):
_db.sessions[key].delete()
@@ -437,6 +503,10 @@ def delete_sessions_for_user(
user = _db.users.get(user_uuid)
if not user:
return
from paskia import oidc_notify # noqa: PLC0415
keys = [s.key for s in user.sessions]
oidc_notify.schedule_notifications(keys)
with _db.transaction("admin:delete_sessions_for_user", ctx):
for sess in user.sessions:
sess.delete()
@@ -513,13 +583,17 @@ def login(
if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
# Generate token and derive key
token = secrets.token_urlsafe(12)
session = Session.create(
user=user_uuid,
credential=credential_uuid,
key=base64url.enc(hash_secret("cookie", token)),
host=host,
ip=ip,
user_agent=user_agent,
expiry=now + duration,
validated=now,
)
user_str = str(user_uuid)
with _db.transaction("login", user=user_str):
@@ -527,7 +601,33 @@ def login(
# Update credential
_db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
return session.key
return token
def oidc_login(
session: Session,
credential_uuid: UUID,
sign_count: int,
) -> None:
"""Store an OIDC session and update credential in a single transaction.
The caller is responsible for generating the token, deriving the key,
and creating the Session object. This function only handles the
database transaction.
Updates:
- user.last_seen, user.visits
- credential.sign_count, credential.last_used
Stores:
- the provided session
"""
now = datetime.now(UTC)
user_str = str(session.user_uuid)
with _db.transaction("oidc_login", user=user_str):
session.store(now)
# Update credential
_db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
def create_credential_session(
@@ -555,13 +655,18 @@ def create_credential_session(
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
# Generate token and derive key
token = secrets.token_urlsafe(12)
key = base64url.enc(hash_secret("cookie", token))
session = Session.create(
user=user_uuid,
credential=credential.uuid,
key=key,
host=host,
ip=ip,
user_agent=user_agent,
expiry=now + SESSION_LIFETIME,
validated=now,
)
user_str = str(user_uuid)
with _db.transaction("create_credential_session", user=user_str):
@@ -582,7 +687,102 @@ def create_credential_session(
# Delete reset token if provided
if reset_key:
token = _db.reset_tokens.get(reset_key)
if token:
token.delete()
return session.key
reset_token = _db.reset_tokens.get(reset_key)
if reset_token:
reset_token.delete()
return token
# -------------------------------------------------------------------------
# OIDC Provider operations
# -------------------------------------------------------------------------
def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> None:
"""Create a new OIDC client."""
if client.uuid in _db.oidc.clients:
raise ValueError(f"OIDC client {client.uuid} already exists")
with _db.transaction("admin:create_oid_client", ctx):
_db.oidc.clients[client.uuid] = client
def update_oid_client(
client_uuid: UUID,
name: str | None = None,
redirect_uris: list[str] | None = None,
secret_hash: bytes | None = None,
backchannel_logout_uri: str | None = _UNSET,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update an OIDC client's name, redirect URIs, and/or secret."""
if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found")
client = _db.oidc.clients[client_uuid]
changes = {}
if name is not None and name != client.name:
changes["name"] = name
if redirect_uris is not None and redirect_uris != client.redirect_uris:
changes["redirect_uris"] = redirect_uris
if secret_hash is not None and secret_hash != client.client_secret_hash:
changes["client_secret_hash"] = secret_hash
if (
backchannel_logout_uri is not _UNSET
and backchannel_logout_uri != client.backchannel_logout_uri
):
changes["backchannel_logout_uri"] = backchannel_logout_uri
if not changes:
return # No changes to make
new_logout_uri = (
backchannel_logout_uri
if backchannel_logout_uri is not _UNSET
else client.backchannel_logout_uri
)
with _db.transaction("admin:update_oid_client", ctx):
# Create updated client with new values
updated_client = Client(
client_secret_hash=secret_hash
if secret_hash is not None
else client.client_secret_hash,
name=name if name is not None else client.name,
redirect_uris=redirect_uris
if redirect_uris is not None
else client.redirect_uris,
backchannel_logout_uri=new_logout_uri,
)
updated_client.uuid = client.uuid
_db.oidc.clients[client_uuid] = updated_client
def reset_oid_client_secret(
client_uuid: UUID,
new_secret_hash: bytes,
*,
ctx: SessionContext | None = None,
) -> None:
"""Reset an OIDC client's secret."""
if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found")
client = _db.oidc.clients[client_uuid]
with _db.transaction("admin:reset_oid_client_secret", ctx):
updated = Client(
client_secret_hash=new_secret_hash,
name=client.name,
redirect_uris=client.redirect_uris,
backchannel_logout_uri=client.backchannel_logout_uri,
)
updated.uuid = client.uuid
_db.oidc.clients[client_uuid] = updated
def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete an OIDC client."""
if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found")
with _db.transaction("admin:delete_oid_client", ctx):
del _db.oidc.clients[client_uuid]
+110 -15
View File
@@ -5,12 +5,14 @@ import secrets
from datetime import UTC, datetime
from uuid import UUID
import base64url
import msgspec
import uuid7
from paskia import db
from paskia.util import hostutil
from paskia.util import passphrase as passphrase_util
from paskia.util.crypto import hash_secret
# Sentinel for uuid fields before they are set by create() or DB post init
_UUID_UNSET = UUID(int=0)
@@ -194,10 +196,10 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
return role
class User(msgspec.Struct, dict=True, omit_defaults=False, kw_only=True):
class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
"""User data structure.
Mutable fields: display_name, role_uuid, last_seen, visits, theme
Mutable fields: display_name, role_uuid, last_seen, visits, theme, email, preferred_username
Immutable fields: created_at (set at creation, never modified)
uuid is derived from created_at using uuid7.
"""
@@ -208,6 +210,9 @@ class User(msgspec.Struct, dict=True, omit_defaults=False, kw_only=True):
visits: int
last_seen: datetime | None = None
theme: str = ""
email: str | None = None # OIDC email claim
preferred_username: str | None = None # OIDC preferred_username claim
telephone: str | None = None # Telephone number
def __post_init__(self):
if not hasattr(self, "uuid"):
@@ -356,12 +361,17 @@ class Credential(msgspec.Struct, dict=True):
return cred
class Session(msgspec.Struct, dict=True):
class Session(msgspec.Struct, dict=True, omit_defaults=True):
"""Session data structure.
Mutable fields: expiry (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent
key is stored in the dict key, not in the struct.
Mutable fields: validated (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
key is the hashed db_key, stored in the dict key, not in the struct.
If client_uuid is set, this is an OIDC session.
Security: The database stores only derived keys, never the raw secret.
A database leak does not expose working session credentials.
"""
user_uuid: UUID = msgspec.field(name="user")
@@ -369,7 +379,8 @@ class Session(msgspec.Struct, dict=True):
host: str
ip: str
user_agent: str
expiry: datetime
validated: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
def __post_init__(self):
if not hasattr(self, "key"):
@@ -390,7 +401,7 @@ class Session(msgspec.Struct, dict=True):
return {
"ip": self.ip,
"user_agent": self.user_agent,
"expiry": self.expiry.isoformat(),
"validated": self.validated.isoformat(),
}
def store(self, last_seen: datetime) -> None:
@@ -413,25 +424,37 @@ class Session(msgspec.Struct, dict=True):
cls,
user: UUID | User,
credential: UUID | Credential,
key: str,
host: str,
ip: str,
user_agent: str,
expiry: datetime,
validated: datetime,
client: UUID | None = None,
) -> Session:
"""Create a new Session with auto-generated key."""
"""Create a new Session with the provided key.
Args:
key: The base64url-encoded hashed session key (derived from secret via hash_secret then base64url.enc)
Returns:
Session object with key set
"""
user_uuid = user if isinstance(user, UUID) else user.uuid
credential_uuid = (
credential if isinstance(credential, UUID) else credential.uuid
)
session = cls(
user_uuid=user_uuid,
credential_uuid=credential_uuid,
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
validated=validated,
client_uuid=client,
)
session.key = secrets.token_urlsafe(12)
session.key = key
return session
@@ -459,6 +482,10 @@ class ResetToken(msgspec.Struct, dict=True):
"""Store this reset token in the database. Must be called inside a transaction."""
db.data().reset_tokens[self.key] = self
def delete(self) -> None:
"""Delete this reset token from the database. Must be called inside a transaction."""
del db.data().reset_tokens[self.key]
@staticmethod
def hash(passphrase: str) -> bytes:
"""Hash a passphrase to bytes for reset token storage."""
@@ -509,6 +536,58 @@ class ResetToken(msgspec.Struct, dict=True):
return token, passphrase
# -------------------------------------------------------------------------
# OIDC Provider structures
# -------------------------------------------------------------------------
class Client(msgspec.Struct, dict=True, omit_defaults=True):
"""OIDC client (relying party) registration.
client_id is the dict key (UUID).
"""
client_secret_hash: bytes
name: str
redirect_uris: list[str]
backchannel_logout_uri: str | None = None
def __post_init__(self):
if not hasattr(self, "uuid"):
self.uuid: UUID = _UUID_UNSET
@classmethod
def create(
cls,
name: str,
redirect_uris: list[str],
client_secret: str,
created_at: datetime | None = None,
backchannel_logout_uri: str | None = None,
) -> tuple[Client, str]:
"""Create a new OIDClient with hashed secret.
Returns (client, client_secret) tuple.
"""
now = created_at or datetime.now(UTC)
secret_hash = hashlib.sha256(client_secret.encode()).digest()
client = cls(
client_secret_hash=secret_hash,
name=name,
redirect_uris=redirect_uris,
backchannel_logout_uri=backchannel_logout_uri,
)
client.uuid = uuid7.create(now)
return client, client_secret
def verify_secret(self, client_secret: str) -> bool:
"""Verify a client secret against stored hash."""
return secrets.compare_digest(
self.client_secret_hash,
hashlib.sha256(client_secret.encode()).digest(),
)
class SessionContext(msgspec.Struct):
session: Session
user: User
@@ -518,6 +597,11 @@ class SessionContext(msgspec.Struct):
permissions: list[Permission] = []
class OIDC(msgspec.Struct, dict=True):
clients: dict[UUID, Client] = {}
key: bytes | None = None
class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True):
"""Stored configuration for the instance."""
@@ -544,6 +628,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {}
reset_tokens: dict[bytes, ResetToken] = {}
# OIDC provider data
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
def __post_init__(self):
# Store reference for persistence (not serialized)
@@ -563,28 +649,37 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
session.key = key
for key, token in self.reset_tokens.items():
token.key = key
# OIDC
for uuid, client in self.oidc.clients.items():
client.uuid = uuid
def transaction(self, action, ctx=None, *, user=None):
"""Wrap writes in transaction. Delegates to JsonlStore."""
return self._store.transaction(action, ctx, user=user)
def session_ctx(
self, session_key: str, host: str | None = None
self, session_secret: str, host: str | None = None
) -> SessionContext | None:
"""Get full session context with effective permissions.
Args:
session_key: The session key string
session_secret: The session secret (cookie value) - will be hashed for lookup
host: Optional host for binding/validation and domain-scoped permissions
Returns:
SessionContext if valid, None if session not found, expired, or host mismatch
"""
key = base64url.enc(hash_secret("cookie", session_secret))
try:
s = self.sessions[session_key]
s = self.sessions[key]
except KeyError:
return None
# OIDC sessions (client_uuid set) are not valid for cookie-based auth
if s.client_uuid is not None:
return None
# Normalize host for comparison (stored hosts are already normalized)
normalized_input = hostutil.normalize_host(host)
+327 -49
View File
@@ -6,11 +6,13 @@ from fastapi.responses import JSONResponse
from paskia import aaguid as aaguid_mod
from paskia import db
from paskia.authsession import EXPIRES, reset_expires
from paskia.authsession import reset_expires
from paskia.db import Org as OrgDC
from paskia.db import Permission as PermDC
from paskia.db import Role as RoleDC
from paskia.db import User as UserDC
from paskia.db.operations import _UNSET
from paskia.db.structs import Client
from paskia.fastapi import authz
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
@@ -23,14 +25,17 @@ from paskia.util import (
)
from paskia.util.apistructs import (
ApiAaguidInfo,
ApiAdminInfo,
ApiCreateLinkResponse,
ApiOidcClient,
ApiOrg,
ApiOrgResponse,
ApiPermission,
ApiRole,
ApiUser,
ApiUserDetail,
ApiUserSession,
ApiUuidResponse,
format_datetime,
)
from paskia.util.hostutil import normalize_host
@@ -79,31 +84,6 @@ async def adminapp(request: Request, auth=AUTH_COOKIE):
# -------------------- Organizations --------------------
@app.get("/orgs")
async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
orgs = list(db.data().orgs.values())
if not master_admin(ctx):
# Org admins can only see their own organization
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
def org_to_dict(o):
roles = o.roles
return ApiOrgResponse(
org=o,
permissions={p.uuid: p for p in o.permissions},
roles={r.uuid: r for r in roles},
users={u.uuid: u for r in roles for u in r.users},
)
return MsgspecResponse({o.uuid: org_to_dict(o) for o in orgs})
@app.post("/orgs")
async def admin_create_org(
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
@@ -532,7 +512,7 @@ async def admin_create_user_registration_link(
return MsgspecResponse(
ApiCreateLinkResponse(
url=url,
expires=format_datetime(expiry),
expires=expiry,
token_type=token_type,
)
)
@@ -560,15 +540,14 @@ async def admin_get_user_detail(
)
normalized_host = hostutil.normalize_host(request.headers.get("host"))
sessions = [
ApiUserSession.from_db(
sessions = {
s.key: ApiUserSession.from_db(
s,
current_key=auth,
current_key=ctx.session.key,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
]
}
return MsgspecResponse(
ApiUserDetail(
@@ -581,17 +560,23 @@ async def admin_get_user_detail(
).items()
},
sessions=sessions,
org=ApiOrg.from_db(user.org),
role=ApiRole.from_db(user.role),
)
)
@app.patch("/users/{user_uuid}/display-name")
async def admin_update_user_display_name(
@app.patch("/users/{user_uuid}/info")
async def admin_update_user_info(
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update user profile info (display_name, email, preferred_username, telephone).
Pass only the fields you want to update. Use null to clear optional fields.
"""
try:
user = db.data().users[user_uuid]
except KeyError:
@@ -606,12 +591,26 @@ async def admin_update_user_display_name(
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
new_name = (payload.get("display_name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
if len(new_name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
db.update_user_display_name(user_uuid, new_name, ctx=ctx)
kwargs = {}
if "display_name" in payload:
name = (payload["display_name"] or "").strip()
if not name:
raise HTTPException(status_code=400, detail="display_name cannot be empty")
if len(name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
kwargs["display_name"] = name
if "email" in payload:
kwargs["email"] = payload["email"]
if "preferred_username" in payload:
kwargs["preferred_username"] = payload["preferred_username"]
if "telephone" in payload:
kwargs["telephone"] = payload["telephone"]
if not kwargs:
raise HTTPException(status_code=400, detail="No fields to update")
db.update_user_info(user_uuid, **kwargs, ctx=ctx)
return {"status": "ok"}
@@ -692,14 +691,16 @@ async def admin_delete_user_session(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
target_session = db.data().sessions.get(session_id)
session_key = session_id
target_session = db.data().sessions.get(session_key)
if not target_session or target_session.user_uuid != user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_id, ctx=ctx, action="admin:delete_session")
db.delete_session(session_key, ctx=ctx, action="admin:delete_session")
# Check if admin terminated their own session
current_terminated = session_id == auth
current_terminated = session_key == ctx.session.key
return {"status": "ok", "current_session_terminated": current_terminated}
@@ -707,14 +708,24 @@ async def admin_delete_user_session(
def _validate_permission_domain(domain: str | None) -> None:
"""Validate that domain is rp_id or a subdomain of it."""
"""Validate that domain is rp_id, a subdomain of it, or an OIDC client UUID."""
if domain is None:
return
# Allow OIDC client UUIDs (used for groups claim)
try:
client_uuid = UUID(domain)
if client_uuid in db.data().oidc.clients:
return
except ValueError:
pass
rp_id = passkey.instance.rp_id
if domain == rp_id or domain.endswith(f".{rp_id}"):
return
raise ValueError(f"Domain '{domain}' must be '{rp_id}' or its subdomain")
raise ValueError(
f"Domain '{domain}' must be '{rp_id}', its subdomain, or an OIDC client UUID"
)
def _check_admin_lockout(
@@ -788,16 +799,62 @@ def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) ->
)
@app.get("/permissions")
async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
@app.get("/info")
async def admin_info(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
# Orgs
orgs = list(db.data().orgs.values())
if not master_admin(ctx):
# Org admins can only see their own organization
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
def org_to_dict(o):
roles = o.roles
return ApiOrgResponse(
org=ApiOrg.from_db(o),
permissions={p.uuid: p for p in o.permissions},
roles={r.uuid: r for r in roles},
users={u.uuid: u for r in roles for u in r.users},
)
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
# Permissions
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
return MsgspecResponse({p.uuid: ApiPermission.from_db(p) for p in perms})
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}
# OIDC Clients (master admin only)
oidc_clients_dict = {}
if master_admin(ctx):
clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid)
sessions = db.data().sessions
# Count active sessions per client
client_session_counts = {}
for session in sessions.values():
if session.client_uuid:
client_session_counts[session.client_uuid] = (
client_session_counts.get(session.client_uuid, 0) + 1
)
oidc_clients_dict = {
client.uuid: ApiOidcClient.from_db(
client, client_session_counts.get(client.uuid, 0)
)
for client in clients
}
return MsgspecResponse(
ApiAdminInfo(
orgs=orgs_dict,
permissions=perms_dict,
oidc_clients=oidc_clients_dict,
)
)
@app.post("/permissions")
@@ -897,3 +954,224 @@ async def admin_delete_permission(
db.delete_permission(permission_uuid, ctx=ctx)
return {"status": "ok"}
# -------------------- OIDC Clients --------------------
@app.post("/oidc-clients")
async def admin_create_oidc_client(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Create a new OIDC client (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
# Client ID and secret hash are generated client-side
client_id = payload.get("client_id", "").strip()
secret_hash_hex = payload.get("secret_hash", "").strip()
name = payload.get("name", "").strip()
redirect_uris = payload.get("redirect_uris", [])
backchannel_logout_uri = payload.get("backchannel_logout_uri")
if isinstance(backchannel_logout_uri, str):
backchannel_logout_uri = backchannel_logout_uri.strip() or None
if not client_id or not secret_hash_hex:
raise ValueError("client_id and secret_hash are required")
try:
client_uuid = UUID(client_id)
except (ValueError, AttributeError):
raise ValueError("client_id must be a valid UUID")
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
if not isinstance(redirect_uris, list):
raise ValueError("redirect_uris must be a list")
# Validate redirect URIs
for uri in redirect_uris:
if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}")
if backchannel_logout_uri and not backchannel_logout_uri.startswith("http"):
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
client = Client(
client_secret_hash=secret_hash,
name=name,
redirect_uris=redirect_uris,
backchannel_logout_uri=backchannel_logout_uri,
)
client.uuid = client_uuid
db.create_oid_client(client, ctx=ctx)
return {"status": "ok", "client_id": str(client.uuid)}
@app.patch("/oidc-clients/{client_uuid}")
async def admin_update_oidc_client(
client_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update an OIDC client's name and redirect URIs (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
name = payload.get("name", "").strip() if "name" in payload else None
redirect_uris = payload.get("redirect_uris") if "redirect_uris" in payload else None
secret_hash_hex = (
payload.get("secret_hash", "").strip() if "secret_hash" in payload else None
)
backchannel_logout_uri = (
payload.get("backchannel_logout_uri")
if "backchannel_logout_uri" in payload
else _UNSET
)
if isinstance(backchannel_logout_uri, str):
backchannel_logout_uri = backchannel_logout_uri.strip() or None
if name is not None and not name:
raise ValueError("Client name cannot be empty")
if redirect_uris is not None:
if not isinstance(redirect_uris, list):
raise ValueError("redirect_uris must be a list")
# Validate redirect URIs
for uri in redirect_uris:
if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}")
if (
backchannel_logout_uri is not _UNSET
and backchannel_logout_uri
and not backchannel_logout_uri.startswith("http")
):
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
secret_hash = None
if secret_hash_hex:
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
try:
db.update_oid_client(
client_uuid,
name=name,
redirect_uris=redirect_uris,
secret_hash=secret_hash,
backchannel_logout_uri=backchannel_logout_uri,
ctx=ctx,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
@app.post("/oidc-clients/{client_uuid}/reset-secret")
async def admin_reset_oidc_client_secret(
client_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Reset an OIDC client's secret (master admin only).
The new secret is generated client-side; only the SHA-256 hash is sent.
"""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
secret_hash_hex = payload.get("secret_hash", "").strip()
if not secret_hash_hex:
raise ValueError("secret_hash is required")
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
try:
db.reset_oid_client_secret(client_uuid, secret_hash, ctx=ctx)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
@app.delete("/oidc-clients/{client_uuid}")
async def admin_delete_oidc_client(
client_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Delete an OIDC client (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
try:
db.delete_oid_client(client_uuid, ctx=ctx)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
+38 -23
View File
@@ -13,9 +13,9 @@ from fastapi import (
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import db
from paskia import authcode, db
from paskia._version import __version__
from paskia.authsession import EXPIRES, expires, get_reset
from paskia.authsession import EXPIRES, get_reset
from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
@@ -23,7 +23,7 @@ from paskia.globals import passkey as global_passkey
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
bearer_auth = HTTPBearer(auto_error=True)
bearer_auth = HTTPBearer(auto_error=False)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -89,13 +89,13 @@ async def validate_token(
raise
renewed = False
if auth:
consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC))
consumed = datetime.now(UTC) - ctx.session.validated
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
db.update_session(
auth,
ctx.session.key,
ip=get_client_ip(request),
user_agent=request.headers.get("user-agent") or "",
expiry=expires(),
validated=datetime.now(UTC),
ctx=ctx,
)
session.set_session_cookie(response, auth)
@@ -129,7 +129,7 @@ async def forward_authentication(
- If Accept header contains "text/html": HTML page for authentication
with data attributes for mode and other metadata.
- Otherwise: JSON response with error details and an `iframe` field
pointing to /auth/restricted/?mode=... for iframe-based authentication.
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
"""
try:
ctx = await authz.verify(
@@ -152,11 +152,7 @@ async def forward_authentication(
"Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": (
ctx.session.expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
if ctx.session.expiry.tzinfo
else ctx.session.expiry.replace(tzinfo=UTC)
.isoformat()
.replace("+00:00", "Z")
(ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z")
),
"Remote-Credential": str(ctx.session.credential),
}
@@ -203,7 +199,7 @@ async def get_settings():
)
@app.post("/user-info")
@app.get("/user-info")
async def api_user_info(
request: Request,
response: Response,
@@ -223,8 +219,7 @@ async def api_user_info(
return MsgspecResponse(
await userinfo.build_user_info(
user_uuid=ctx.user.uuid,
auth=auth,
session_record=ctx.session,
session_key=ctx.session.key,
request_host=request.headers.get("host"),
ctx=ctx,
)
@@ -234,6 +229,8 @@ async def api_user_info(
@app.get("/token-info")
async def token_info(credentials=Depends(bearer_auth)):
"""Get reset/device-add token info. Pass token via Bearer header."""
if not credentials or not credentials.credentials:
raise HTTPException(401, "Bearer token required")
token = credentials.credentials
if not passphrase.is_well_formed(token):
raise HTTPException(400, "Invalid token format")
@@ -260,7 +257,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if not ctx:
return {"message": "Already logged out"}
with suppress(Exception):
db.delete_session(auth, ctx=ctx, action="logout")
db.delete_session(ctx.session.key, ctx=ctx, action="logout")
session.clear_session_cookie(response)
return {"message": "Logged out successfully"}
@@ -269,11 +266,29 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
async def api_set_session(
request: Request, response: Response, auth=Depends(bearer_auth)
):
ctx = db.data().session_ctx(auth.credentials, request.headers.get("host"))
"""Exchange an auth code for setting the session cookie.
Called by frontend after WebSocket authentication.
The code is ephemeral (60s TTL) and can only be used once.
"""
if not auth or not auth.credentials:
raise HTTPException(400, "Bearer token required")
# Verify host is provided
host = hostutil.normalize_host(request.headers.get("host", ""))
if not host:
raise HTTPException(400, "Host header required")
a = authcode.consume_cookie(auth.credentials)
if not a:
raise HTTPException(401, "Code expired or already used")
secret = a.session_key
# Verify the session exists
ctx = db.data().session_ctx(secret, host)
if not ctx:
raise HTTPException(401, "Session expired")
session.set_session_cookie(response, auth.credentials)
return {
"message": "Session cookie set successfully",
"user": str(ctx.user.uuid),
}
raise HTTPException(401, f"Session not found on {host}")
session.set_session_cookie(response, secret)
return {"status": "ok", "user": str(ctx.user.uuid)}
+1 -1
View File
@@ -41,7 +41,7 @@ async def auth_error_content(exc: AuthException) -> dict:
# Build hash fragment from mode and metadata
params = {"mode": exc.mode, **exc.metadata}
fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
iframe_url = f"/auth/restricted/#{fragment}"
iframe_url = f"/auth/restricted/iframe#{fragment}"
return {
"detail": exc.detail,
"auth": {
+45 -4
View File
@@ -8,11 +8,11 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from fastapi_vue import Frontend
from paskia import globals
from paskia import authcode, globals
from paskia.__main__ import DEVMODE
from paskia.db import start_background, stop_background
from paskia.db.logging import configure_db_logging
from paskia.fastapi import admin, api, auth_host, ws
from paskia.fastapi import admin, api, auth_host, oid, ws
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase, vitedev
@@ -67,6 +67,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
await start_background()
yield
await stop_background()
await authcode.stop()
app = FastAPI(
@@ -87,11 +88,51 @@ app.middleware("http")(auth_host.redirect_middleware)
app.mount("/auth/api/admin/", admin.app)
app.mount("/auth/api/", api.app)
app.mount("/auth/ws/", ws.app)
app.mount("/auth/oidc/", oid.app)
@app.get("/auth/restricted/")
# OIDC Well-Known endpoints (must be at site root)
@app.get("/.well-known/openid-configuration")
async def openid_configuration(request: Request):
"""OpenID Connect Discovery document."""
# Build issuer URL from request
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("host", request.url.netloc)
issuer = f"{scheme}://{host}"
return {
"issuer": issuer,
"authorization_endpoint": f"{issuer}/auth/restricted/oidc",
"token_endpoint": f"{issuer}/auth/oidc/token",
"userinfo_endpoint": f"{issuer}/auth/oidc/userinfo",
"jwks_uri": f"{issuer}/auth/oidc/keys",
"backchannel_logout_supported": True,
"backchannel_logout_session_supported": True,
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["EdDSA"],
"scopes_supported": ["openid", "profile", "email"],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic",
],
"code_challenge_methods_supported": ["S256"],
"claims_supported": [
"sub",
"name",
"preferred_username",
"email",
"groups",
"sid",
],
}
@app.get("/auth/restricted/iframe")
@app.get("/auth/restricted/oidc")
async def restricted_view():
"""Serve the restricted/authentication UI for iframe embedding."""
"""Serve the restricted/authentication UI for iframe or OpenID Connect."""
return Response(*await vitedev.read("/auth/restricted/index.html"))
+574
View File
@@ -0,0 +1,574 @@
"""
OIDC Provider endpoints.
Implements OpenID Connect 1.0 Authorization Code flow:
- POST /token - Token endpoint (code exchange)
- GET /userinfo - UserInfo endpoint (bearer token)
Authorization is handled by /auth/restricted/oidc which passes OIDC params to
the /auth/ws/authenticate WebSocket.
"""
import base64
import hashlib
import logging
from datetime import UTC, datetime
from uuid import UUID
import base64url
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import authcode, db
from paskia.db.structs import Session
from paskia.util import oidjwt
from paskia.util.crypto import hash_secret
_logger = logging.getLogger(__name__)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@app.get("/keys")
async def keys():
"""JSON Web Key Set for token verification."""
return oidjwt.get_jwks()
def _oidc_session_by_token(
token: str, client_uuid: UUID | None = None
) -> Session | None:
"""Look up an OIDC session by token (refresh token value)."""
key = base64url.enc(hash_secret("oidc", token))
s = db.data().sessions.get(key)
if not s or s.client_uuid is None:
return None
if client_uuid is not None and s.client_uuid != client_uuid:
return None
return s
def _oidc_session_by_sid(sid: bytes, client_uuid: UUID | None = None) -> Session | None:
"""Look up an OIDC session by sid (for backchannel logout)."""
for s in db.data().sessions.values():
if s.client_uuid is None:
continue
if client_uuid is not None and s.client_uuid != client_uuid:
continue
if base64url.dec(s.key) == sid:
return s
return None
def _get_issuer(request: Request) -> str:
"""Build issuer URL from request."""
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("host", request.url.netloc)
return f"{scheme}://{host}"
def _verify_pkce(code_verifier: str, code_challenge: str) -> bool:
"""Verify PKCE code_verifier against stored code_challenge (S256 only)."""
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return computed == code_challenge
def _parse_client_credentials(
request: Request,
client_id: str | None,
client_secret: str | None,
) -> tuple[str, str]:
"""Extract client credentials from request (Basic auth or body params)."""
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("basic "):
try:
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
client_id, client_secret = decoded.split(":", 1)
except Exception:
raise HTTPException(401, "Invalid Authorization header")
if not client_id or not client_secret:
raise HTTPException(401, "Missing client credentials")
return client_id, client_secret
@app.post("/token")
async def token(
request: Request,
grant_type: str = Form(...),
code: str | None = Form(None),
redirect_uri: str | None = Form(None),
client_id: str | None = Form(None),
client_secret: str | None = Form(None),
code_verifier: str | None = Form(None),
refresh_token: str | None = Form(None),
):
"""OIDC Token endpoint.
Supports:
- grant_type=authorization_code: Exchange code for tokens
- grant_type=refresh_token: Refresh access token using sid
Supports client_secret_post and client_secret_basic authentication.
Per RFC 6749 Section 4.1.3: MUST use POST with application/x-www-form-urlencoded.
"""
# RFC 6749: Token endpoint MUST NOT accept query parameters
if request.url.query:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Query parameters not allowed",
},
status_code=400,
)
# RFC 6749: MUST use application/x-www-form-urlencoded
content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" not in content_type:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Content-Type must be application/x-www-form-urlencoded",
},
status_code=400,
)
# Get client credentials (required for all grant types)
client_id, client_secret = _parse_client_credentials(
request, client_id, client_secret
)
# Validate client
try:
client_uuid = UUID(client_id)
except ValueError:
return JSONResponse({"error": "invalid_client"}, status_code=401)
client = db.data().oidc.clients.get(client_uuid)
if not client or not client.verify_secret(client_secret):
return JSONResponse({"error": "invalid_client"}, status_code=401)
if grant_type == "authorization_code":
return await _handle_authorization_code(
request, client, client_id, code, redirect_uri, code_verifier
)
elif grant_type == "refresh_token":
return await _handle_refresh_token(request, client, client_id, refresh_token)
else:
return JSONResponse(
{"error": "unsupported_grant_type"},
status_code=400,
)
async def _handle_authorization_code(
request: Request,
client,
client_id: str,
code: str | None,
redirect_uri: str | None,
code_verifier: str | None,
):
"""Handle grant_type=authorization_code."""
if not code:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing code"},
status_code=400,
)
# Consume auth code (atomic delete + return)
oidc_code = authcode.consume_oidc(code)
if not oidc_code:
return JSONResponse(
{"error": "invalid_grant", "error_description": "Code expired or invalid"},
status_code=400,
)
# Look up the OIDC session by token
session = _oidc_session_by_token(oidc_code.session_key, client.uuid)
if not session:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Session not found or not OIDC",
},
status_code=400,
)
# Verify redirect_uri matches
if redirect_uri and redirect_uri != oidc_code.redirect_uri:
return JSONResponse(
{"error": "invalid_grant", "error_description": "redirect_uri mismatch"},
status_code=400,
)
# Verify PKCE if code_challenge was provided at authorization time
if oidc_code.code_challenge:
if not code_verifier:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Missing code_verifier",
},
status_code=400,
)
if not _verify_pkce(code_verifier, oidc_code.code_challenge):
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid code_verifier",
},
status_code=400,
)
# Get user from session
user = db.data().users.get(session.user_uuid)
if not user:
return JSONResponse(
{"error": "invalid_grant", "error_description": "User not found"},
status_code=400,
)
# Derive sid from session key
sid = session.key
return _build_token_response(
request,
user,
client_id,
oidc_code.session_key,
sid,
oidc_code.nonce,
oidc_code.scope,
credential_uuid=session.credential_uuid,
)
async def _handle_refresh_token(
request: Request,
client,
client_id: str,
refresh_token_value: str | None,
):
"""Handle grant_type=refresh_token.
The refresh_token is the session secret. On refresh:
- Validates session exists and belongs to client
- Extends session expiry (24h sliding window)
- Records current IP and user_agent
- Issues new access_token and id_token
"""
if not refresh_token_value:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing refresh_token"},
status_code=400,
)
# Look up session by refresh token
session = _oidc_session_by_token(refresh_token_value, client.uuid)
if not session:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid or expired refresh_token",
},
status_code=400,
)
# Get user
user = db.data().users.get(session.user_uuid)
if not user:
return JSONResponse(
{"error": "invalid_grant", "error_description": "User not found"},
status_code=400,
)
# Refresh the session - extend expiry and record IP/user_agent
now = datetime.now(UTC)
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not ip:
ip = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
db.update_session(
session.key,
ip=ip,
user_agent=user_agent,
validated=now,
)
_logger.info("OIDC session refreshed: %s", session.key)
# Base64url encode session's derived sid for JWT claim
sid_str = session.key
return _build_token_response(
request,
user,
client_id,
refresh_token_value,
sid_str,
nonce=None,
scope="openid",
credential_uuid=session.credential_uuid,
)
def _build_token_response(
request: Request,
user,
client_id: str,
secret: str,
sid: str,
nonce: str | None,
scope: str,
credential_uuid: UUID | None = None,
):
"""Build the token response with access_token, id_token, and refresh_token."""
issuer = _get_issuer(request)
# Get user's permissions scoped to this OIDC client (domain == client UUID)
role = user.role
org = role.org
org_perm_uuids = {p.uuid for p in org.permissions}
groups = []
for perm_uuid in role.permission_set:
if perm_uuid not in org_perm_uuids:
continue
p = db.data().permissions.get(perm_uuid)
if p and p.domain == client_id:
groups.append(p.scope)
# Get credential's last_used as auth_time
auth_time = None
if credential_uuid:
try:
credential = db.data().credentials[credential_uuid]
if credential.last_used:
auth_time = credential.last_used
except KeyError:
pass
# Create ID token
id_token = oidjwt.create_id_token(
issuer=issuer,
subject=user.uuid,
audience=client_id,
nonce=nonce,
sid=sid,
name=user.display_name,
preferred_username=user.preferred_username,
email=user.email,
groups=groups or None,
auth_time=auth_time,
)
# Create access token
access_token = oidjwt.create_access_token(
issuer=issuer,
subject=user.uuid,
audience=client_id,
scope=scope,
)
return JSONResponse(
{
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": secret,
"id_token": id_token,
}
)
bearer_auth = HTTPBearer(auto_error=False)
@app.get("/userinfo")
async def userinfo(
request: Request,
credentials=Depends(bearer_auth),
):
"""OIDC UserInfo endpoint.
Returns claims about the authenticated user.
Requires Bearer token from /token endpoint.
"""
if not credentials:
raise HTTPException(401, "Bearer token required")
issuer = _get_issuer(request)
payload = oidjwt.decode_access_token(credentials.credentials, issuer)
if not payload:
raise HTTPException(401, "Invalid or expired token")
# Verify audience is a valid client
aud = payload.get("aud")
if not aud:
raise HTTPException(401, "Invalid token (missing aud claim)")
try:
client_uuid = UUID(aud)
except ValueError:
raise HTTPException(401, "Invalid token (invalid aud format)")
if not db.data().oidc.clients.get(client_uuid):
raise HTTPException(401, "Invalid token (unknown client)")
# Get user
try:
user_uuid = UUID(payload["sub"])
except (KeyError, ValueError):
raise HTTPException(401, "Invalid token")
user = db.data().users.get(user_uuid)
if not user:
raise HTTPException(401, "User not found")
# Get user's permissions scoped to this OIDC client (domain == client UUID)
role = user.role
org = role.org
org_perm_uuids = {p.uuid for p in org.permissions}
groups = []
for perm_uuid in role.permission_set:
if perm_uuid not in org_perm_uuids:
continue
p = db.data().permissions.get(perm_uuid)
if p and p.domain == aud:
groups.append(p.scope)
# Build userinfo response based on scope
scope = payload.get("scope", "openid").split()
response = {"sub": str(user.uuid)}
if "profile" in scope:
response["name"] = user.display_name
if user.preferred_username:
response["preferred_username"] = user.preferred_username
if "email" in scope and user.email:
response["email"] = user.email
# Include client-scoped permissions as groups
if groups:
response["groups"] = groups
return response
@app.post("/backchannel-logout")
async def backchannel_logout(
request: Request,
logout_token: str = Form(...),
):
"""OIDC Back-Channel Logout endpoint.
Receives a logout_token JWT from the RP and invalidates the session.
The logout_token must contain either 'sid' (session ID) or 'sub' (user ID).
Per OIDC Back-Channel Logout 1.0: uses application/x-www-form-urlencoded.
"""
# Validate content type
content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" not in content_type:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Content-Type must be application/x-www-form-urlencoded",
},
status_code=400,
)
# Decode and verify the logout token
issuer = _get_issuer(request)
payload = oidjwt.decode_access_token(logout_token, issuer)
if not payload:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid logout_token"},
status_code=400,
)
# Validate required claims
sid = payload.get("sid")
sub = payload.get("sub")
# Verify audience is a valid client (if present)
aud = payload.get("aud")
client_uuid = None
if aud:
try:
client_uuid = UUID(aud)
if not db.data().oidc.clients.get(client_uuid):
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Unknown client in logout_token",
},
status_code=400,
)
except ValueError:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Invalid client format in logout_token",
},
status_code=400,
)
if not sid and not sub:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "logout_token must contain sid or sub",
},
status_code=400,
)
# Delete session(s)
deleted = 0
if sid:
# Decode sid from base64url to bytes
try:
sid_bytes = base64url.dec(sid)
except Exception:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid sid format"},
status_code=400,
)
# Delete specific session by sid
session = _oidc_session_by_sid(sid_bytes, client_uuid)
if session:
db.delete_session(session.key)
deleted = 1
_logger.info("Back-channel logout: deleted session %s", sid)
elif sub:
# Delete all OIDC sessions for this user/client
try:
user_uuid = UUID(sub)
except ValueError:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid sub claim"},
status_code=400,
)
# Find and delete matching sessions
sessions_to_delete = [
s
for s in db.data().sessions.values()
if s.user_uuid == user_uuid
and s.client_uuid is not None
and (client_uuid is None or s.client_uuid == client_uuid)
]
for session in sessions_to_delete:
db.delete_session(session.key)
deleted += 1
if deleted:
_logger.info(
"Back-channel logout: deleted %d sessions for user %s", deleted, sub
)
# Return 200 OK even if no sessions were found (per spec)
return JSONResponse({"deleted": deleted})
+14 -5
View File
@@ -10,12 +10,14 @@ Endpoints:
"""
import asyncio
from datetime import UTC, datetime
from uuid import UUID
import base64url
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import db, remoteauth
from paskia import authcode, db, remoteauth
from paskia.authcode import CookieCode
from paskia.authsession import expires
from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_and_login
@@ -183,7 +185,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
"user": str(result_data["user_uuid"]),
}
if result_data.get("session_token"):
response["session_token"] = result_data["session_token"]
response["exchange_code"] = result_data["session_token"]
if result_data.get("reset_token"):
response["reset_token"] = result_data["reset_token"]
await ws.send_json(response)
@@ -310,9 +312,8 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
# Handle authenticate request (no PoW needed - already validated during lookup)
if msg.get("authenticate") and request is not None:
ctx = await authenticate_and_login(ws, auth)
ctx, secret = await authenticate_and_login(ws, auth)
session_token = ctx.session.key
reset_token = None
if request.action == "register":
@@ -325,11 +326,19 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
user=str(ctx.user.uuid),
)
# Create exchange code for the session (don't expose raw secret)
exchange_code = authcode.store_cookie(
CookieCode(
session_key=secret,
created=datetime.now(UTC),
)
)
# Complete the remote auth request (notifies the waiting device)
cred = db.data().credentials[ctx.session.credential_uuid]
completed = await remoteauth.instance.complete_request(
token=request.key,
session_token=session_token,
session_token=exchange_code,
user_uuid=ctx.user.uuid,
credential_uuid=cred.uuid,
reset_token=reset_token,
+1 -1
View File
@@ -84,5 +84,5 @@ def clear_session_cookie(response: Response) -> None:
httponly=True,
secure=True,
path="/",
samesite="lax",
samesite="strict",
)
+51 -10
View File
@@ -1,4 +1,3 @@
from datetime import UTC
from uuid import UUID
from fastapi import (
@@ -40,6 +39,7 @@ async def user_update_display_name(
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update display name only. Used by registration flow (auto-fills preferred_username)."""
if not auth:
raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login"
@@ -59,6 +59,49 @@ async def user_update_display_name(
return {"status": "ok"}
@app.patch("/info")
async def user_update_info(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update user profile info (display_name, email, preferred_username, telephone).
Pass only the fields you want to update. Use null to clear optional fields.
Does NOT auto-fill preferred_username (unlike /display-name endpoint).
"""
if not auth:
raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login"
)
ctx = db.data().session_ctx(auth, request.headers.get("host"))
if not ctx:
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
)
kwargs = {}
if "display_name" in payload:
name = (payload["display_name"] or "").strip()
if not name:
raise HTTPException(status_code=400, detail="display_name cannot be empty")
if len(name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
kwargs["display_name"] = name
if "email" in payload:
kwargs["email"] = payload["email"]
if "preferred_username" in payload:
kwargs["preferred_username"] = payload["preferred_username"]
if "telephone" in payload:
kwargs["telephone"] = payload["telephone"]
if not kwargs:
raise HTTPException(status_code=400, detail="No fields to update")
db.update_user_info(ctx.user.uuid, **kwargs, ctx=ctx)
return {"status": "ok"}
@app.patch("/theme")
async def user_update_theme(
request: Request,
@@ -77,7 +120,7 @@ async def user_update_theme(
theme = payload.get("theme", "")
if theme not in ("", "light", "dark"):
raise HTTPException(status_code=400, detail="Invalid theme")
db.update_user_theme(ctx.user.uuid, theme, ctx=ctx)
db.update_user_info(ctx.user.uuid, theme=theme, ctx=ctx)
return {"status": "ok"}
@@ -114,12 +157,14 @@ async def api_delete_session(
status_code=401, detail="Session expired", mode="login"
)
target_session = db.data().sessions.get(session_id)
session_key = session_id
target_session = db.data().sessions.get(session_key)
if not target_session or target_session.user_uuid != ctx.user.uuid:
raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_id, ctx=ctx)
current_terminated = session_id == auth
db.delete_session(session_key, ctx=ctx)
current_terminated = session_key == ctx.session.key
if current_terminated:
session.clear_session_cookie(response) # explicit because 200
return {"status": "ok", "current_session_terminated": current_terminated}
@@ -163,11 +208,7 @@ async def api_create_link(
ApiCreateLinkResponse(
message="Registration link generated successfully",
url=url,
expires=(
expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
if expiry.tzinfo
else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
),
expires=expiry,
token_type="device addition",
)
)
+175 -16
View File
@@ -1,13 +1,37 @@
import secrets
from datetime import UTC, datetime
from urllib.parse import urlencode
from uuid import UUID
import base64url
from fastapi import FastAPI, WebSocket
from paskia import db
from paskia import authcode, db
from paskia.authcode import CookieCode, OIDCCode
from paskia.authsession import get_reset
from paskia.db.structs import Session
from paskia.fastapi import authz, remote
from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_and_login, register_chat
from paskia.fastapi.wschat import (
authenticate_and_login,
authenticate_chat,
register_chat,
)
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.globals import passkey
from paskia.util import hostutil, passphrase
from paskia.util.crypto import hash_secret
def create_exchange_code(session_key: str) -> str:
"""Create an ephemeral exchange code for session authentication."""
now = datetime.now(UTC)
cookie_code = CookieCode(
session_key=session_key,
created=now,
)
return authcode.store_cookie(cookie_code)
# Create a FastAPI subapp for WebSocket endpoints
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -68,14 +92,17 @@ async def websocket_register_add(
ip=metadata["ip"],
user_agent=metadata["user_agent"],
)
auth = token
session_key = token
assert isinstance(auth, str) and len(auth) == 16
# Create exchange code (ephemeral, 60s TTL)
exchange_code = create_exchange_code(session_key)
assert isinstance(session_key, str) and len(session_key) == 16
await ws.send_json(
{
"user": str(user.uuid),
"credential": str(credential.uuid),
"session_token": auth,
"exchange_code": exchange_code,
"message": "New credential added successfully",
}
)
@@ -83,10 +110,89 @@ async def websocket_register_add(
@app.websocket("/authenticate")
@websocket_error_handler
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
async def websocket_authenticate(
ws: WebSocket,
auth=AUTH_COOKIE,
# OIDC params (when present, creates auth code instead of session)
client_id: str | None = None,
redirect_uri: str | None = None,
scope: str = "openid",
state: str | None = None,
nonce: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
):
origin = validate_origin(ws)
host = origin.split("://", 1)[1]
# OIDC mode: validate client before auth
oidc_client = None
if client_id and redirect_uri:
try:
client_uuid = UUID(client_id)
except ValueError:
await ws.send_json({"status": 400, "detail": "Invalid client_id"})
return
oidc_client = db.data().oidc.clients.get(client_uuid)
if not oidc_client:
await ws.send_json({"status": 400, "detail": "Unknown client_id"})
return
# Redirect URI autodiscovery: if no URIs are defined, store the first one
if not oidc_client.redirect_uris:
# Basic validation: must be an HTTP(S) URL
if not redirect_uri.startswith(("http://", "https://")):
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
return
# Store as the only allowed redirect URI
db.update_oid_client(client_uuid, redirect_uris=[redirect_uri])
# Reload client to get updated redirect_uris
oidc_client = db.data().oidc.clients.get(client_uuid)
elif redirect_uri not in oidc_client.redirect_uris:
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
return
scopes = scope.split()
if "openid" not in scopes:
await ws.send_json({"status": 400, "detail": "Scope must include openid"})
return
# PKCE: when code_challenge is present, only S256 is supported
# If method is omitted, default to S256 per best practice (not "plain" per RFC 7636)
# When code_challenge is absent, ignore code_challenge_method entirely
if code_challenge:
if code_challenge_method and code_challenge_method != "S256":
await ws.send_json(
{
"status": 400,
"detail": "Only S256 code_challenge_method is supported",
}
)
return
# Default to S256 when method not specified (implicit)
# Validate state parameter if provided (defensive against injection)
if state:
# State should be short-lived and contain only safe characters
# Per OAuth 2.0 spec: unreserved characters - alphanumerics and -._~
if len(state) > 500:
await ws.send_json(
{
"status": 400,
"detail": "state parameter is too long (max 500 chars)",
}
)
return
if not all(c.isalnum() or c in "-._~" for c in state):
await ws.send_json(
{
"status": 400,
"detail": "state parameter contains invalid characters",
}
)
return
# If there's an existing session, restrict to that user's credentials (reauth)
session_user_uuid = None
if auth:
@@ -94,15 +200,68 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
if existing_ctx:
session_user_uuid = existing_ctx.user.uuid
ctx = await authenticate_and_login(ws, auth)
if oidc_client:
# OIDC mode: authenticate and create OIDC session
cred, new_sign_count = await authenticate_chat(ws)
# If reauth mode, verify the credential belongs to the session's user
if session_user_uuid and ctx.user.uuid != session_user_uuid:
raise ValueError("This passkey belongs to a different account")
# Get metadata for session
origin = validate_origin(ws)
host = origin.split("://", 1)[1]
normalized_host = hostutil.normalize_host(host)
metadata = infodict(ws, "oidc_auth")
await ws.send_json(
{
"user": str(ctx.user.uuid),
"session_token": ctx.session.key,
}
)
# Use same timestamp for session and auth code
now = datetime.now(UTC)
# Generate token and create OIDC session
token = secrets.token_urlsafe(12)
session = Session.create(
user=cred.user_uuid,
credential=cred.uuid,
key=base64url.enc(hash_secret("oidc", token)),
host=normalized_host,
ip=metadata["ip"],
user_agent=metadata["user_agent"],
validated=now,
client=oidc_client.uuid,
)
db.oidc_login(
session=session,
credential_uuid=cred.uuid,
sign_count=new_sign_count,
)
# Create auth code (in-memory only)
oidc_code = OIDCCode(
session_key=token,
created=now,
redirect_uri=redirect_uri,
scope=scope,
nonce=nonce,
code_challenge=code_challenge,
)
code = authcode.store_oidc(oidc_code)
# Build redirect URL
params = {"code": code}
if state:
params["state"] = state
redirect_url = f"{redirect_uri}?{urlencode(params)}"
await ws.send_json({"redirect_url": redirect_url})
else:
# Normal mode: authenticate and create session
ctx, session_key = await authenticate_and_login(ws, auth)
# If reauth mode, verify the credential belongs to the session's user
if session_user_uuid and ctx.user.uuid != session_user_uuid:
raise ValueError("This passkey belongs to a different account")
# Create exchange code (ephemeral, 60s TTL)
exchange_code = create_exchange_code(session_key)
await ws.send_json(
{
"user": str(ctx.user.uuid),
"exchange_code": exchange_code,
}
)
+5 -5
View File
@@ -68,13 +68,13 @@ async def authenticate_chat(
async def authenticate_and_login(
ws: WebSocket,
auth: str | None = None,
) -> SessionContext:
) -> tuple[SessionContext, str]:
"""Run WebAuthn authentication flow, create session, and return the session context.
If auth is provided, restrict authentication to credentials of that session's user.
Returns:
SessionContext for the authenticated session
Tuple of (SessionContext for the authenticated session, session secret)
"""
origin = validate_origin(ws)
host = origin.split("://", 1)[1]
@@ -97,7 +97,7 @@ async def authenticate_and_login(
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
# Create session and update user/credential
token = db.login(
secret = db.login(
user_uuid=cred.user_uuid,
credential_uuid=cred.uuid,
sign_count=new_sign_count,
@@ -107,7 +107,7 @@ async def authenticate_and_login(
)
# Fetch and return the full session context
ctx = db.data().session_ctx(token, normalized_host)
ctx = db.data().session_ctx(secret, normalized_host)
if not ctx:
raise ValueError("Failed to create session context")
return ctx
return ctx, secret
+4 -1
View File
@@ -1,6 +1,6 @@
from typing import Generic, TypeVar
from paskia import db, remoteauth
from paskia import authcode, db, remoteauth
from paskia.bootstrap import bootstrap_if_needed
from paskia.sansio import Passkey
@@ -58,6 +58,9 @@ async def init(
# Initialize remote auth manager
await remoteauth.init()
# Initialize auth code manager
await authcode.start()
if bootstrap:
# Bootstrap system if needed
+118
View File
@@ -0,0 +1,118 @@
"""
OIDC Back-Channel Logout notifications.
When sessions are deleted (logout, admin, expiry), this module notifies
any OIDC clients that have a backchannel_logout_uri configured.
"""
import asyncio
import logging
from uuid import UUID
import httpx
from paskia import db
from paskia.util import oidjwt
from paskia.util.hostutil import _load_config
_logger = logging.getLogger(__name__)
# Timeout for back-channel logout requests
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
def _issuer() -> str:
"""Derive issuer URL from config (same base as discovery document)."""
cfg = _load_config()
return cfg.get("site_url", "https://localhost")
def _collect_oidc_sessions(
session_keys: list[str],
) -> list[tuple[str, str, UUID, UUID | None]]:
"""Collect (backchannel_logout_uri, sid, client_uuid, user_uuid) for OIDC sessions.
Must be called before the sessions are deleted from the database.
Returns only sessions whose client has a backchannel_logout_uri configured.
"""
notifications = []
data = db.data()
for key in session_keys:
session = data.sessions.get(key)
if not session or session.client_uuid is None:
continue
client = data.oidc.clients.get(session.client_uuid)
if not client or not client.backchannel_logout_uri:
continue
sid = session.key
notifications.append(
(client.backchannel_logout_uri, sid, session.client_uuid, session.user_uuid)
)
return notifications
async def _send_logout_token(
client: httpx.AsyncClient,
uri: str,
token: str,
) -> None:
"""POST a logout_token to a single client endpoint."""
try:
resp = await client.post(
uri,
data={"logout_token": token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status_code == 200:
_logger.debug("Back-channel logout OK: %s", uri)
else:
_logger.warning(
"Back-channel logout %s returned %d: %s",
uri,
resp.status_code,
resp.text[:200],
)
except Exception:
_logger.warning("Back-channel logout failed: %s", uri, exc_info=True)
async def notify(
notifications: list[tuple[str, str, UUID, UUID | None]],
) -> None:
"""Send back-channel logout tokens to all collected endpoints.
Args:
notifications: list of (backchannel_logout_uri, sid, client_uuid, user_uuid)
as returned by _collect_oidc_sessions.
"""
if not notifications:
return
issuer = _issuer()
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
tasks = []
for uri, sid, client_uuid, user_uuid in notifications:
token = oidjwt.create_logout_token(
issuer=issuer,
audience=str(client_uuid),
sid=sid,
sub=user_uuid,
)
tasks.append(_send_logout_token(client, uri, token))
await asyncio.gather(*tasks, return_exceptions=True)
def schedule_notifications(session_keys: list[str]) -> None:
"""Collect OIDC info from sessions (before deletion) and schedule async notifications.
Must be called BEFORE the sessions are deleted. The actual HTTP requests
are fire-and-forget via the running event loop.
"""
notifications = _collect_oidc_sessions(session_keys)
if not notifications:
return
try:
loop = asyncio.get_running_loop()
loop.create_task(notify(notifications))
except RuntimeError:
_logger.debug("No event loop for back-channel logout notifications")
+52 -35
View File
@@ -1,37 +1,20 @@
from __future__ import annotations
"""API response utilities using msgspec for JSON serialization.
msgspec handles UUID and datetime conversion automatically.
API structs inherit from db structs with kw_only=True to add uuid/key fields.
"""
from datetime import UTC, datetime
from __future__ import annotations
from datetime import datetime
from uuid import UUID
import msgspec
from paskia import db
from paskia.db.structs import Credential, Org, Permission, Role, User
from paskia.util import useragent
def _utc_datetime(dt: datetime | None) -> datetime | None:
"""Convert datetime to UTC, handling both aware and naive datetimes."""
if dt is None:
return None
if dt.tzinfo:
return dt.astimezone(UTC)
return dt.replace(tzinfo=UTC)
def format_datetime(dt: datetime | None) -> str | None:
"""Format a datetime to ISO 8601 string with Z suffix for UTC."""
if dt is None:
return None
utc_dt = _utc_datetime(dt)
return utc_dt.isoformat().replace("+00:00", "Z") if utc_dt else None
# -------------------------------------------------------------------------
# API structs - inherit from db structs, add uuid for serialization
# -------------------------------------------------------------------------
@@ -83,6 +66,24 @@ class ApiPermission(msgspec.Struct, kw_only=True):
)
class ApiOidcClient(msgspec.Struct, kw_only=True):
"""OIDC Client for API responses."""
name: str
redirect_uris: list[str]
backchannel_logout_uri: str | None = None
active_sessions: int = 0
@classmethod
def from_db(cls, c, active_sessions: int = 0):
return cls(
name=c.name,
redirect_uris=c.redirect_uris,
backchannel_logout_uri=c.backchannel_logout_uri,
active_sessions=active_sessions,
)
class ApiAaguidInfo(msgspec.Struct, kw_only=True, omit_defaults=True):
"""AAGUID information for authenticators."""
@@ -91,17 +92,19 @@ class ApiAaguidInfo(msgspec.Struct, kw_only=True, omit_defaults=True):
icon_dark: str | None = None
class ApiUserSession(msgspec.Struct):
class ApiUserSession(msgspec.Struct, omit_defaults=True):
"""Session for user info responses with computed fields."""
credential_uuid: UUID = msgspec.field(name="credential")
host: str
ip: str
user_agent: str
expiry: datetime
validated: datetime
last_renewed: datetime
is_current: bool = False
is_current_host: bool = False
client_uuid: UUID | None = msgspec.field(name="client", default=None)
client_name: str | None = None
@classmethod
def from_db(
@@ -110,19 +113,23 @@ class ApiUserSession(msgspec.Struct):
*,
current_key: str,
normalized_host: str | None,
expires_delta, # timedelta
) -> ApiUserSession:
client_name = None
if s.client_uuid:
c = db.data().oidc.clients.get(s.client_uuid)
client_name = c.name if c else str(s.client_uuid)
return cls(
credential_uuid=s.credential_uuid,
host=s.host,
ip=s.ip,
user_agent=useragent.compact_user_agent(s.user_agent),
expiry=s.expiry,
last_renewed=s.expiry - expires_delta,
validated=s.validated,
last_renewed=s.validated,
is_current=s.key == current_key,
is_current_host=bool(
normalized_host and s.host and s.host == normalized_host
),
is_current_host=not s.client_uuid
and bool(normalized_host and s.host and s.host == normalized_host),
client_uuid=s.client_uuid,
client_name=client_name,
)
@@ -132,8 +139,10 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
user: ApiUser
credentials: dict[UUID, Credential]
aaguid_info: dict[str, ApiAaguidInfo]
sessions: list[ApiUserSession]
sessions: dict[bytes, ApiUserSession]
permissions: dict[UUID, ApiPermission] = {}
org: ApiOrg | None = None
role: ApiRole | None = None
# -------------------------------------------------------------------------
@@ -144,7 +153,7 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
class ApiOrgResponse(msgspec.Struct, kw_only=True):
"""Org response containing Org with roles and users as UUID-keyed dicts."""
org: Org
org: ApiOrg
permissions: dict[UUID, Permission]
roles: dict[UUID, Role]
users: dict[UUID, User]
@@ -179,7 +188,7 @@ class ApiCreateLinkResponse(msgspec.Struct):
"""Response struct for create-link endpoints."""
url: str
expires: str
expires: datetime
token_type: str
message: str | None = None
@@ -187,7 +196,7 @@ class ApiCreateLinkResponse(msgspec.Struct):
class ApiUserContext(msgspec.Struct, omit_defaults=True):
"""User context for session validation."""
uuid: str
uuid: UUID
display_name: str
theme: str = ""
@@ -195,14 +204,14 @@ class ApiUserContext(msgspec.Struct, omit_defaults=True):
class ApiOrgContext(msgspec.Struct):
"""Org context for session validation."""
uuid: str
uuid: UUID
display_name: str
class ApiRoleContext(msgspec.Struct):
"""Role context for session validation."""
uuid: str
uuid: UUID
display_name: str
@@ -221,3 +230,11 @@ class ApiValidateResponse(msgspec.Struct):
valid: bool
renewed: bool
ctx: ApiSessionContext
class ApiAdminInfo(msgspec.Struct, kw_only=True):
"""Combined admin info response."""
orgs: dict[UUID, ApiOrgResponse]
permissions: dict[UUID, ApiPermission]
oidc_clients: dict[UUID, ApiOidcClient] = {}
+52
View File
@@ -0,0 +1,52 @@
import hashlib
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
def hash_secret(*data) -> bytes:
"""A custom HMAC that securily combines and hashes the given data (context, secrets). The first argument should be a namespacing string."""
inner = bytearray(len(data).to_bytes(8, "big"))
for d in data:
if isinstance(d, str):
d = d.encode()
inner += hashlib.sha256(d).digest()
return hashlib.sha256(inner).digest()[:12]
def secret_key() -> bytes:
"""Generate a new Ed25519 private key and return as 32 raw bytes."""
private_key = Ed25519PrivateKey.generate()
return private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
def public_key_from_secret(secret_key_bytes: bytes) -> Ed25519PrivateKey:
"""Load Ed25519 private key from 32 raw bytes."""
return Ed25519PrivateKey.from_private_bytes(secret_key_bytes)
def get_public_key_der(private_key: Ed25519PrivateKey) -> bytes:
"""Get DER-encoded public key for kid generation."""
public_key = private_key.public_key()
return public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
def generate_kid(public_key_der: bytes) -> str:
"""Generate key ID from public key DER bytes."""
return hashlib.sha256(public_key_der).hexdigest()[:16]
def get_public_key_raw(private_key: Ed25519PrivateKey) -> bytes:
"""Get raw 32-byte public key for JWKS."""
public_key = private_key.public_key()
return public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
+37
View File
@@ -0,0 +1,37 @@
"""Name and username utilities."""
import re
import unicodedata
def slugify_name(name: str) -> str:
"""Convert display name to slug-compatible username.
Uses dots as separators, preserves existing dots and dashes.
Strips trailing parenthesized content and other unwanted punctuation.
Examples:
'John Doe''john.doe'
'María José García''maria.jose.garcia'
'Jean-Pierre''jean-pierre'
'John.Doe''john.doe'
'John Doe (Admin)''john.doe'
' Multiple Spaces ''multiple.spaces'
Returns empty string for empty/whitespace-only input.
"""
if not name:
return ""
# Strip trailing parenthesized content (e.g., " (Admin)")
name = re.sub(r"\s*\([^)]*\)\s*$", "", name)
# Normalize unicode → ASCII equivalent (é → e, ñ → n)
name = unicodedata.normalize("NFKD", name)
name = name.encode("ascii", "ignore").decode("ascii")
# Lowercase
name = name.lower()
# Replace non-alphanumeric (except . and -) with dots
name = re.sub(r"[^a-z0-9.-]+", ".", name)
# Collapse multiple dots
name = re.sub(r"\.+", ".", name)
# Strip leading/trailing dots
return name.strip(".")
+230
View File
@@ -0,0 +1,230 @@
"""
OIDC JWT utilities for signing ID tokens and serving JWKS.
"""
import hashlib
from base64 import urlsafe_b64encode
from datetime import UTC, datetime, timedelta
from uuid import UUID
import jwt
from paskia import db
from paskia.util.crypto import (
generate_kid,
get_public_key_der,
get_public_key_raw,
public_key_from_secret,
secret_key,
)
# JWT signing key (loaded on first use)
_private_key = None
_public_key = None
_kid: str | None = None
def _load_or_generate_key() -> None:
"""Load existing Ed25519 key or generate a new one."""
global _private_key, _public_key, _kid
data = db.data()
if data.oidc.key is not None:
_private_key = public_key_from_secret(data.oidc.key)
else:
raw_key = secret_key()
with data.transaction("oidc_key"):
data.oidc.key = raw_key
_private_key = public_key_from_secret(raw_key)
_public_key = _private_key.public_key()
# Generate kid from public key fingerprint
pub_der = get_public_key_der(_private_key)
_kid = generate_kid(pub_der)
def _ensure_key() -> None:
"""Ensure key is loaded."""
if _private_key is None:
_load_or_generate_key()
def get_jwks() -> dict:
"""Get JWKS (JSON Web Key Set) for public key verification."""
_ensure_key()
assert _public_key is not None
# Ed25519 public key is 32 bytes raw
pub_bytes = get_public_key_raw(_private_key)
return {
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"use": "sig",
"alg": "EdDSA",
"kid": _kid,
"x": urlsafe_b64encode(pub_bytes).rstrip(b"=").decode("ascii"),
}
]
}
def create_id_token(
issuer: str,
subject: UUID,
audience: str, # client_id
nonce: str | None = None,
sid: str | None = None,
name: str | None = None,
preferred_username: str | None = None,
email: str | None = None,
groups: list[str] | None = None,
auth_time: datetime | None = None,
expires_in: int = 3600,
) -> str:
"""Create a signed ID token (JWT).
Args:
issuer: Token issuer (site URL)
subject: User UUID (sub claim)
audience: Client ID (aud claim)
nonce: Nonce from authorization request
sid: Session ID for backchannel logout
name: User's display name
preferred_username: User's preferred username
email: User's email address
groups: List of permission scopes (groups claim)
auth_time: When the user authenticated (last credential use time)
expires_in: Token lifetime in seconds
Returns:
Signed JWT string
"""
_ensure_key()
now = datetime.now(UTC)
payload = {
"iss": issuer,
"sub": str(subject),
"aud": audience,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=expires_in)).timestamp()),
}
if nonce:
payload["nonce"] = nonce
if sid:
payload["sid"] = sid
if name:
payload["name"] = name
if preferred_username:
payload["preferred_username"] = preferred_username
if email:
payload["email"] = email
if groups:
payload["groups"] = groups
if auth_time:
payload["auth_time"] = int(auth_time.timestamp())
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
def create_access_token(
issuer: str,
subject: UUID,
audience: str,
scope: str,
expires_in: int = 3600,
) -> str:
"""Create a signed access token (JWT) for userinfo endpoint.
Args:
issuer: Token issuer (site URL)
subject: User UUID
audience: Client ID
scope: Granted scopes
expires_in: Token lifetime in seconds
Returns:
Signed JWT string
"""
_ensure_key()
now = datetime.now(UTC)
payload = {
"iss": issuer,
"sub": str(subject),
"aud": audience,
"scope": scope,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=expires_in)).timestamp()),
}
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
def decode_access_token(
token: str, issuer: str, audience: str | None = None
) -> dict | None:
"""Decode and verify an access token.
Args:
token: JWT string
issuer: Expected issuer
audience: Optional expected audience (client_id). If provided, aud claim must match.
Returns:
Decoded payload or None if invalid
"""
_ensure_key()
try:
# PyJWT requires audience parameter when token has aud claim.
# When audience is None, we skip PyJWT's audience validation and validate manually.
options = {}
decode_kwargs = {
"algorithms": ["EdDSA"],
"issuer": issuer,
}
if audience is not None:
decode_kwargs["audience"] = audience
else:
options["verify_aud"] = False
return jwt.decode(token, _public_key, options=options, **decode_kwargs)
except jwt.PyJWTError:
return None
def create_logout_token(
issuer: str,
audience: str,
sid: str | None = None,
sub: UUID | None = None,
) -> str:
"""Create a signed logout token for back-channel logout notification.
Per OIDC Back-Channel Logout 1.0, the logout token must contain
either sid (session) or sub (user), or both.
Args:
issuer: Token issuer (site URL)
audience: Client ID (aud claim)
sid: Session ID (base64url-encoded)
sub: User UUID
Returns:
Signed JWT string
"""
_ensure_key()
now = datetime.now(UTC)
payload = {
"iss": issuer,
"aud": audience,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=120)).timestamp()),
"events": {"http://schemas.openid.net/event/backchannel-logout": {}},
"jti": hashlib.sha256(
f"{now.timestamp()}{audience}{sid}{sub}".encode()
).hexdigest()[:16],
}
if sid:
payload["sid"] = sid
if sub:
payload["sub"] = str(sub)
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
+2 -3
View File
@@ -1,8 +1,7 @@
"""Utility functions for session validation and checking."""
"""Utility functions for session validation, derivation, and checking."""
from datetime import UTC, datetime
from paskia.authsession import EXPIRES
from paskia.db import SessionContext
from paskia.util.timeutil import parse_duration
@@ -32,7 +31,7 @@ def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
if ctx.credential and ctx.credential.last_used:
auth_time = ctx.credential.last_used
else:
auth_time = ctx.session.expiry - EXPIRES
auth_time = ctx.session.validated
time_since_auth = datetime.now(UTC) - auth_time
return time_since_auth <= max_age_delta
+5 -8
View File
@@ -1,7 +1,6 @@
"""User information formatting and retrieval logic."""
from paskia import aaguid, db
from paskia.authsession import EXPIRES
from paskia.db import SessionContext
from paskia.util import hostutil
from paskia.util.apistructs import (
@@ -37,8 +36,7 @@ def build_session_context(ctx: SessionContext) -> ApiSessionContext:
async def build_user_info(
*,
user_uuid,
auth: str,
session_record,
session_key: str,
request_host: str | None,
ctx: SessionContext | None = None,
) -> ApiUserDetail:
@@ -46,15 +44,14 @@ async def build_user_info(
user = db.data().users[user_uuid]
normalized_host = hostutil.normalize_host(request_host)
sessions = [
ApiUserSession.from_db(
sessions = {
s.key: ApiUserSession.from_db(
s,
current_key=auth,
current_key=session_key,
normalized_host=normalized_host,
expires_delta=EXPIRES,
)
for s in user.sessions
]
}
return ApiUserDetail(
user=ApiUser.from_db(user),
+1 -1
View File
@@ -17,7 +17,7 @@ dependencies = [
"webauthn>=1.11.1",
"base64url>=1.0.0",
"uuid7-standard>=1.0.0",
"pyjwt>=2.8.0",
"pyjwt[crypto]>=2.8.0",
"jsondiff>=2.2.1",
"msgspec>=0.20.0",
"aiofiles>=25.1.0",
+53 -10
View File
@@ -9,12 +9,17 @@ Since we can't emulate WebAuthn passkeys, we create sessions directly
in the database to test authenticated endpoints.
"""
from __future__ import annotations
import asyncio
import os
import secrets
import tempfile
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from uuid import UUID
import base64url
import httpx
import pytest
import pytest_asyncio
@@ -22,6 +27,7 @@ import pytest_asyncio
import paskia.db.operations as ops_db
from paskia import globals as paskia_globals
from paskia.authsession import reset_expires
from paskia.config import SESSION_LIFETIME
from paskia.db import (
Config,
Credential,
@@ -33,14 +39,15 @@ from paskia.db import (
create_credential,
create_reset_token,
create_role,
create_session,
create_user,
)
from paskia.db.jsonl import JsonlStore
from paskia.db.operations import DB
from paskia.db.structs import Session
from paskia.fastapi.mainapp import app
from paskia.fastapi.session import AUTH_COOKIE_NAME
from paskia.sansio import Passkey
from paskia.util.crypto import hash_secret
@pytest.fixture(scope="session")
@@ -60,7 +67,6 @@ async def test_db() -> AsyncGenerator[DB, None]:
- A default organization with Administration role
- An admin user with the Administration role
"""
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
db = DB(config=Config(rp_id="test.example.com"))
store = JsonlStore(db, f.name)
@@ -179,13 +185,11 @@ async def session_token(
test_db: DB, test_user: User, test_credential: Credential
) -> str:
"""Create a session for the admin user and return the token."""
return create_session(
_db_key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
)
return secret
@pytest_asyncio.fixture(scope="function")
@@ -193,13 +197,11 @@ async def regular_session_token(
test_db: DB, regular_user: User, regular_credential: Credential
) -> str:
"""Create a session for a regular user and return the token."""
return create_session(
_db_key, secret = create_test_session(
user_uuid=regular_user.uuid,
credential_uuid=regular_credential.uuid,
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
)
return secret
@pytest_asyncio.fixture(scope="function")
@@ -241,3 +243,44 @@ def auth_cookie(token: str) -> httpx.Cookies:
cookies = httpx.Cookies()
cookies.set(AUTH_COOKIE_NAME, token, domain="localhost")
return cookies
def create_test_session(
user_uuid: UUID,
credential_uuid: UUID,
host: str = "localhost",
ip: str = "127.0.0.1",
user_agent: str = "pytest",
duration: timedelta | None = None,
) -> tuple[str, str]:
"""Create a test session. Returns (key, token) tuple.
- key: str used for session lookup (base64url encoded)
- token: stored in cookie/sent to client
"""
if duration is None:
duration = SESSION_LIFETIME
if user_uuid not in ops_db._db.users:
raise ValueError(f"User {user_uuid} not found")
if credential_uuid not in ops_db._db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
now = datetime.now(UTC)
# Generate token and derive key
token = secrets.token_urlsafe(12)
key = base64url.enc(hash_secret("cookie", token))
session = Session.create(
user=user_uuid,
credential=credential_uuid,
key=key,
host=host,
ip=ip,
user_agent=user_agent,
validated=now,
)
if session.key in ops_db._db.sessions:
raise ValueError("Session already exists")
with ops_db._db.transaction("create_test_session"):
session.store(now)
return session.key, token
+48 -40
View File
@@ -16,6 +16,7 @@ import secrets
from datetime import UTC, datetime
from uuid import UUID
import base64url
import httpx
import pytest
import pytest_asyncio
@@ -33,11 +34,11 @@ from paskia.db import (
create_org,
create_permission,
create_role,
create_session,
create_user,
)
from paskia.db.operations import DB
from tests.conftest import auth_headers
from paskia.util.crypto import hash_secret
from tests.conftest import auth_headers, create_test_session
# -------------------- Additional Fixtures --------------------
@@ -97,13 +98,11 @@ async def second_org_session_token(
test_db: DB, second_org_user: User, second_org_credential: Credential
) -> str:
"""Create a session for the second org admin user."""
return create_session(
_db_key, secret = create_test_session(
user_uuid=second_org_user.uuid,
credential_uuid=second_org_credential.uuid,
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
)
return secret
@pytest_asyncio.fixture(scope="function")
@@ -153,13 +152,11 @@ async def org_admin_session_token(
test_db: DB, org_admin_user: User, org_admin_credential: Credential
) -> str:
"""Create a session for the org admin user."""
return create_session(
_db_key, secret = create_test_session(
user_uuid=org_admin_user.uuid,
credential_uuid=org_admin_credential.uuid,
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
)
return secret
@pytest_asyncio.fixture(scope="function")
@@ -182,7 +179,7 @@ class TestExceptionHandlers:
async def test_auth_exception_handler(self, client: httpx.AsyncClient):
"""AuthException should return proper JSON with auth info."""
# Accessing admin without auth triggers AuthException
response = await client.get("/auth/api/admin/orgs")
response = await client.get("/auth/api/admin/info")
assert response.status_code == 401
data = response.json()
assert "detail" in data
@@ -219,7 +216,7 @@ class TestAdminOrganizations:
@pytest.mark.asyncio
async def test_list_orgs_requires_auth(self, client: httpx.AsyncClient):
"""List orgs without auth should return 401."""
response = await client.get("/auth/api/admin/orgs")
response = await client.get("/auth/api/admin/info")
assert response.status_code == 401
@pytest.mark.asyncio
@@ -228,7 +225,7 @@ class TestAdminOrganizations:
):
"""List orgs without admin permission should return 403."""
response = await client.get(
"/auth/api/admin/orgs",
"/auth/api/admin/info",
headers={
**auth_headers(regular_session_token),
"Host": "localhost:4401",
@@ -242,19 +239,24 @@ class TestAdminOrganizations:
):
"""Admin user should be able to list organizations."""
response = await client.get(
"/auth/api/admin/orgs",
"/auth/api/admin/info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
assert isinstance(data, dict)
assert "orgs" in data
orgs_data = data["orgs"]
assert isinstance(orgs_data, dict)
assert len(orgs_data) >= 1
# Check org structure
org = data[0]
org_data = list(orgs_data.values())[0]
assert "org" in org_data
org = org_data["org"]
assert "uuid" in org
assert "display_name" in org
assert "roles" in org
assert "users" in org
assert "roles" in org_data
assert "users" in org_data
@pytest.mark.asyncio
async def test_list_orgs_with_org_admin(
@@ -266,13 +268,13 @@ class TestAdminOrganizations:
):
"""Org admin should only see their own organization."""
response = await client.get(
"/auth/api/admin/orgs",
"/auth/api/admin/info",
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
# Should only see their own org, not the second org
org_uuids = [o["uuid"] for o in data]
org_uuids = [org_data["org"]["uuid"] for org_data in data["orgs"].values()]
assert str(test_org.uuid) in org_uuids
@pytest.mark.asyncio
@@ -794,7 +796,8 @@ class TestAdminUsersInOrg:
)
assert response.status_code == 200
data = response.json()
assert "display_name" in data
assert "user" in data
assert "display_name" in data["user"]
assert "credentials" in data
assert "sessions" in data
assert "aaguid_info" in data
@@ -845,7 +848,8 @@ class TestAdminUsersInOrg:
)
assert response.status_code == 200
data = response.json()
assert "display_name" in data
assert "user" in data
assert "display_name" in data["user"]
@pytest.mark.asyncio
async def test_update_user_display_name_in_org(
@@ -853,7 +857,7 @@ class TestAdminUsersInOrg:
):
"""Admin should be able to update user display name."""
response = await client.patch(
f"/auth/api/admin/users/{test_user.uuid}/display-name",
f"/auth/api/admin/users/{test_user.uuid}/info",
json={"display_name": "Updated Admin Name"},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
@@ -866,7 +870,7 @@ class TestAdminUsersInOrg:
"""Updating non-existent user should return 404."""
fake_uuid = uuid7.create()
response = await client.patch(
f"/auth/api/admin/users/{fake_uuid}/display-name",
f"/auth/api/admin/users/{fake_uuid}/info",
json={"display_name": "New Name"},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
@@ -884,7 +888,7 @@ class TestAdminUsersInOrg:
):
"""Org admin cannot update user from another org."""
response = await client.patch(
f"/auth/api/admin/users/{second_org_user.uuid}/display-name",
f"/auth/api/admin/users/{second_org_user.uuid}/info",
json={"display_name": "New Name"},
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
)
@@ -896,13 +900,13 @@ class TestAdminUsersInOrg:
):
"""Updating user with empty display name should fail."""
response = await client.patch(
f"/auth/api/admin/users/{test_user.uuid}/display-name",
f"/auth/api/admin/users/{test_user.uuid}/info",
json={"display_name": " "},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
data = response.json()
assert "display_name required" in data["detail"]
assert "display_name cannot be empty" in data["detail"]
@pytest.mark.asyncio
async def test_update_user_display_name_too_long(
@@ -910,13 +914,13 @@ class TestAdminUsersInOrg:
):
"""Updating user with too long display name should fail."""
response = await client.patch(
f"/auth/api/admin/users/{test_user.uuid}/display-name",
json={"display_name": "x" * 100},
f"/auth/api/admin/users/{test_user.uuid}/info",
json={"display_name": "x" * 65},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
data = response.json()
assert "too long" in data["detail"]
assert "display_name too long" in data["detail"]
@pytest.mark.asyncio
async def test_update_user_role_in_org(
@@ -1290,7 +1294,7 @@ class TestAdminSessions:
):
"""Admin should be able to delete a user's session."""
# Create an additional session to delete
extra_token = create_session(
extra_db_key, _extra_secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
host="other.host:4401",
@@ -1299,7 +1303,7 @@ class TestAdminSessions:
)
response = await client.delete(
f"/auth/api/admin/users/{test_user.uuid}/sessions/{extra_token}",
f"/auth/api/admin/users/{test_user.uuid}/sessions/{extra_db_key}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
@@ -1316,8 +1320,9 @@ class TestAdminSessions:
test_user,
):
"""Admin can delete their own current session."""
session_db_key = base64url.enc(hash_secret("cookie", session_token))
response = await client.delete(
f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_token}",
f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_db_key}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
@@ -1394,14 +1399,17 @@ class TestAdminPermissions:
):
"""Admin should be able to list all permissions."""
response = await client.get(
"/auth/api/admin/permissions",
"/auth/api/admin/info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert isinstance(data, dict)
assert "permissions" in data
permissions_data = data["permissions"]
assert isinstance(permissions_data, dict)
# Should include at least auth:admin
perm_scopes = [p["scope"] for p in data]
perm_scopes = [p["scope"] for p in permissions_data.values()]
assert "auth:admin" in perm_scopes
@pytest.mark.asyncio
@@ -1414,13 +1422,13 @@ class TestAdminPermissions:
):
"""Org admin should only see permissions their org can grant."""
response = await client.get(
"/auth/api/admin/permissions",
"/auth/api/admin/info",
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
# Should only see permissions the org can grant
perm_scopes = [p["scope"] for p in data]
perm_scopes = [p["scope"] for p in data["permissions"].values()]
assert grantable_permission.scope in perm_scopes
# test_org CAN grant auth:admin (it's in org.permissions), so org admin sees it
assert "auth:admin" in perm_scopes
@@ -1704,7 +1712,7 @@ class TestOrgAdminAuthExceptions:
):
"""Regular user trying to update display name should get 403."""
response = await client.patch(
f"/auth/api/admin/users/{test_user.uuid}/display-name",
f"/auth/api/admin/users/{test_user.uuid}/info",
json={"display_name": "New Name"},
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
)
+29 -22
View File
@@ -11,15 +11,16 @@ These tests cover:
"""
import secrets
from datetime import timedelta
from datetime import UTC, datetime, timedelta
import httpx
import pytest
from paskia import authcode
from paskia.authsession import EXPIRES
from paskia.db import create_session, delete_session
from paskia.db import delete_session
from paskia.util.passphrase import generate
from tests.conftest import auth_headers
from tests.conftest import auth_headers, create_test_session
class TestSettingsEndpoint:
@@ -229,12 +230,12 @@ class TestLogoutEndpoint:
class TestUserInfoEndpoint:
"""Tests for POST /auth/api/user-info"""
"""Tests for GET /auth/api/user-info"""
@pytest.mark.asyncio
async def test_user_info_without_auth_returns_401(self, client: httpx.AsyncClient):
"""User info without session should return 401."""
response = await client.post("/auth/api/user-info")
response = await client.get("/auth/api/user-info")
assert response.status_code == 401
@pytest.mark.asyncio
@@ -242,22 +243,22 @@ class TestUserInfoEndpoint:
self, client: httpx.AsyncClient, session_token: str, test_user
):
"""User info with valid session should return user data."""
response = await client.post(
response = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "ctx" in data
assert data["ctx"]["user"]["uuid"] == str(test_user.uuid)
assert data["ctx"]["user"]["display_name"] == test_user.display_name
assert "user" in data
assert data["user"]["uuid"] == str(test_user.uuid)
assert data["user"]["display_name"] == test_user.display_name
@pytest.mark.asyncio
async def test_user_info_includes_credentials(
self, client: httpx.AsyncClient, session_token: str
):
"""User info should include user's credentials."""
response = await client.post(
response = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
@@ -271,7 +272,7 @@ class TestUserInfoEndpoint:
self, client: httpx.AsyncClient, session_token: str
):
"""User info should include user's active sessions."""
response = await client.post(
response = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
@@ -285,36 +286,42 @@ class TestUserInfoEndpoint:
self, client: httpx.AsyncClient, session_token: str
):
"""User info should include user's permissions."""
response = await client.post(
response = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "ctx" in data
assert "permissions" in data["ctx"]
assert "permissions" in data
class TestSetSessionEndpoint:
"""Tests for POST /auth/api/set-session"""
@pytest.mark.asyncio
async def test_set_session_without_bearer_returns_401(
async def test_set_session_without_bearer_returns_400(
self, client: httpx.AsyncClient
):
"""Set session without bearer token should return 401."""
"""Set session without bearer token should return 400."""
response = await client.post("/auth/api/set-session")
assert response.status_code == 401
assert response.status_code == 400
@pytest.mark.asyncio
async def test_set_session_with_valid_bearer_token(
self, client: httpx.AsyncClient, session_token: str
):
"""Set session with valid bearer token should set cookie."""
"""Set session with valid auth code as bearer should set cookie."""
code = authcode.store_cookie(
authcode.CookieCode(
session_key=session_token,
created=datetime.now(UTC),
)
)
response = await client.post(
"/auth/api/set-session",
headers={
"Authorization": f"Bearer {session_token}",
"Authorization": f"Bearer {code}",
"Host": "localhost:4401",
},
)
@@ -522,7 +529,7 @@ class TestValidateSessionRefresh:
"""Validate should return 401 if session disappears during refresh."""
# Create a session with a short remaining duration to trigger refresh
token = create_session(
db_key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
host="localhost",
@@ -532,11 +539,11 @@ class TestValidateSessionRefresh:
)
# Delete the session right before validate tries to refresh
delete_session(token)
delete_session(db_key)
response = await client.post(
"/auth/api/validate",
headers={**auth_headers(token), "Host": "localhost:4401"},
headers={**auth_headers(secret), "Host": "localhost:4401"},
)
# Session was found initially but disappeared during refresh
assert response.status_code == 401