From a62e8ddf1eab3c651a6bcaad29314366af72c3c3 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 2 Dec 2025 03:10:16 +0000 Subject: [PATCH] Implement restricted-api for JS-driven auth calls, examples added (WIP!). Layout and styling simplified. --- examples/index.html | 24 ++ examples/restricted-api.html | 242 ++++++++++++++++++ examples/style.css | 22 ++ frontend/restricted-api/index.html | 1 + frontend/src/admin/AdminApp.vue | 148 ++++++----- frontend/src/assets/style.css | 56 ++-- frontend/src/components/DeviceLinkView.vue | 38 ++- frontend/src/components/Modal.vue | 6 +- frontend/src/components/ProfileView.vue | 159 ++++++------ frontend/src/components/RestrictedAuth.vue | 206 +++++++++++++++ frontend/src/host/HostApp.vue | 104 ++++---- frontend/src/reset/ResetApp.vue | 82 +++--- .../src/restricted-api/RestrictedApiApp.vue | 123 +++++++++ frontend/src/restricted-api/main.js | 5 + frontend/src/restricted/RestrictedApp.vue | 163 ++---------- frontend/vite.config.js | 64 ++--- passkey/fastapi/mainapp.py | 17 +- passkey/util/frontend.py | 2 +- 18 files changed, 972 insertions(+), 490 deletions(-) create mode 100644 examples/index.html create mode 100644 examples/restricted-api.html create mode 100644 examples/style.css create mode 100644 frontend/restricted-api/index.html create mode 100644 frontend/src/components/RestrictedAuth.vue create mode 100644 frontend/src/restricted-api/RestrictedApiApp.vue create mode 100644 frontend/src/restricted-api/main.js diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..e13490f --- /dev/null +++ b/examples/index.html @@ -0,0 +1,24 @@ + + + + + + PassKey Auth - Examples + + + +
+
+

🔐 PassKey Auth Examples

+

Interactive demos and code examples for PassKey authentication

+
+ + diff --git a/examples/restricted-api.html b/examples/restricted-api.html new file mode 100644 index 0000000..da3e8de --- /dev/null +++ b/examples/restricted-api.html @@ -0,0 +1,242 @@ + + + + + + Restricted API Demo + + + +
+
+

🔐 Restricted API Demo

+

Demonstrates iframe-based authentication using postMessage

+
+ +
+
+

Authentication Modes

+ + + +
+ +
+

API Tests

+ + + +
+ + + +
+

Message Events

+
Waiting for messages...
+
+
+
+ + + diff --git a/examples/style.css b/examples/style.css new file mode 100644 index 0000000..4ce7ffd --- /dev/null +++ b/examples/style.css @@ -0,0 +1,22 @@ +:root { + color-scheme: light dark; +} + +body:has(iframe) { + overflow: hidden; /* prevent scrolling the page */ +} + +iframe { + /* Fullscreen overlay */ + border: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + /* Transparent background with a backdrop effect (optional) */ + color-scheme: auto; + backdrop-filter: blur(4px) brightness(0.7); + -webkit-backdrop-filter: blur(4px) brightness(0.7); +} diff --git a/frontend/restricted-api/index.html b/frontend/restricted-api/index.html new file mode 100644 index 0000000..97be4a7 --- /dev/null +++ b/frontend/restricted-api/index.html @@ -0,0 +1 @@ +
diff --git a/frontend/src/admin/AdminApp.vue b/frontend/src/admin/AdminApp.vue index 9ae327d..d790324 100644 --- a/frontend/src/admin/AdminApp.vue +++ b/frontend/src/admin/AdminApp.vue @@ -56,7 +56,7 @@ const permissionSummary = computed(() => { for (const o of orgs.value) { const orgBase = { uuid: o.uuid, display_name: o.display_name } const orgPerms = new Set(o.permissions || []) - + // Org-level permissions (direct) - only count if org can grant them for (const pid of o.permissions || []) { if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } @@ -65,13 +65,13 @@ const permissionSummary = computed(() => { summary[pid].orgSet.add(o.uuid) } } - + // Role-based permissions (inheritance) - only count if org can grant them for (const r of o.roles) { for (const pid of r.permissions) { // Only count if the org can grant this permission if (!orgPerms.has(pid)) continue - + if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 } if (!summary[pid].orgSet.has(o.uuid)) { summary[pid].orgs.push(orgBase) @@ -253,14 +253,14 @@ function deleteRole(role) { async function toggleRolePermission(role, pid, checked) { // Calculate new permissions array - const newPermissions = checked - ? [...role.permissions, pid] + const newPermissions = checked + ? [...role.permissions, pid] : role.permissions.filter(p => p !== pid) - + // Optimistic update const prevPermissions = [...role.permissions] role.permissions = newPermissions - + try { const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', @@ -450,7 +450,7 @@ async function submitDialog() { const newDisplay = dialog.value.data.display_name?.trim() if (!newDisplay) throw new Error('Display name required') if (!newId) throw new Error('ID required') - + if (newId !== permission.id) { // ID changed, use rename endpoint const body = { old_id: permission.id, new_id: newId, display_name: newDisplay } @@ -484,76 +484,74 @@ async function submitDialog() {
-
-
-
-

{{ pageHeading }}

- -
+
+
+

{{ pageHeading }}

+ +
-
-
-
Loading…
-
{{ error }}
- +
+
-
diff --git a/frontend/src/reset/ResetApp.vue b/frontend/src/reset/ResetApp.vue index ba340d1..459c7f5 100644 --- a/frontend/src/reset/ResetApp.vue +++ b/frontend/src/reset/ResetApp.vue @@ -7,52 +7,50 @@
-
-
-
-

🔑 Registration

-

- {{ subtitleMessage }} -

-
+
+
+

🔑 Registration

+

+ {{ subtitleMessage }} +

+
-
-
-

Loading reset details…

+
+
+

Loading reset details…

+
+
+ +
+
+

{{ errorMessage }}

+
+
-
+
+
-
-
-

{{ errorMessage }}

-
- -
-
-
- -
-
- - -
-
-
+ maxlength="64" + @keyup.enter="registerPasskey" + /> + + +
+
diff --git a/frontend/src/restricted-api/RestrictedApiApp.vue b/frontend/src/restricted-api/RestrictedApiApp.vue new file mode 100644 index 0000000..7dd6cce --- /dev/null +++ b/frontend/src/restricted-api/RestrictedApiApp.vue @@ -0,0 +1,123 @@ + + + diff --git a/frontend/src/restricted-api/main.js b/frontend/src/restricted-api/main.js new file mode 100644 index 0000000..71c4ba8 --- /dev/null +++ b/frontend/src/restricted-api/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import RestrictedApiApp from './RestrictedApiApp.vue' +import '@/assets/style.css' + +createApp(RestrictedApiApp).mount('#app') diff --git a/frontend/src/restricted/RestrictedApp.vue b/frontend/src/restricted/RestrictedApp.vue index 3056e6a..ecd9654 100644 --- a/frontend/src/restricted/RestrictedApp.vue +++ b/frontend/src/restricted/RestrictedApp.vue @@ -1,137 +1,32 @@ - - diff --git a/frontend/vite.config.js b/frontend/vite.config.js index e3b29ec..5d5144f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,20 +1,35 @@ import { fileURLToPath, URL } from 'node:url' - import { defineConfig } from 'vite' import { resolve } from 'node:path' import vue from '@vitejs/plugin-vue' +import { readFileSync, existsSync, statSync } from 'node:fs' -// https://vite.dev/config/ -export default defineConfig(({ command, mode }) => ({ +export default defineConfig(({ command }) => ({ + appType: 'mpa', plugins: [ vue(), + { + name: 'serve-examples', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = req.url?.split('?')[0] + if (url === '/examples') return res.writeHead(301, { Location: '/examples/' }).end() + if (url?.startsWith('/examples/')) { + const file = resolve(__dirname, '../examples', url === '/examples/' ? 'index.html' : url.slice(10)) + if (existsSync(file) && statSync(file).isFile()) { + res.setHeader('Content-Type', { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript' }[file.slice(file.lastIndexOf('.'))] || 'text/plain') + return res.end(readFileSync(file)) + } + return res.writeHead(404).end() + } + next() + }) + } + } ], resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - }, + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, - // Use absolute paths at dev, deploy under /auth/ base: command === 'build' ? '/auth/' : '/', server: { port: 4403, @@ -22,26 +37,16 @@ export default defineConfig(({ command, mode }) => ({ '/auth/': { target: 'http://localhost:4402', ws: true, - changeOrigin: false, - // We proxy API + WS under /auth/, but want Vite to serve the SPA entrypoints - // and static assets so that HMR works. Bypass tells http-proxy to skip - // proxying when we return a (possibly rewritten) local path. - bypass(req) { - const rawUrl = req.url || '' - // Strip query/hash to match path-only for SPA entrypoints with query params (e.g. ?reset=token) - const url = rawUrl.split('?')[0].split('#')[0] - // Bypass only root SPA entrypoints + static assets so Vite serves them for HMR. - // Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend. - if (url === '/auth/' || url === '/auth') return '/' - if (url === '/auth/host' || url === '/auth/host/') return '/host/index.html' - if (url === '/host' || url === '/host/') return '/host/index.html' - if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/' - if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '') - if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html' - if (/^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html' - if (url === '/auth/restricted' || url === '/auth/restricted/') return '/restricted/index.html' - if (url === '/restricted' || url === '/restricted/') return '/restricted/index.html' - // Everything else (including /auth/admin/* APIs) should proxy. + bypass: (req) => { + const url = req.url?.split('?')[0] + if (url?.startsWith('/auth/assets/')) return url.slice(5) + + const routes = { '': '/', host: '/host/index.html', admin: '/admin/', restricted: '/restricted/index.html', 'restricted-api': '/restricted-api/index.html' } + for (const [path, target] of Object.entries(routes)) { + if ([`/auth/${path}`, `/auth/${path}/`, `/${path}`, `/${path}/`].includes(url)) return target + } + + if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$|^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html' } } } @@ -49,16 +54,15 @@ export default defineConfig(({ command, mode }) => ({ build: { outDir: '../passkey/frontend-build', emptyOutDir: true, - assetsDir: 'assets', rollupOptions: { input: { index: resolve(__dirname, 'index.html'), admin: resolve(__dirname, 'admin/index.html'), reset: resolve(__dirname, 'reset/index.html'), restricted: resolve(__dirname, 'restricted/index.html'), + 'restricted-api': resolve(__dirname, 'restricted-api/index.html'), host: resolve(__dirname, 'host/index.html') - }, - output: {} + } } } })) diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py index f785e55..910bd7d 100644 --- a/passkey/fastapi/mainapp.py +++ b/passkey/fastapi/mainapp.py @@ -101,6 +101,17 @@ async def admin_root(request: Request, auth=AUTH_COOKIE): return await admin.adminapp(request, auth) # Delegated (enforces access control) +@app.get("/auth/restricted") +async def restricted_view(): + return FileResponse(frontend.file("restricted", "index.html")) + + +@app.get("/auth/restricted-api") +async def restricted_api_view(): + return FileResponse(frontend.file("restricted-api", "index.html")) + + +# Note: this catch-all handler must be the last route defined @app.get("/{reset}") @app.get("/auth/{reset}") async def reset_link(reset: str): @@ -108,9 +119,3 @@ async def reset_link(reset: str): if not passphrase.is_well_formed(reset): raise HTTPException(status_code=404) return FileResponse(frontend.file("reset", "index.html")) - - -@app.get("/restricted", include_in_schema=False) -@app.get("/auth/restricted", include_in_schema=False) -async def restricted_view(): - return FileResponse(frontend.file("restricted", "index.html")) diff --git a/passkey/util/frontend.py b/passkey/util/frontend.py index b3c2a7d..aece0b9 100644 --- a/passkey/util/frontend.py +++ b/passkey/util/frontend.py @@ -27,7 +27,7 @@ BUN_BUG = """\ NO_FRONTEND = """\ ┃ -┃ Note: only static build of the frontend is served at port 8078. +┃ Note: only static build of the frontend is served at localhost:4402. ┃ The page will not update with frontend code changes. """