diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..8ee54e8 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..114cf21 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,29 @@ +# frontend + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). + +## Customize configuration + +See [Vite Configuration Reference](https://vite.dev/config/). + +## Project Setup + +```sh +npm install +``` + +### Compile and Hot-Reload for Development + +```sh +npm run dev +``` + +### Compile and Minify for Production + +```sh +npm run build +``` diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100755 index 0000000..b188daa Binary files /dev/null and b/frontend/bun.lockb differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b19040a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite App + + +
+ + + diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 0000000..5a1f2d2 --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ba4d74d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@simplewebauthn/browser": "^13.1.2", + "pinia": "^3.0.3", + "qrcode": "^1.5.4", + "vue": "^3.5.17" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.0", + "vite": "^7.0.4", + "vite-plugin-vue-devtools": "^7.7.7" + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..df36fcf Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d2e6af6 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css new file mode 100644 index 0000000..52c0c55 --- /dev/null +++ b/frontend/src/assets/style.css @@ -0,0 +1,482 @@ +/* Passkey Authentication - Main Styles */ + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + margin: 0; + padding: 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.container { + background: white; + padding: 40px; + border-radius: 15px; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); + width: 100%; + max-width: 400px; + text-align: center; +} + +.view { + display: none; +} + +.view.active { + display: block; +} + +h1 { + color: #333; + margin-bottom: 30px; + font-weight: 300; + font-size: 28px; +} + +h2 { + color: #555; + margin-bottom: 20px; + font-weight: 400; + font-size: 22px; +} + +input[type="text"] { + width: 100%; + padding: 15px; + border: 2px solid #e1e5e9; + border-radius: 8px; + font-size: 16px; + margin-bottom: 20px; + box-sizing: border-box; + transition: border-color 0.3s ease; +} + +input[type="text"]:focus { + outline: none; + border-color: #667eea; +} + +button { + width: 100%; + padding: 15px; + margin-bottom: 15px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + border: none; + border-radius: 8px; + transition: all 0.3s ease; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); +} + +.btn-secondary { + background: transparent; + color: #667eea; + border: 2px solid #667eea; +} + +.btn-secondary:hover:not(:disabled) { + background: #667eea; + color: white; +} + +.btn-danger { + background: #dc3545; + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #c82333; +} + +button:disabled { + background: #ccc !important; + cursor: not-allowed !important; + transform: none !important; + box-shadow: none !important; +} + +.status { + padding: 10px; + margin: 15px 0; + border-radius: 5px; + font-size: 14px; +} + +.status.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.status.error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.status.info { + background: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; +} + +.credential-list { + max-height: 300px; + overflow-y: auto; + margin: 20px 0; +} + +.credential-item { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 15px; + margin: 10px 0; + text-align: left; +} + +.credential-item.current-session { + border: 2px solid #007bff; + background: #f8f9ff; + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2); +} + +.credential-item.current-session .credential-info h4 { + color: #0056b3; +} + +.credential-header { + display: grid; + grid-template-columns: 32px 1fr auto auto; + gap: 12px; + align-items: center; + margin-bottom: 10px; +} + +.credential-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.auth-icon { + border-radius: 4px; + width: 32px; + height: 32px; +} + +.auth-emoji { + font-size: 24px; + display: block; + text-align: center; +} + +.credential-info { + min-width: 0; +} + +.credential-info h4 { + margin: 0; + color: #333; + font-size: 16px; +} + +.credential-dates { + text-align: right; + flex-shrink: 0; + margin-left: 20px; + display: grid; + grid-template-columns: auto auto; + gap: 5px 10px; + align-items: center; +} + +.date-label { + color: #666; + font-weight: normal; + font-size: 12px; + text-align: right; +} + +.date-value { + color: #333; + font-size: 12px; + text-align: left; +} + +.user-info { + background: #e7f3ff; + border: 1px solid #bee5eb; + border-radius: 8px; + padding: 15px; + margin: 20px 0; +} + +.user-info h3 { + margin: 0 0 10px 0; + color: #0c5460; +} + +.user-info p { + margin: 5px 0; + color: #0c5460; +} + +.toggle-link { + color: #667eea; + text-decoration: underline; + cursor: pointer; + font-size: 14px; +} + +.toggle-link:hover { + color: #764ba2; +} + +.hidden { + display: none; +} + +.credential-actions { + display: flex; + align-items: center; +} + +.btn-delete-credential { + background: none; + border: none; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + font-size: 16px; + color: #dc3545; + transition: background-color 0.2s; +} + +.btn-delete-credential:hover:not(:disabled) { + background-color: #f8d7da; +} + +.btn-delete-credential:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.token-info { + background: #f5f5f5; + padding: 15px; + border-radius: 8px; + margin: 15px 0; + text-align: left; +} + +.token-info strong { + color: #333; +} + +.token-info code { + background: #e9ecef; + padding: 4px 8px; + border-radius: 4px; + font-family: monospace; +} + +.qr-container { + display: flex; + flex-direction: column; + align-items: center; + margin: 20px 0; +} + +.qr-code { + border: 1px solid #ddd; + border-radius: 8px; + padding: 10px; + background: white; + margin: 10px 0; +} + +.link-container { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 15px; + margin: 10px 0; + word-break: break-all; +} + +.link-container .link-text { + font-family: monospace; + font-size: 14px; + color: #495057; + margin: 0; +} + +/* Global Status Styles */ +.global-status { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 10000; + min-width: 300px; + max-width: 600px; + display: none; + animation: slideDown 0.3s ease-out; +} + +.global-status .status { + margin: 0; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + border-width: 2px; + font-weight: 500; + padding: 12px 20px; + border-radius: 8px; + text-align: center; +} + +.status.info { + background: #d1ecf1; + color: #0c5460; + border-color: #bee5eb; +} + +.status.success { + background: #d4edda; + color: #155724; + border-color: #c3e6cb; +} + +.status.error { + background: #f8d7da; + color: #721c24; + border-color: #f5c6cb; +} + +@keyframes slideDown { + from { + transform: translateX(-50%) translateY(-100%); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +} + +/* Vue-specific styles */ +[v-cloak] { + display: none; +} + +/* Dialog overlay and modal styles */ +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease-out; +} + +.device-dialog { + background: white; + padding: 30px; + border-radius: 15px; + box-shadow: 0 10px 30px rgba(0,0,0,0.3); + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + border: none; + animation: slideUp 0.3s ease-out; +} + +.device-link-section { + margin: 20px 0; +} + +.token-info { + text-align: center; +} + +.token-display { + margin: 15px 0; + padding: 10px; + background: #f8f9fa; + border-radius: 8px; +} + +.token-display code { + font-size: 16px; + font-weight: bold; + color: #495057; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + transform: translateY(50px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Responsive improvements */ +@media (max-width: 600px) { + .container { + margin: 20px; + padding: 30px 20px; + max-width: none; + } + + .device-dialog { + margin: 20px; + padding: 20px; + max-width: none; + } + + .global-status { + left: 20px; + right: 20px; + transform: none; + min-width: auto; + } + + .credential-header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .credential-dates { + width: 100%; + } +} diff --git a/frontend/src/components/AddDeviceCredentialView.vue b/frontend/src/components/AddDeviceCredentialView.vue new file mode 100644 index 0000000..89e217c --- /dev/null +++ b/frontend/src/components/AddDeviceCredentialView.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/components/DeviceLinkView.vue b/frontend/src/components/DeviceLinkView.vue new file mode 100644 index 0000000..35be61f --- /dev/null +++ b/frontend/src/components/DeviceLinkView.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/components/LoginView.vue b/frontend/src/components/LoginView.vue new file mode 100644 index 0000000..a1a0d2c --- /dev/null +++ b/frontend/src/components/LoginView.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/ProfileView.vue b/frontend/src/components/ProfileView.vue new file mode 100644 index 0000000..e89214b --- /dev/null +++ b/frontend/src/components/ProfileView.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/frontend/src/components/RegisterView.vue b/frontend/src/components/RegisterView.vue new file mode 100644 index 0000000..532c4a8 --- /dev/null +++ b/frontend/src/components/RegisterView.vue @@ -0,0 +1,57 @@ + + + diff --git a/frontend/src/components/StatusMessage.vue b/frontend/src/components/StatusMessage.vue new file mode 100644 index 0000000..f96d8f2 --- /dev/null +++ b/frontend/src/components/StatusMessage.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..cf77201 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,11 @@ +import './assets/style.css' + +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' + +const app = createApp(App) + +app.use(createPinia()) + +app.mount('#app') diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..38a136d --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,161 @@ +import { defineStore } from 'pinia' +import { registerUser, authenticateUser, registerWithToken } from '@/utils/passkey' +import aWebSocket from '@/utils/awaitable-websocket' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + // Auth State + currentUser: null, + isLoading: false, + + // UI State + currentView: 'login', // 'login', 'register', 'profile', 'device-link' + status: { + message: '', + type: 'info', + show: false + }, + }), + actions: { + showMessage(message, type = 'info', duration = 3000) { + this.status = { + message, + type, + show: true + } + if (duration > 0) { + setTimeout(() => { + this.status.show = false + }, duration) + } + }, + async validateStoredToken() { + try { + const response = await fetch('/auth/validate-token') + const result = await response.json() + return result.status === 'success' + } catch (error) { + return false + } + }, + async setSessionCookie(sessionToken) { + const response = await fetch('/auth/set-session', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${sessionToken}`, + 'Content-Type': 'application/json' + }, + }) + const result = await response.json() + if (result.error) { + throw new Error(result.error) + } + return result + }, + async register(user_name) { + this.isLoading = true + try { + const result = await registerUser(user_name) + + await this.setSessionCookie(result.session_token) + + this.currentUser = { + user_id: result.user_id, + user_name: user_name, + } + + return result + } finally { + this.isLoading = false + } + }, + async authenticate() { + this.isLoading = true + try { + const result = await authenticateUser() + + await this.setSessionCookie(result.session_token) + await this.loadUserInfo() + + return result + } finally { + this.isLoading = false + } + }, + async loadUserInfo() { + const response = await fetch('/auth/user-info') + const result = await response.json() + if (result.error) throw new Error(`Server: ${result.error}`) + + this.currentUser = result.user + }, + async loadCredentials() { + this.isLoading = true + try { + const response = await fetch('/auth/user-credentials') + const result = await response.json() + if (result.error) throw new Error(`Server: ${result.error}`) + + this.currentCredentials = result.credentials + this.aaguidInfo = result.aaguid_info || {} + } finally { + this.isLoading = false + } + }, + async addNewCredential() { + this.isLoading = true; + try { + const result = await registerWithToken() + await this.loadCredentials() + return result; + } catch (error) { + throw new Error(`Failed to add new credential: ${error.message}`) + } finally { + this.isLoading = false + } + }, + async deleteCredential(credentialId) { + const response = await fetch('/auth/delete-credential', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ credential_id: credentialId }) + }) + const result = await response.json() + if (result.error) throw new Error(`Server: ${result.error}`) + + await this.loadCredentials() + }, + async logout() { + try { + await fetch('/auth/logout', {method: 'POST'}) + } catch (error) { + console.error('Logout error:', error) + } + + this.currentUser = null + this.currentCredentials = [] + this.aaguidInfo = {} + }, + async checkResetCookieAndRegister() { + const passphrase = getCookie('reset') + if (passphrase) { + // Abandon existing session + await fetch('/auth/logout', { method: 'POST', credentials: 'include' }) + + // Register additional token for the user + try { + const result = await registerUserFromCookie() + await this.setSessionCookie(result.session_token) + this.currentUser = { + user_id: result.user_id, + user_name: result.user_name, + } + } catch (error) { + console.error('Failed to register additional token:', error) + } + } + }, + } +}) diff --git a/frontend/src/utils/awaitable-websocket.js b/frontend/src/utils/awaitable-websocket.js new file mode 100644 index 0000000..34dab89 --- /dev/null +++ b/frontend/src/utils/awaitable-websocket.js @@ -0,0 +1,83 @@ +class AwaitableWebSocket extends WebSocket { + #received = [] + #waiting = [] + #err = null + #opened = false + + constructor(resolve, reject, url, protocols, binaryType) { + super(url, protocols) + this.binaryType = binaryType || 'blob' + this.onopen = () => { + this.#opened = true + resolve(this) + } + this.onmessage = e => { + if (this.#waiting.length) this.#waiting.shift().resolve(e.data) + else this.#received.push(e.data) + } + this.onclose = e => { + if (!this.#opened) { + reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`)) + return + } + this.#err = e.wasClean + ? new Error(`Websocket ${this.url} closed ${e.code}`) + : new Error(`WebSocket ${this.url} closed with error ${e.code}`) + this.#waiting.splice(0).forEach(p => p.reject(this.#err)) + } + } + + receive() { + // If we have a message already received, return it immediately + if (this.#received.length) return Promise.resolve(this.#received.shift()) + // Wait for incoming messages, if we have an error, reject immediately + if (this.#err) return Promise.reject(this.#err) + return new Promise((resolve, reject) => this.#waiting.push({ resolve, reject })) + } + + async receive_bytes() { + const data = await this.receive() + if (typeof data === 'string') { + console.error("WebSocket received text data, expected a binary message", data) + throw new Error("WebSocket received text data, expected a binary message") + } + return data instanceof Blob ? data.bytes() : new Uint8Array(data) + } + + async receive_json() { + const data = await this.receive() + if (typeof data !== 'string') { + console.error("WebSocket received binary data, expected JSON string", data) + throw new Error("WebSocket received binary data, expected JSON string") + } + let parsed + try { + parsed = JSON.parse(data) + } catch (err) { + console.error("Failed to parse JSON from WebSocket message", data, err) + throw new Error("Failed to parse JSON from WebSocket message") + } + if (parsed.error) { + throw new Error(`Server: ${parsed.error}`) + } + return parsed + } + + send_json(data) { + let jsonData + try { + jsonData = JSON.stringify(data) + } catch (err) { + throw new Error(`Failed to stringify data for WebSocket: ${err.message}`) + } + this.send(jsonData) + } +} + +// Construct an async WebSocket with await aWebSocket(url) +export default function aWebSocket(url, options = {}) { + const { protocols, binaryType } = options + return new Promise((resolve, reject) => { + new AwaitableWebSocket(resolve, reject, url, protocols, binaryType) + }) +} diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js new file mode 100644 index 0000000..9b38ff3 --- /dev/null +++ b/frontend/src/utils/helpers.js @@ -0,0 +1,24 @@ +// Utility functions + +export function formatDate(dateString) { + if (!dateString) return 'Never' + + const date = new Date(dateString) + const now = new Date() + const diffMs = now - date + const diffMinutes = Math.floor(diffMs / (1000 * 60)) + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + + if (diffMs < 0 || diffDays > 7) return date.toLocaleDateString() + if (diffMinutes === 0) return 'Just now' + if (diffMinutes < 60) return diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago` + if (diffHours < 24) return diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago` + return diffDays === 1 ? 'a day ago' : `${diffDays} days ago` +} + +export function getCookie(name) { + const value = `; ${document.cookie}` + const parts = value.split(`; ${name}=`) + if (parts.length === 2) return parts.pop().split(';').shift() +} diff --git a/frontend/src/utils/passkey.js b/frontend/src/utils/passkey.js new file mode 100644 index 0000000..0a74dea --- /dev/null +++ b/frontend/src/utils/passkey.js @@ -0,0 +1,38 @@ +import { startRegistration, startAuthentication } from '@simplewebauthn/browser' +import aWebSocket from '@/utils/awaitable-websocket' + +export async function register(url, options) { + if (options) url += `?${new URLSearchParams(options).toString()}` + const ws = await aWebSocket(url) + + const optionsJSON = await ws.receive_json() + const registrationResponse = await startRegistration({ optionsJSON }) + ws.send_json(registrationResponse) + + const result = await ws.receive_json() + ws.close() + return result; +} + +export async function registerUser(user_name) { + return register('/auth/ws/new_user_registration', { user_name }) +} + +export async function registerCredential() { + return register('/auth/ws/add_credential') +} +export async function registerWithToken(token) { + return register('/auth/ws/add_device_credential', {token}) +} + +export async function authenticateUser() { + const ws = await aWebSocket('/auth/ws/authenticate') + + const optionsJSON = await ws.receive_json() + const authResponse = await startAuthentication({ optionsJSON }) + ws.send_json(authResponse) + + const result = await ws.receive_json() + ws.close() + return result +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..dad8e01 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,33 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + vueDevTools(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, + server: { + port: 3000, + proxy: { + '/auth/': { + target: 'http://localhost:8000', + ws: true, + changeOrigin: false + } + } + }, + build: { + outDir: '../static/dist', + emptyOutDir: true, + assetsDir: 'assets' + } +})