Compare commits

...

9 Commits

65 changed files with 1621 additions and 2982 deletions

3
.gitignore vendored
View File

@ -4,4 +4,5 @@ dist/
!.gitignore !.gitignore
*.lock *.lock
*.db *.db
server-secret.bin server-secret.bin
/passkey/frontend-build

23
Caddyfile Normal file
View File

@ -0,0 +1,23 @@
(auth) {
# Forward /auth to the authentication service
redir /auth /auth/ 302
@auth path /auth/*
handle @auth {
reverse_proxy localhost:4401
}
handle {
# Check for authentication
forward_auth localhost:4401 {
uri /auth/forward-auth
copy_headers x-auth-user-id
}
{block}
}
}
localhost {
import auth {
# Proxy authenticated requests to the main application
reverse_proxy localhost:3000
}
}

9
dev.py
View File

@ -1,9 +0,0 @@
#!/usr/bin/env python3
"""
Development server runner
"""
if __name__ == "__main__":
from passkeyauth.main import main
main()

30
frontend/.gitignore vendored Normal file
View File

@ -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

29
frontend/README.md Normal file
View File

@ -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
```

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/src/assets/icon.webp">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passkey Authentication</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
frontend/jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

21
frontend/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"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"
}
}

46
frontend/src/App.vue Normal file
View File

@ -0,0 +1,46 @@
<template>
<div>
<StatusMessage />
<LoginView v-if="store.currentView === 'login'" />
<RegisterView v-if="store.currentView === 'register'" />
<ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" />
<AddDeviceCredentialView v-if="store.currentView === 'add-device-credential'" />
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import StatusMessage from '@/components/StatusMessage.vue'
import LoginView from '@/components/LoginView.vue'
import RegisterView from '@/components/RegisterView.vue'
import ProfileView from '@/components/ProfileView.vue'
import DeviceLinkView from '@/components/DeviceLinkView.vue'
import AddDeviceCredentialView from '@/components/AddDeviceCredentialView.vue'
import { getCookie } from './utils/helpers'
const store = useAuthStore()
let isLoggedIn
onMounted(async () => {
if (getCookie('auth-token')) {
store.currentView = 'add-device-credential'
return
}
isLoggedIn = await store.validateStoredToken()
if (isLoggedIn) {
// User is logged in, load their data and go to profile
try {
await store.loadUserInfo()
store.currentView = 'profile'
} catch (error) {
console.error('Failed to load user info:', error)
store.currentView = 'login'
}
} else {
// User is not logged in, show login
store.currentView = 'login'
}
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -326,3 +326,157 @@ button:disabled {
color: #495057; color: #495057;
margin: 0; 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%;
}
}

View File

@ -0,0 +1,55 @@
<template>
<div class="container">
<div class="view active">
<h1>🔑 Add Device Credential</h1>
<button
class="btn-primary"
:disabled="authStore.isLoading"
@click="register"
>
{{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }}
</button>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import { registerWithToken } from '@/utils/passkey'
import { ref, onMounted } from 'vue'
import { getCookie } from '@/utils/helpers'
const authStore = useAuthStore()
const token = ref(null)
// Check existing session on app load
onMounted(() => {
// Check for 'auth-token' cookie
token.value = getCookie('auth-token')
if (!token.value) {
authStore.showMessage('No registration token cookie found.', 'error')
authStore.currentView = 'login'
return
}
// Delete the cookie
document.cookie = 'auth-token=; Max-Age=0; path=/'
})
function register() {
authStore.isLoading = true
authStore.showMessage('Starting registration...', 'info')
registerWithToken(token.value).finally(() => {
authStore.isLoading = false
}).then(() => {
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
authStore.currentView = 'profile'
}).catch((error) => {
console.error('Registration error:', error)
if (error.name === "NotAllowedError") {
authStore.showMessage('Registration cancelled', 'error')
} else {
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
}
})
}
</script>

View File

@ -0,0 +1,69 @@
<template>
<div class="container">
<div class="view active">
<h1>📱 Add Device</h1>
<div class="device-link-section">
<h2>Device Addition Link</h2>
<div class="qr-container">
<canvas id="qrCode" class="qr-code"></canvas>
<p v-if="deviceLink.url">
<a :href="deviceLink.url" id="deviceLinkText" @click="copyLink">
{{ deviceLink.url.replace(/^[^:]+:\/\//, '') }}
</a>
</p>
</div>
<p>
<strong>Scan and visit the URL on another device.</strong><br>
<small> Expires in 24 hours and can only be used once.</small>
</p>
</div>
<button @click="authStore.currentView = 'profile'" class="btn-secondary">
Back to Profile
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import QRCode from 'qrcode/lib/browser'
const authStore = useAuthStore()
const deviceLink = ref({ url: '', token: '' })
const copyLink = async (event) => {
event.preventDefault()
if (deviceLink.value.url) {
await navigator.clipboard.writeText(deviceLink.value.url)
authStore.showMessage('Link copied to clipboard!')
authStore.currentView = 'profile'
}
}
onMounted(async () => {
try {
const response = await fetch('/auth/create-device-link', { method: 'POST' })
const result = await response.json()
if (result.error) throw new Error(result.error)
deviceLink.value = {
url: result.addition_link,
token: result.token
}
// Generate QR code
const qrCodeElement = document.getElementById('qrCode')
if (qrCodeElement) {
QRCode.toCanvas(qrCodeElement, deviceLink.value.url, error => {
if (error) console.error('Failed to generate QR code:', error)
})
}
} catch (error) {
console.error('Failed to fetch device link:', error)
}
})
</script>

View File

@ -0,0 +1,43 @@
<template>
<div class="container">
<div class="view active">
<h1>🔐 Passkey Login</h1>
<form @submit.prevent="handleLogin">
<button
type="submit"
class="btn-primary"
:disabled="authStore.isLoading"
>
{{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }}
</button>
</form>
<p class="toggle-link">
<a href="#" @click.prevent="authStore.currentView = 'register'">
Don't have an account? Register here
</a>
</p>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const handleLogin = async () => {
try {
console.log('Login button clicked')
authStore.showMessage('Starting authentication...', 'info')
await authStore.authenticate()
authStore.showMessage('Authentication successful!', 'success', 2000)
if (location.pathname.startsWith('/auth/')) {
authStore.currentView = 'profile'
} else {
location.reload()
}
} catch (error) {
authStore.showMessage(`Authentication failed: ${error.message}`, 'error')
}
}
</script>

View File

@ -0,0 +1,185 @@
<template>
<div class="container">
<div class="view active">
<h1>👋 Welcome!</h1>
<div v-if="authStore.currentUser" class="user-info">
<h3>👤 {{ authStore.currentUser.user_name }}</h3>
<span><strong>Visits:</strong></span>
<span>{{ authStore.currentUser.visits || 0 }}</span>
<span><strong>Registered:</strong></span>
<span>{{ formatDate(authStore.currentUser.created_at) }}</span>
<span><strong>Last seen:</strong></span>
<span>{{ formatDate(authStore.currentUser.last_seen) }}</span>
</div>
<h2>Your Passkeys</h2>
<div class="credential-list">
<div v-if="authStore.isLoading">
<p>Loading credentials...</p>
</div>
<div v-else-if="userCredentialsData.credentials.length === 0">
<p>No passkeys found.</p>
</div>
<div v-else>
<div
v-for="credential in userCredentialsData.credentials"
:key="credential.credential_id"
:class="['credential-item', { 'current-session': credential.is_current_session }]"
>
<div class="credential-header">
<div class="credential-icon">
<img
v-if="getCredentialAuthIcon(credential)"
:src="getCredentialAuthIcon(credential)"
:alt="getCredentialAuthName(credential)"
class="auth-icon"
width="32"
height="32"
>
<span v-else class="auth-emoji">🔑</span>
</div>
<div class="credential-info">
<h4>{{ getCredentialAuthName(credential) }}</h4>
</div>
<div class="credential-dates">
<span class="date-label">Created:</span>
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
<span class="date-label">Last used:</span>
<span class="date-value">{{ formatDate(credential.last_used) }}</span>
</div>
<div class="credential-actions">
<button
@click="deleteCredential(credential.credential_id)"
class="btn-delete-credential"
:disabled="credential.is_current_session"
:title="credential.is_current_session ? 'Cannot delete current session credential' : ''"
>
🗑
</button>
</div>
</div>
</div>
</div>
</div>
<div class="button-group" style="display: flex; gap: 10px;">
<button @click="addNewCredential" class="btn-primary">
Add New Passkey
</button>
<button @click="authStore.currentView = 'device-link'" class="btn-primary">
Add Another Device
</button>
</div>
<button @click="logout" class="btn-danger" style="width: 100%;">
Logout
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { formatDate } from '@/utils/helpers'
import { registerCredential } from '@/utils/passkey'
const authStore = useAuthStore()
const currentCredentials = ref([])
const userCredentialsData = ref({ credentials: [], aaguid_info: {} })
const updateInterval = ref(null)
onMounted(async () => {
try {
await authStore.loadUserInfo()
currentCredentials.value = await authStore.loadCredentials()
} catch (error) {
authStore.showMessage(`Failed to load user info: ${error.message}`, 'error')
authStore.currentView = 'login'
return
}
// Fetch user credentials from the server
try {
const response = await fetch('/auth/user-credentials')
const result = await response.json()
console.log('Fetch Response:', result) // Log the entire response
if (result.error) throw new Error(result.error)
Object.assign(userCredentialsData.value, result) // Store the entire response
} catch (error) {
console.error('Failed to fetch user credentials:', error)
}
updateInterval.value = setInterval(() => {
// Trigger Vue reactivity to update formatDate fields
authStore.currentUser = { ...authStore.currentUser }
userCredentialsData.value.credentials = [...userCredentialsData.value.credentials]
}, 60000) // Update every minute
})
onUnmounted(() => {
if (updateInterval.value) {
clearInterval(updateInterval.value)
}
})
const getCredentialAuthName = (credential) => {
const authInfo = userCredentialsData.value.aaguid_info[credential.aaguid]
return authInfo ? authInfo.name : 'Unknown Authenticator'
}
const getCredentialAuthIcon = (credential) => {
const authInfo = userCredentialsData.value.aaguid_info[credential.aaguid]
if (!authInfo) return null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
return authInfo[iconKey] || null
}
const addNewCredential = async () => {
try {
authStore.isLoading = true
authStore.showMessage('Adding new passkey...', 'info')
const result = await registerCredential()
currentCredentials.value = await authStore.loadCredentials()
authStore.showMessage('New passkey added successfully!', 'success', 3000)
} catch (error) {
console.error('Failed to add new passkey:', error)
authStore.showMessage(`Failed to add passkey: ${error.message}`, 'error')
} finally {
authStore.isLoading = false
}
}
const deleteCredential = async (credentialId) => {
if (!confirm('Are you sure you want to delete this passkey?')) return
try {
await authStore.deleteCredential(credentialId)
currentCredentials.value = await authStore.loadCredentials()
authStore.showMessage('Passkey deleted successfully!', 'success', 3000)
} catch (error) {
authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error')
}
}
const logout = async () => {
await authStore.logout()
authStore.currentView = 'login'
}
</script>
<style scoped>
.user-info {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
}
.user-info h3 {
grid-column: span 2;
}
.user-info span {
text-align: left;
}
</style>

View File

@ -0,0 +1,57 @@
<template>
<div class="container">
<div class="view active">
<h1>🔐 Create Account</h1>
<form @submit.prevent="handleRegister">
<input
type="text"
v-model="user_name"
placeholder="Enter username"
required
:disabled="authStore.isLoading"
>
<button
type="submit"
class="btn-primary"
:disabled="authStore.isLoading || !user_name.trim()"
>
{{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }}
</button>
</form>
<p class="toggle-link">
<a href="#" @click.prevent="authStore.currentView = 'login'">
Already have an account? Login here
</a>
</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const user_name = ref('')
const handleRegister = async () => {
if (!user_name.value.trim()) return
try {
authStore.showMessage('Starting registration...', 'info')
await authStore.register(user_name.value.trim())
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
setTimeout(() => {
authStore.currentView = 'profile'
}, 1500)
} catch (error) {
console.error('Registration error:', error)
if (error.name === "NotAllowedError") {
authStore.showMessage('Registration cancelled', 'error')
} else {
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
}
}
}
</script>

View File

@ -0,0 +1,13 @@
<template>
<div v-if="authStore.status.show" class="global-status" style="display: block;">
<div :class="['status', authStore.status.type]">
{{ authStore.status.message }}
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
</script>

11
frontend/src/main.js Normal file
View File

@ -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')

129
frontend/src/stores/auth.js Normal file
View File

@ -0,0 +1,129 @@
import { defineStore } from 'pinia'
import { registerUser, authenticateUser, registerWithToken } from '@/utils/passkey'
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 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 = {}
},
}
})

View File

@ -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)
})
}

View File

@ -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()
}

View File

@ -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/register_new', { 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
}

32
frontend/vite.config.js Normal file
View File

@ -0,0 +1,32 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig(({ command, mode }) => ({
plugins: [
vue(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
base: command === 'build' ? '/auth/' : '/',
server: {
port: 4403,
proxy: {
'/auth/': {
target: 'http://localhost:4401',
ws: true,
changeOrigin: false
}
}
},
build: {
outDir: '../passkey/frontend-build',
emptyOutDir: true,
assetsDir: 'assets'
}
}))

326
main.py
View File

@ -1,326 +0,0 @@
import json
import uuid
from typing import Dict
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from webauthn import generate_registration_options, verify_registration_response
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
ResidentKeyRequirement,
UserVerificationRequirement,
)
app = FastAPI(title="WebAuthn Registration Server")
# In-memory storage for challenges (in production, use Redis or similar)
active_challenges: Dict[str, str] = {}
# WebAuthn configuration
RP_ID = "localhost"
RP_NAME = "WebAuthn Demo"
ORIGIN = "http://localhost:8000"
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, WebSocket] = {}
async def connect(self, websocket: WebSocket, client_id: str):
await websocket.accept()
self.active_connections[client_id] = websocket
def disconnect(self, client_id: str):
if client_id in self.active_connections:
del self.active_connections[client_id]
async def send_message(self, message: dict, client_id: str):
if client_id in self.active_connections:
await self.active_connections[client_id].send_text(json.dumps(message))
manager = ConnectionManager()
@app.websocket("/ws/{client_id}")
async def websocket_endpoint(websocket: WebSocket, client_id: str):
await manager.connect(websocket, client_id)
try:
while True:
data = await websocket.receive_text()
message = json.loads(data)
if message["type"] == "registration_challenge":
await handle_registration_challenge(message, client_id)
elif message["type"] == "registration_response":
await handle_registration_response(message, client_id)
else:
await manager.send_message(
{
"type": "error",
"message": f"Unknown message type: {message['type']}",
},
client_id,
)
except WebSocketDisconnect:
manager.disconnect(client_id)
async def handle_registration_challenge(message: dict, client_id: str):
"""Handle registration challenge request"""
try:
username = message.get("username", "user@example.com")
user_id = str(uuid.uuid4()).encode()
# Generate registration options with Resident Key support
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=user_id,
user_name=username,
user_display_name=username,
# Enable Resident Keys (discoverable credentials)
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
# Support common algorithms
supported_pub_key_algs=[
COSEAlgorithmIdentifier.ECDSA_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
],
)
# Store challenge for this client
active_challenges[client_id] = options.challenge
# Convert options to dict for JSON serialization
options_dict = {
"challenge": options.challenge,
"rp": {
"name": options.rp.name,
"id": options.rp.id,
},
"user": {
"id": options.user.id,
"name": options.user.name,
"displayName": options.user.display_name,
},
"pubKeyCredParams": [
{"alg": param.alg, "type": param.type}
for param in options.pub_key_cred_params
],
"timeout": options.timeout,
"attestation": options.attestation,
"authenticatorSelection": {
"residentKey": options.authenticator_selection.resident_key.value,
"userVerification": options.authenticator_selection.user_verification.value,
},
}
await manager.send_message(
{"type": "registration_challenge_response", "options": options_dict},
client_id,
)
except Exception as e:
await manager.send_message(
{"type": "error", "message": f"Failed to generate challenge: {str(e)}"},
client_id,
)
async def handle_registration_response(message: dict, client_id: str):
"""Handle registration response verification"""
try:
# Get the stored challenge
if client_id not in active_challenges:
await manager.send_message(
{"type": "error", "message": "No active challenge found"}, client_id
)
return
expected_challenge = active_challenges[client_id]
credential = message["credential"]
# Verify the registration response
verification = verify_registration_response(
credential=credential,
expected_challenge=expected_challenge,
expected_origin=ORIGIN,
expected_rp_id=RP_ID,
)
if verification.verified:
# Clean up the challenge
del active_challenges[client_id]
await manager.send_message(
{
"type": "registration_success",
"message": "Registration successful!",
"credentialId": verification.credential_id,
"credentialPublicKey": verification.credential_public_key,
},
client_id,
)
else:
await manager.send_message(
{"type": "error", "message": "Registration verification failed"},
client_id,
)
except Exception as e:
await manager.send_message(
{"type": "error", "message": f"Registration failed: {str(e)}"}, client_id
)
# Serve static files
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
async def get_index():
return HTMLResponse(
content="""
<!DOCTYPE html>
<html>
<head>
<title>WebAuthn Registration Demo</title>
<script src="/static/simplewebauthn-browser.min.js"></script>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.container { text-align: center; }
button { padding: 10px 20px; margin: 10px; font-size: 16px; cursor: pointer; }
.success { color: green; }
.error { color: red; }
.info { color: blue; }
#log { text-align: left; background: #f5f5f5; padding: 10px; margin: 20px 0; border-radius: 5px; }
</style>
</head>
<body>
<div class="container">
<h1>WebAuthn Registration Demo</h1>
<p>Test WebAuthn registration with Resident Keys support</p>
<div>
<label for="username">Username:</label>
<input type="text" id="username" value="user@example.com" style="margin: 10px; padding: 5px;">
</div>
<button id="registerBtn">Register Passkey</button>
<div id="status"></div>
<div id="log"></div>
</div>
<script>
const { startRegistration } = SimpleWebAuthnBrowser;
// Generate a unique client ID
const clientId = Math.random().toString(36).substring(7);
// WebSocket connection
const ws = new WebSocket(`ws://localhost:8000/ws/${clientId}`);
const statusDiv = document.getElementById('status');
const logDiv = document.getElementById('log');
const registerBtn = document.getElementById('registerBtn');
const usernameInput = document.getElementById('username');
function log(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
logDiv.innerHTML += `<div class="${type}">[${timestamp}] ${message}</div>`;
logDiv.scrollTop = logDiv.scrollHeight;
}
function setStatus(message, type = 'info') {
statusDiv.innerHTML = `<div class="${type}">${message}</div>`;
}
ws.onopen = function() {
log('Connected to WebSocket server', 'success');
setStatus('Ready for registration', 'success');
registerBtn.disabled = false;
};
ws.onmessage = async function(event) {
const message = JSON.parse(event.data);
log(`Received: ${message.type}`);
if (message.type === 'registration_challenge_response') {
try {
log('Starting WebAuthn registration...');
setStatus('Touch your authenticator...', 'info');
const attResp = await startRegistration(message.options);
log('WebAuthn registration completed, verifying...');
setStatus('Verifying registration...', 'info');
ws.send(JSON.stringify({
type: 'registration_response',
credential: attResp
}));
} catch (error) {
log(`Registration failed: ${error.message}`, 'error');
setStatus('Registration failed', 'error');
registerBtn.disabled = false;
}
} else if (message.type === 'registration_success') {
log('Registration verified successfully!', 'success');
setStatus('Registration successful! Passkey created.', 'success');
registerBtn.disabled = false;
} else if (message.type === 'error') {
log(`Error: ${message.message}`, 'error');
setStatus(`Error: ${message.message}`, 'error');
registerBtn.disabled = false;
}
};
ws.onerror = function(error) {
log('WebSocket error: ' + error, 'error');
setStatus('Connection error', 'error');
};
ws.onclose = function() {
log('WebSocket connection closed', 'info');
setStatus('Disconnected', 'error');
registerBtn.disabled = true;
};
registerBtn.addEventListener('click', function() {
const username = usernameInput.value.trim();
if (!username) {
alert('Please enter a username');
return;
}
registerBtn.disabled = true;
setStatus('Requesting registration challenge...', 'info');
log(`Starting registration for: ${username}`);
ws.send(JSON.stringify({
type: 'registration_challenge',
username: username
}));
});
// Disable button until connection is ready
registerBtn.disabled = true;
</script>
</body>
</html>
"""
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

3
passkey/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .sansio import Passkey
__all__ = ["Passkey"]

View File

@ -0,0 +1,32 @@
"""
AAGUID (Authenticator Attestation GUID) management for WebAuthn credentials.
This module provides functionality to:
- Load AAGUID data from JSON file
- Look up authenticator information by AAGUID
- Return only relevant AAGUID data for user credentials
"""
import json
from collections.abc import Iterable
from importlib.resources import files
__ALL__ = ["AAGUID", "filter"]
# Path to the AAGUID JSON file
AAGUID_FILE = files("passkey") / "aaguid" / "combined_aaguid.json"
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
def filter(aaguids: Iterable[str]) -> dict[str, dict]:
"""
Get AAGUID information only for the provided set of AAGUIDs.
Args:
aaguids: Set of AAGUID strings that the user has credentials for
Returns:
Dictionary mapping AAGUID to authenticator information for only
the AAGUIDs that the user has and that we have data for
"""
return {aaguid: AAGUID[aaguid] for aaguid in aaguids if aaguid in AAGUID}

View File

@ -25,7 +25,7 @@ from sqlalchemy.dialects.sqlite import BLOB
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from .passkey import StoredCredential from ..sansio import StoredCredential
DB_PATH = "sqlite+aiosqlite:///webauthn.db" DB_PATH = "sqlite+aiosqlite:///webauthn.db"

View File

@ -10,14 +10,14 @@ This module contains all the HTTP API endpoints for:
from fastapi import Request, Response from fastapi import Request, Response
from . import db from .. import aaguid
from .aaguid_manager import get_aaguid_manager from ..db import sql
from .jwt_manager import refresh_session_token, validate_session_token from ..util.jwt import refresh_session_token, validate_session_token
from .session_manager import ( from .session_manager import (
clear_session_cookie, clear_session_cookie,
get_current_user, get_current_user,
get_session_token_from_auth_header_or_body, get_session_token_from_bearer,
get_session_token_from_request, get_session_token_from_cookie,
set_session_cookie, set_session_cookie,
) )
@ -52,51 +52,46 @@ async def get_user_credentials(request: Request) -> dict:
# Get current session credential ID # Get current session credential ID
current_credential_id = None current_credential_id = None
session_token = get_session_token_from_request(request) session_token = get_session_token_from_cookie(request)
if session_token: if session_token:
token_data = validate_session_token(session_token) token_data = validate_session_token(session_token)
if token_data: if token_data:
current_credential_id = token_data.get("credential_id") current_credential_id = token_data.get("credential_id")
# Get all credentials for the user # Get all credentials for the user
credential_ids = await db.get_user_credentials(user.user_id) credential_ids = await sql.get_user_credentials(user.user_id)
credentials = [] credentials = []
user_aaguids = set() user_aaguids = set()
for cred_id in credential_ids: for cred_id in credential_ids:
try: stored_cred = await sql.get_credential_by_id(cred_id)
stored_cred = await db.get_credential_by_id(cred_id)
# Convert AAGUID to string format # Convert AAGUID to string format
aaguid_str = str(stored_cred.aaguid) aaguid_str = str(stored_cred.aaguid)
user_aaguids.add(aaguid_str) user_aaguids.add(aaguid_str)
# Check if this is the current session credential # Check if this is the current session credential
is_current_session = current_credential_id == stored_cred.credential_id is_current_session = current_credential_id == stored_cred.credential_id
credentials.append( credentials.append(
{ {
"credential_id": stored_cred.credential_id.hex(), "credential_id": stored_cred.credential_id.hex(),
"aaguid": aaguid_str, "aaguid": aaguid_str,
"created_at": stored_cred.created_at.isoformat(), "created_at": stored_cred.created_at.isoformat(),
"last_used": stored_cred.last_used.isoformat() "last_used": stored_cred.last_used.isoformat()
if stored_cred.last_used if stored_cred.last_used
else None, else None,
"last_verified": stored_cred.last_verified.isoformat() "last_verified": stored_cred.last_verified.isoformat()
if stored_cred.last_verified if stored_cred.last_verified
else None, else None,
"sign_count": stored_cred.sign_count, "sign_count": stored_cred.sign_count,
"is_current_session": is_current_session, "is_current_session": is_current_session,
} }
) )
except ValueError:
# Skip invalid credentials
continue
# Get AAGUID information for only the AAGUIDs that the user has # Get AAGUID information for only the AAGUIDs that the user has
aaguid_manager = get_aaguid_manager() aaguid_info = aaguid.filter(user_aaguids)
aaguid_info = aaguid_manager.get_relevant_aaguids(user_aaguids)
# Sort credentials by creation date (earliest first, most recently created last) # Sort credentials by creation date (earliest first, most recently created last)
credentials.sort(key=lambda cred: cred["created_at"]) credentials.sort(key=lambda cred: cred["created_at"])
@ -113,7 +108,7 @@ async def get_user_credentials(request: Request) -> dict:
async def refresh_token(request: Request, response: Response) -> dict: async def refresh_token(request: Request, response: Response) -> dict:
"""Refresh the session token.""" """Refresh the session token."""
try: try:
session_token = get_session_token_from_request(request) session_token = get_session_token_from_cookie(request)
if not session_token: if not session_token:
return {"error": "No session token found"} return {"error": "No session token found"}
@ -134,7 +129,7 @@ async def refresh_token(request: Request, response: Response) -> dict:
async def validate_token(request: Request) -> dict: async def validate_token(request: Request) -> dict:
"""Validate a session token and return user info.""" """Validate a session token and return user info."""
try: try:
session_token = get_session_token_from_request(request) session_token = get_session_token_from_cookie(request)
if not session_token: if not session_token:
return {"error": "No session token found"} return {"error": "No session token found"}
@ -165,7 +160,7 @@ async def logout(response: Response) -> dict:
async def set_session(request: Request, response: Response) -> dict: async def set_session(request: Request, response: Response) -> dict:
"""Set session cookie using JWT token from request body or Authorization header.""" """Set session cookie using JWT token from request body or Authorization header."""
try: try:
session_token = await get_session_token_from_auth_header_or_body(request) session_token = await get_session_token_from_bearer(request)
if not session_token: if not session_token:
return {"error": "No session token provided"} return {"error": "No session token provided"}
@ -212,26 +207,26 @@ async def delete_credential(request: Request) -> dict:
# First, verify the credential belongs to the current user # First, verify the credential belongs to the current user
try: try:
stored_cred = await db.get_credential_by_id(credential_id_bytes) stored_cred = await sql.get_credential_by_id(credential_id_bytes)
if stored_cred.user_id != user.user_id: if stored_cred.user_id != user.user_id:
return {"error": "Credential not found or access denied"} return {"error": "Credential not found or access denied"}
except ValueError: except ValueError:
return {"error": "Credential not found"} return {"error": "Credential not found"}
# Check if this is the current session credential # Check if this is the current session credential
session_token = get_session_token_from_request(request) session_token = get_session_token_from_cookie(request)
if session_token: if session_token:
token_data = validate_session_token(session_token) token_data = validate_session_token(session_token)
if token_data and token_data.get("credential_id") == credential_id_bytes: if token_data and token_data.get("credential_id") == credential_id_bytes:
return {"error": "Cannot delete current session credential"} return {"error": "Cannot delete current session credential"}
# Get user's remaining credentials count # Get user's remaining credentials count
remaining_credentials = await db.get_user_credentials(user.user_id) remaining_credentials = await sql.get_user_credentials(user.user_id)
if len(remaining_credentials) <= 1: if len(remaining_credentials) <= 1:
return {"error": "Cannot delete last remaining credential"} return {"error": "Cannot delete last remaining credential"}
# Delete the credential # Delete the credential
await db.delete_user_credential(credential_id_bytes) await sql.delete_user_credential(credential_id_bytes)
return {"status": "success", "message": "Credential deleted successfully"} return {"status": "success", "message": "Credential deleted successfully"}

186
passkey/fastapi/main.py Normal file
View File

@ -0,0 +1,186 @@
"""
Minimal FastAPI WebAuthn server with WebSocket support for passkey registration and authentication.
This module provides a simple WebAuthn implementation that:
- Uses WebSocket for real-time communication
- Supports Resident Keys (discoverable credentials) for passwordless authentication
- Maintains challenges locally per connection
- Uses async SQLite database for persistent storage of users and credentials
- Enables true passwordless authentication where users don't need to enter a user_name
"""
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import (
FastAPI,
Request,
Response,
)
from fastapi import (
Path as FastAPIPath,
)
from fastapi.responses import (
FileResponse,
RedirectResponse,
)
from fastapi.staticfiles import StaticFiles
from ..db import sql
from .api_handlers import (
delete_credential,
get_user_credentials,
get_user_info,
logout,
refresh_token,
set_session,
validate_token,
)
from .reset_handlers import create_device_addition_link, validate_device_addition_token
from .ws_handlers import ws_app
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
@asynccontextmanager
async def lifespan(app: FastAPI):
await sql.init_database()
yield
app = FastAPI(title="Passkey Auth", lifespan=lifespan)
# Mount the WebSocket subapp
app.mount("/auth/ws", ws_app)
@app.get("/auth/user-info")
async def api_get_user_info(request: Request):
"""Get user information from session cookie."""
return await get_user_info(request)
@app.get("/auth/user-credentials")
async def api_get_user_credentials(request: Request):
"""Get all credentials for a user using session cookie."""
return await get_user_credentials(request)
@app.post("/auth/refresh-token")
async def api_refresh_token(request: Request, response: Response):
"""Refresh the session token."""
return await refresh_token(request, response)
@app.get("/auth/validate-token")
async def api_validate_token(request: Request):
"""Validate a session token and return user info."""
return await validate_token(request)
@app.get("/auth/forward-auth")
async def forward_authentication(request: Request):
"""A verification endpoint to use with Caddy forward_auth or Nginx auth_request."""
result = await validate_token(request)
if result.get("status") != "success":
# Serve the index.html of the authentication app if not authenticated
return FileResponse(
STATIC_DIR / "index.html",
status_code=401,
headers={"www-authenticate": "PrivateToken"},
)
# If authenticated, return a success response
return Response(
status_code=204,
headers={"x-auth-user-id": result["user_id"]},
)
@app.post("/auth/logout")
async def api_logout(response: Response):
"""Log out the current user by clearing the session cookie."""
return await logout(response)
@app.post("/auth/set-session")
async def api_set_session(request: Request, response: Response):
"""Set session cookie using JWT token from request body or Authorization header."""
return await set_session(request, response)
@app.post("/auth/delete-credential")
async def api_delete_credential(request: Request):
"""Delete a specific credential for the current user."""
return await delete_credential(request)
@app.post("/auth/create-device-link")
async def api_create_device_link(request: Request):
"""Create a device addition link for the authenticated user."""
return await create_device_addition_link(request)
@app.post("/auth/validate-device-token")
async def api_validate_device_token(request: Request):
"""Validate a device addition token."""
return await validate_device_addition_token(request)
@app.get("/auth/{passphrase}")
async def reset_authentication(
passphrase: str = FastAPIPath(pattern=r"^\w+(\.\w+){2,}$"),
):
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
key="auth-token",
value=passphrase,
httponly=False,
secure=True,
samesite="strict",
max_age=2,
)
return response
# Serve static files
app.mount(
"/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets"
)
@app.get("/auth")
async def redirect_to_index():
"""Serve the main authentication app."""
return FileResponse(STATIC_DIR / "index.html")
# Catch-all route for SPA - serve index.html for all non-API routes
@app.get("/{path:path}")
async def spa_handler(request: Request, path: str):
"""Serve the Vue SPA for all routes (except API and static)"""
if "text/html" not in request.headers.get("accept", ""):
return Response(content="Not Found", status_code=404)
return FileResponse(STATIC_DIR / "index.html")
def main():
"""Entry point for the application"""
import uvicorn
uvicorn.run(
"passkey.fastapi.main:app",
host="localhost",
port=4401,
reload=True,
log_level="info",
)
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
main()

View File

@ -11,8 +11,8 @@ from datetime import datetime, timedelta
from fastapi import Request from fastapi import Request
from . import db from ..db import sql
from .passphrase import generate from ..util.passphrase import generate
from .session_manager import get_current_user from .session_manager import get_current_user
@ -25,19 +25,18 @@ async def create_device_addition_link(request: Request) -> dict:
return {"error": "Authentication required"} return {"error": "Authentication required"}
# Generate a human-readable token # Generate a human-readable token
token = generate(n=4, sep="-") # e.g., "able-ocean-forest-dawn" token = generate(n=4, sep=".") # e.g., "able-ocean-forest-dawn"
# Create reset token in database # Create reset token in database
await db.create_reset_token(user.user_id, token) await sql.create_reset_token(user.user_id, token)
# Generate the device addition link with pretty URL # Generate the device addition link with pretty URL
addition_link = f"http://localhost:8000/reset/{token}" addition_link = f"{request.headers.get('origin', '')}/auth/{token}"
return { return {
"status": "success", "status": "success",
"message": "Device addition link generated successfully", "message": "Device addition link generated successfully",
"addition_link": addition_link, "addition_link": addition_link,
"token": token,
"expires_in_hours": 24, "expires_in_hours": 24,
} }
@ -55,7 +54,7 @@ async def validate_device_addition_token(request: Request) -> dict:
return {"error": "Device addition token is required"} return {"error": "Device addition token is required"}
# Get reset token # Get reset token
reset_token = await db.get_reset_token(token) reset_token = await sql.get_reset_token(token)
if not reset_token: if not reset_token:
return {"error": "Invalid or expired device addition token"} return {"error": "Invalid or expired device addition token"}
@ -65,7 +64,7 @@ async def validate_device_addition_token(request: Request) -> dict:
return {"error": "Device addition token has expired"} return {"error": "Device addition token has expired"}
# Get user info # Get user info
user = await db.get_user_by_id(reset_token.user_id) user = await sql.get_user_by_id(reset_token.user_id)
return { return {
"status": "success", "status": "success",
@ -83,7 +82,7 @@ async def use_device_addition_token(token: str) -> dict:
"""Delete a device addition token after successful use.""" """Delete a device addition token after successful use."""
try: try:
# Get reset token first to validate it exists and is not expired # Get reset token first to validate it exists and is not expired
reset_token = await db.get_reset_token(token) reset_token = await sql.get_reset_token(token)
if not reset_token: if not reset_token:
return {"error": "Invalid or expired device addition token"} return {"error": "Invalid or expired device addition token"}
@ -93,7 +92,7 @@ async def use_device_addition_token(token: str) -> dict:
return {"error": "Device addition token has expired"} return {"error": "Device addition token has expired"}
# Delete the token (it's now used) # Delete the token (it's now used)
await db.delete_reset_token(token) await sql.delete_reset_token(token)
return { return {
"status": "success", "status": "success",

View File

@ -7,19 +7,18 @@ This module provides session management functionality including:
- Session validation and token handling - Session validation and token handling
""" """
from typing import Optional
from uuid import UUID from uuid import UUID
from fastapi import Request, Response from fastapi import Request, Response
from .db import User, get_user_by_id from ..db.sql import User, get_user_by_id
from .jwt_manager import validate_session_token from ..util.jwt import validate_session_token
COOKIE_NAME = "session_token" COOKIE_NAME = "auth"
COOKIE_MAX_AGE = 86400 # 24 hours COOKIE_MAX_AGE = 86400 # 24 hours
async def get_current_user(request: Request) -> Optional[User]: async def get_current_user(request: Request) -> User | None:
"""Get the current user from the session cookie.""" """Get the current user from the session cookie."""
session_token = request.cookies.get(COOKIE_NAME) session_token = request.cookies.get(COOKIE_NAME)
if not session_token: if not session_token:
@ -43,7 +42,7 @@ def set_session_cookie(response: Response, session_token: str) -> None:
value=session_token, value=session_token,
max_age=COOKIE_MAX_AGE, max_age=COOKIE_MAX_AGE,
httponly=True, httponly=True,
secure=False, # Set to True in production with HTTPS secure=True,
samesite="lax", samesite="lax",
) )
@ -53,36 +52,29 @@ def clear_session_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME) response.delete_cookie(key=COOKIE_NAME)
def get_session_token_from_request(request: Request) -> Optional[str]: def get_session_token_from_cookie(request: Request) -> str | None:
"""Extract session token from request cookies.""" """Extract session token from request cookies."""
return request.cookies.get(COOKIE_NAME) return request.cookies.get(COOKIE_NAME)
async def validate_session_from_request(request: Request) -> Optional[dict]: async def validate_session_from_request(request: Request) -> dict | None:
"""Validate session token from request and return token data.""" """Validate session token from request and return token data."""
session_token = get_session_token_from_request(request) session_token = get_session_token_from_cookie(request)
if not session_token: if not session_token:
return None return None
return validate_session_token(session_token) return validate_session_token(session_token)
async def get_session_token_from_auth_header_or_body(request: Request) -> Optional[str]: async def get_session_token_from_bearer(request: Request) -> str | None:
"""Extract session token from Authorization header or request body.""" """Extract session token from Authorization header or request body."""
# Try to get token from Authorization header first # Try to get token from Authorization header first
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "): if auth_header and auth_header.startswith("Bearer "):
return auth_header[7:] # Remove "Bearer " prefix return auth_header.removeprefix("Bearer ")
# Try to get from request body
try:
body = await request.json()
return body.get("session_token")
except Exception:
return None
async def get_user_from_cookie_string(cookie_header: str) -> Optional[UUID]: async def get_user_from_cookie_string(cookie_header: str) -> UUID | None:
"""Parse cookie header and return user ID if valid session exists.""" """Parse cookie header and return user ID if valid session exists."""
if not cookie_header: if not cookie_header:
return None return None

View File

@ -0,0 +1,219 @@
"""
WebSocket handlers for passkey authentication operations.
This module contains all WebSocket endpoints for:
- User registration
- Adding credentials to existing users
- Device credential addition via token
- Authentication
"""
import logging
from datetime import datetime, timedelta
from uuid import UUID
import uuid7
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from ..db import sql
from ..db.sql import User
from ..sansio import Passkey
from ..util.jwt import create_session_token
from .session_manager import get_user_from_cookie_string
# Create a FastAPI subapp for WebSocket endpoints
ws_app = FastAPI()
# Initialize the passkey instance
passkey = Passkey(
rp_id="localhost",
rp_name="Passkey Auth",
)
async def register_chat(
ws: WebSocket,
user_id: UUID,
user_name: str,
credential_ids: list[bytes] | None = None,
origin: str | None = None,
):
"""Generate registration options and send them to the client."""
options, challenge = passkey.reg_generate_options(
user_id=user_id,
user_name=user_name,
credential_ids=credential_ids,
origin=origin,
)
await ws.send_json(options)
response = await ws.receive_json()
return passkey.reg_verify(response, challenge, user_id, origin=origin)
@ws_app.websocket("/register_new")
async def websocket_register_new(ws: WebSocket, user_name: str):
"""Register a new user and with a new passkey credential."""
await ws.accept()
origin = ws.headers.get("origin")
try:
user_id = uuid7.create()
# WebAuthn registration
credential = await register_chat(ws, user_id, user_name, origin=origin)
# Store the user and credential in the database
await sql.create_user_and_credential(
User(user_id, user_name, created_at=datetime.now()),
credential,
)
# Create a session token for the new user
session_token = create_session_token(user_id, credential.credential_id)
await ws.send_json(
{
"status": "success",
"user_id": str(user_id),
"session_token": session_token,
}
)
except ValueError as e:
await ws.send_json({"error": str(e)})
except WebSocketDisconnect:
pass
except Exception:
logging.exception("Internal Server Error")
await ws.send_json({"error": "Internal Server Error"})
@ws_app.websocket("/add_credential")
async def websocket_register_add(ws: WebSocket):
"""Register a new credential for an existing user."""
await ws.accept()
origin = ws.headers.get("origin")
try:
# Authenticate user via cookie
cookie_header = ws.headers.get("cookie", "")
user_id = await get_user_from_cookie_string(cookie_header)
if not user_id:
await ws.send_json({"error": "Authentication required"})
return
# Get user information to get the user_name
user = await sql.get_user_by_id(user_id)
user_name = user.user_name
challenge_ids = await sql.get_user_credentials(user_id)
# WebAuthn registration
credential = await register_chat(
ws, user_id, user_name, challenge_ids, origin=origin
)
# Store the new credential in the database
await sql.create_credential_for_user(credential)
await ws.send_json(
{
"status": "success",
"user_id": str(user_id),
"credential_id": credential.credential_id.hex(),
"message": "New credential added successfully",
}
)
except ValueError as e:
await ws.send_json({"error": str(e)})
except WebSocketDisconnect:
pass
except Exception:
logging.exception("Internal Server Error")
await ws.send_json({"error": "Internal Server Error"})
@ws_app.websocket("/add_device_credential")
async def websocket_add_device_credential(ws: WebSocket, token: str):
"""Add a new credential for an existing user via device addition token."""
await ws.accept()
origin = ws.headers.get("origin")
try:
reset_token = await sql.get_reset_token(token)
if not reset_token:
await ws.send_json({"error": "Invalid or expired device addition token"})
return
# Check if token is expired (24 hours)
expiry_time = reset_token.created_at + timedelta(hours=24)
if datetime.now() > expiry_time:
await ws.send_json({"error": "Device addition token has expired"})
return
# Get user information
user = await sql.get_user_by_id(reset_token.user_id)
# WebAuthn registration
# Fetch challenge IDs for the user
challenge_ids = await sql.get_user_credentials(reset_token.user_id)
credential = await register_chat(
ws, reset_token.user_id, user.user_name, challenge_ids, origin=origin
)
# Store the new credential in the database
await sql.create_credential_for_user(credential)
# Delete the device addition token (it's now used)
await sql.delete_reset_token(token)
await ws.send_json(
{
"status": "success",
"user_id": str(reset_token.user_id),
"credential_id": credential.credential_id.hex(),
"message": "New credential added successfully via device addition token",
}
)
except ValueError as e:
await ws.send_json({"error": str(e)})
except WebSocketDisconnect:
pass
except Exception:
logging.exception("Internal Server Error")
await ws.send_json({"error": "Internal Server Error"})
@ws_app.websocket("/authenticate")
async def websocket_authenticate(ws: WebSocket):
await ws.accept()
origin = ws.headers.get("origin")
try:
options, challenge = passkey.auth_generate_options()
await ws.send_json(options)
# Wait for the client to use his authenticator to authenticate
credential = passkey.auth_parse(await ws.receive_json())
# Fetch from the database by credential ID
stored_cred = await sql.get_credential_by_id(credential.raw_id)
# Verify the credential matches the stored data
passkey.auth_verify(credential, challenge, stored_cred, origin=origin)
# Update both credential and user's last_seen timestamp
await sql.login_user(stored_cred.user_id, stored_cred)
# Create a session token for the authenticated user
session_token = create_session_token(
stored_cred.user_id, stored_cred.credential_id
)
await ws.send_json(
{
"status": "success",
"user_id": str(stored_cred.user_id),
"session_token": session_token,
}
)
except (ValueError, InvalidAuthenticationResponse) as e:
logging.exception("ValueError")
await ws.send_json({"error": str(e)})
except WebSocketDisconnect:
pass
except Exception:
logging.exception("Internal Server Error")
await ws.send_json({"error": "Internal Server Error"})

View File

@ -60,7 +60,7 @@ class Passkey:
self, self,
rp_id: str, rp_id: str,
rp_name: str, rp_name: str,
origin: str, origin: str | None = None,
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None, supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
): ):
""" """
@ -74,7 +74,7 @@ class Passkey:
""" """
self.rp_id = rp_id self.rp_id = rp_id
self.rp_name = rp_name self.rp_name = rp_name
self.origin = origin self.origin = origin or f"https://{rp_id}"
self.supported_pub_key_algs = supported_pub_key_algs or [ self.supported_pub_key_algs = supported_pub_key_algs or [
COSEAlgorithmIdentifier.EDDSA, COSEAlgorithmIdentifier.EDDSA,
COSEAlgorithmIdentifier.ECDSA_SHA_256, COSEAlgorithmIdentifier.ECDSA_SHA_256,
@ -88,6 +88,7 @@ class Passkey:
user_id: UUID, user_id: UUID,
user_name: str, user_name: str,
credential_ids: list[bytes] | None = None, credential_ids: list[bytes] | None = None,
origin: str | None = None,
**regopts, **regopts,
) -> tuple[dict, bytes]: ) -> tuple[dict, bytes]:
""" """
@ -99,6 +100,7 @@ class Passkey:
credential_ids: For an already authenticated user, a list of credential IDs credential_ids: For an already authenticated user, a list of credential IDs
associated with the account. This prevents accidentally adding another associated with the account. This prevents accidentally adding another
credential on an authenticator that already has one of the listed IDs. credential on an authenticator that already has one of the listed IDs.
origin: The origin URL of the application (e.g. "https://app.example.com"). Must be a subdomain or same as rp_id, with port and scheme but no path included.
regopts: Additional arguments to generate_registration_options. regopts: Additional arguments to generate_registration_options.
Returns: Returns:
@ -126,6 +128,7 @@ class Passkey:
response_json: dict | str, response_json: dict | str,
expected_challenge: bytes, expected_challenge: bytes,
user_id: UUID, user_id: UUID,
origin: str | None = None,
) -> StoredCredential: ) -> StoredCredential:
""" """
Verify registration response. Verify registration response.
@ -141,7 +144,7 @@ class Passkey:
registration = verify_registration_response( registration = verify_registration_response(
credential=credential, credential=credential,
expected_challenge=expected_challenge, expected_challenge=expected_challenge,
expected_origin=self.origin, expected_origin=origin or self.origin,
expected_rp_id=self.rp_id, expected_rp_id=self.rp_id,
) )
return StoredCredential( return StoredCredential(
@ -193,6 +196,7 @@ class Passkey:
credential: AuthenticationCredential, credential: AuthenticationCredential,
expected_challenge: bytes, expected_challenge: bytes,
stored_cred: StoredCredential, stored_cred: StoredCredential,
origin: str | None = None,
) -> VerifiedAuthentication: ) -> VerifiedAuthentication:
""" """
Verify authentication response against locally stored credential data. Verify authentication response against locally stored credential data.
@ -202,11 +206,12 @@ class Passkey:
expected_challenge: The earlier generated challenge bytes expected_challenge: The earlier generated challenge bytes
stored_cred: The server stored credential record (modified by this function) stored_cred: The server stored credential record (modified by this function)
""" """
expected_origin = origin or self.origin
# Verify the authentication response # Verify the authentication response
verification = verify_authentication_response( verification = verify_authentication_response(
credential=credential, credential=credential,
expected_challenge=expected_challenge, expected_challenge=expected_challenge,
expected_origin=self.origin, expected_origin=expected_origin,
expected_rp_id=self.rp_id, expected_rp_id=self.rp_id,
credential_public_key=stored_cred.public_key, credential_public_key=stored_cred.public_key,
credential_current_sign_count=stored_cred.sign_count, credential_current_sign_count=stored_cred.sign_count,

View File

@ -22,8 +22,8 @@ def load_or_create_secret() -> bytes:
if SECRET_FILE.exists(): if SECRET_FILE.exists():
return SECRET_FILE.read_bytes() return SECRET_FILE.read_bytes()
else: else:
# Generate a new 32-byte secret # Generate a new 16-byte secret
secret = secrets.token_bytes(32) secret = secrets.token_bytes(16)
SECRET_FILE.write_bytes(secret) SECRET_FILE.write_bytes(secret)
return secret return secret
@ -47,7 +47,7 @@ class JWTManager:
Returns: Returns:
JWT token string JWT token string
""" """
now = datetime.utcnow() now = datetime.now()
payload = { payload = {
"user_id": str(user_id), "user_id": str(user_id),
"credential_id": credential_id.hex(), "credential_id": credential_id.hex(),
@ -105,7 +105,7 @@ class JWTManager:
# Global JWT manager instance # Global JWT manager instance
_jwt_manager: Optional[JWTManager] = None _jwt_manager: JWTManager | None = None
def get_jwt_manager() -> JWTManager: def get_jwt_manager() -> JWTManager:
@ -114,7 +114,7 @@ def get_jwt_manager() -> JWTManager:
if _jwt_manager is None: if _jwt_manager is None:
secret = load_or_create_secret() secret = load_or_create_secret()
_jwt_manager = JWTManager(secret) _jwt_manager = JWTManager(secret)
return _jwt_manager return _jwt_manager # type: ignore
def create_session_token(user_id: UUID, credential_id: bytes) -> str: def create_session_token(user_id: UUID, credential_id: bytes) -> str:

View File

@ -1 +0,0 @@
# passkeyauth package

View File

@ -1,65 +0,0 @@
"""
AAGUID (Authenticator Attestation GUID) management for WebAuthn credentials.
This module provides functionality to:
- Load AAGUID data from JSON file
- Look up authenticator information by AAGUID
- Return only relevant AAGUID data for user credentials
"""
import json
from pathlib import Path
from typing import Optional
# Path to the AAGUID JSON file
AAGUID_FILE = Path(__file__).parent / "combined_aaguid.json"
class AAGUIDManager:
"""Manages AAGUID data and lookups."""
def __init__(self):
self.aaguid_data: dict[str, dict] = {}
self.load_aaguid_data()
def load_aaguid_data(self) -> None:
"""Load AAGUID data from the JSON file."""
try:
with open(AAGUID_FILE, encoding="utf-8") as f:
self.aaguid_data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Warning: Could not load AAGUID data: {e}")
self.aaguid_data = {}
def get_authenticator_info(self, aaguid: str) -> Optional[dict]:
"""Get authenticator information for a specific AAGUID."""
return self.aaguid_data.get(aaguid)
def get_relevant_aaguids(self, aaguids: set[str]) -> dict[str, dict]:
"""
Get AAGUID information only for the provided set of AAGUIDs.
Args:
aaguids: Set of AAGUID strings that the user has credentials for
Returns:
Dictionary mapping AAGUID to authenticator information for only
the AAGUIDs that the user has and that we have data for
"""
relevant = {}
for aaguid in aaguids:
if aaguid in self.aaguid_data:
relevant[aaguid] = self.aaguid_data[aaguid]
return relevant
# Global AAGUID manager instance
_aaguid_manager: Optional[AAGUIDManager] = None
def get_aaguid_manager() -> AAGUIDManager:
"""Get the global AAGUID manager instance."""
global _aaguid_manager
if _aaguid_manager is None:
_aaguid_manager = AAGUIDManager()
return _aaguid_manager

View File

@ -1,358 +0,0 @@
"""
Minimal FastAPI WebAuthn server with WebSocket support for passkey registration and authentication.
This module provides a simple WebAuthn implementation that:
- Uses WebSocket for real-time communication
- Supports Resident Keys (discoverable credentials) for passwordless authentication
- Maintains challenges locally per connection
- Uses async SQLite database for persistent storage of users and credentials
- Enables true passwordless authentication where users don't need to enter a user_name
"""
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from uuid import UUID, uuid4
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from . import db
from .api_handlers import (
delete_credential,
get_user_credentials,
get_user_info,
logout,
refresh_token,
set_session,
validate_token,
)
from .db import User
from .jwt_manager import create_session_token
from .passkey import Passkey
from .reset_handlers import create_device_addition_link, validate_device_addition_token
from .session_manager import get_user_from_cookie_string
STATIC_DIR = Path(__file__).parent.parent / "static"
passkey = Passkey(
rp_id="localhost",
rp_name="Passkey Auth",
origin="http://localhost:8000",
)
@asynccontextmanager
async def lifespan(app: FastAPI):
await db.init_database()
yield
app = FastAPI(title="Passkey Auth", lifespan=lifespan)
@app.websocket("/ws/new_user_registration")
async def websocket_register_new(ws: WebSocket):
"""Register a new user and with a new passkey credential."""
await ws.accept()
try:
# Data for the new user account
form = await ws.receive_json()
user_id = uuid4()
user_name = form["user_name"]
# WebAuthn registration
credential = await register_chat(ws, user_id, user_name)
# Store the user and credential in the database
await db.create_user_and_credential(
User(user_id, user_name, created_at=datetime.now()),
credential,
)
# Create a session token for the new user
session_token = create_session_token(user_id, credential.credential_id)
await ws.send_json(
{
"status": "success",
"user_id": str(user_id),
"session_token": session_token,
}
)
except ValueError as e:
await ws.send_json({"error": str(e)})
except WebSocketDisconnect:
pass
@app.websocket("/ws/add_credential")
async def websocket_register_add(ws: WebSocket):
"""Register a new credential for an existing user."""
await ws.accept()
try:
# Authenticate user via cookie
cookie_header = ws.headers.get("cookie", "")
user_id = await get_user_from_cookie_string(cookie_header)
if not user_id:
await ws.send_json({"error": "Authentication required"})
return
# Get user information to get the user_name
user = await db.get_user_by_id(user_id)
user_name = user.user_name
challenge_ids = await db.get_user_credentials(user_id)
# WebAuthn registration
credential = await register_chat(ws, user_id, user_name, challenge_ids)
print(f"New credential for user {user_id}: {credential}")
# Store the new credential in the database
await db.create_credential_for_user(credential)
await ws.send_json(
{
"status": "success",
"user_id": str(user_id),
"credential_id": credential.credential_id.hex(),
"message": "New credential added successfully",
}
)
except ValueError as e:
await ws.send_json({"error": str(e)})
except WebSocketDisconnect:
pass
except Exception as e:
await ws.send_json({"error": f"Server error: {str(e)}"})
@app.websocket("/ws/add_device_credential")
async def websocket_add_device_credential(ws: WebSocket):
"""Add a new credential for an existing user via device addition token."""
await ws.accept()
try:
# Get device addition token from client
message = await ws.receive_json()
token = message.get("token")
if not token:
await ws.send_json({"error": "Device addition token is required"})
return
# Validate device addition token
reset_token = await db.get_reset_token(token)
if not reset_token:
await ws.send_json({"error": "Invalid or expired device addition token"})
return
# Check if token is expired (24 hours)
from datetime import timedelta
expiry_time = reset_token.created_at + timedelta(hours=24)
if datetime.now() > expiry_time:
await ws.send_json({"error": "Device addition token has expired"})
return
# Get user information
user = await db.get_user_by_id(reset_token.user_id)
challenge_ids = await db.get_user_credentials(reset_token.user_id)
# WebAuthn registration
credential = await register_chat(
ws, reset_token.user_id, user.user_name, challenge_ids
)
# Store the new credential in the database
await db.create_credential_for_user(credential)
# Delete the device addition token (it's now used)
await db.delete_reset_token(token)
await ws.send_json(
{
"status": "success",
"user_id": str(reset_token.user_id),
"credential_id": credential.credential_id.hex(),
"message": "New credential added successfully via device addition token",
}
)
except ValueError as e:
await ws.send_json({"error": str(e)})
except WebSocketDisconnect:
pass
except Exception as e:
await ws.send_json({"error": f"Server error: {str(e)}"})
async def register_chat(
ws: WebSocket,
user_id: UUID,
user_name: str,
credential_ids: list[bytes] | None = None,
):
"""Generate registration options and send them to the client."""
options, challenge = passkey.reg_generate_options(
user_id=user_id,
user_name=user_name,
credential_ids=credential_ids,
)
await ws.send_json(options)
response = await ws.receive_json()
print(response)
return passkey.reg_verify(response, challenge, user_id)
@app.websocket("/ws/authenticate")
async def websocket_authenticate(ws: WebSocket):
await ws.accept()
try:
options, challenge = passkey.auth_generate_options()
await ws.send_json(options)
# Wait for the client to use his authenticator to authenticate
credential = passkey.auth_parse(await ws.receive_json())
# Fetch from the database by credential ID
stored_cred = await db.get_credential_by_id(credential.raw_id)
# Verify the credential matches the stored data
passkey.auth_verify(credential, challenge, stored_cred)
# Update both credential and user's last_seen timestamp
await db.login_user(stored_cred.user_id, stored_cred)
# Create a session token for the authenticated user
session_token = create_session_token(
stored_cred.user_id, stored_cred.credential_id
)
await ws.send_json(
{
"status": "success",
"user_id": str(stored_cred.user_id),
"session_token": session_token,
}
)
except ValueError as e:
await ws.send_json({"error": str(e)})
except WebSocketDisconnect:
pass
@app.get("/api/user-info")
async def api_get_user_info(request: Request):
"""Get user information from session cookie."""
return await get_user_info(request)
@app.get("/api/user-credentials")
async def api_get_user_credentials(request: Request):
"""Get all credentials for a user using session cookie."""
return await get_user_credentials(request)
@app.post("/api/refresh-token")
async def api_refresh_token(request: Request, response: Response):
"""Refresh the session token."""
return await refresh_token(request, response)
@app.get("/api/validate-token")
async def api_validate_token(request: Request):
"""Validate a session token and return user info."""
return await validate_token(request)
@app.post("/api/logout")
async def api_logout(response: Response):
"""Log out the current user by clearing the session cookie."""
return await logout(response)
@app.post("/api/set-session")
async def api_set_session(request: Request, response: Response):
"""Set session cookie using JWT token from request body or Authorization header."""
return await set_session(request, response)
@app.post("/api/delete-credential")
async def api_delete_credential(request: Request):
"""Delete a specific credential for the current user."""
return await delete_credential(request)
@app.post("/api/create-device-link")
async def api_create_device_link(request: Request):
"""Create a device addition link for the authenticated user."""
return await create_device_addition_link(request)
@app.post("/api/validate-device-token")
async def api_validate_device_token(request: Request):
"""Validate a device addition token."""
return await validate_device_addition_token(request)
# Serve static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/")
async def get_index():
"""Redirect to login page"""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/auth/login", status_code=302)
@app.get("/auth/login")
async def get_login_page():
"""Serve the login page"""
return FileResponse(STATIC_DIR / "login.html")
@app.get("/auth/register")
async def get_register_page():
"""Serve the register page"""
return FileResponse(STATIC_DIR / "register.html")
@app.get("/auth/dashboard")
async def get_dashboard_page():
"""Redirect to profile (dashboard is now profile)"""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/auth/profile", status_code=302)
@app.get("/auth/profile")
async def get_profile_page():
"""Serve the profile page"""
return FileResponse(STATIC_DIR / "profile.html")
@app.get("/auth/reset")
async def get_reset_page_without_token():
"""Serve the reset page without a token"""
return FileResponse(STATIC_DIR / "reset.html")
@app.get("/reset/{token}")
async def get_reset_page(token: str):
"""Serve the reset page with the token in URL"""
return FileResponse(STATIC_DIR / "reset.html")
def main():
"""Entry point for the application"""
import uvicorn
uvicorn.run(
"passkeyauth.main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info",
)
if __name__ == "__main__":
main()

View File

@ -3,15 +3,14 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project] [project]
name = "passkeyauth" name = "passkey"
version = "0.1.0" version = "0.1.0"
description = "Minimal FastAPI WebAuthn server with WebSocket support" description = "Passkey Authentication for Web Services"
authors = [ authors = [
{name = "User", email = "user@example.com"}, {name = "Leo Vasanko"},
] ]
dependencies = [ dependencies = [
"fastapi[standard]>=0.104.1", "fastapi[standard]>=0.104.1",
"uvicorn[standard]>=0.24.0",
"websockets>=12.0", "websockets>=12.0",
"webauthn>=1.11.1", "webauthn>=1.11.1",
"base64url>=1.0.0", "base64url>=1.0.0",
@ -34,7 +33,11 @@ select = ["E", "F", "I", "N", "W", "UP"]
ignore = ["E501"] # Line too long ignore = ["E501"] # Line too long
[tool.ruff.isort] [tool.ruff.isort]
known-first-party = ["passkeyauth"] known-first-party = ["passkey"]
[project.scripts] [project.scripts]
serve = "passkeyauth.main:main" serve = "passkey.main:main"
[tool.hatch.build]
artifacts = ["passkeyauth/frontend-static"]
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"

36
scripts/build-frontend.py Normal file
View File

@ -0,0 +1,36 @@
# noqa: INP001
import os
import shutil
import subprocess
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
super().initialize(version, build_data)
stderr.write(">>> Building Jacloud frontend\n")
npm = None
bun = shutil.which("bun")
if bun is None:
npm = shutil.which("npm")
if npm is None:
raise RuntimeError(
"Bun or NodeJS `npm` is required for building but neither was found"
)
# npm --prefix doesn't work on Windows, so we chdir instead
os.chdir("frontend")
try:
if npm:
stderr.write("### npm install\n")
subprocess.run([npm, "install"], check=True) # noqa: S603
stderr.write("\n### npm run build\n")
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
else:
stderr.write("### bun install\n")
subprocess.run([bun, "install"], check=True) # noqa: S603
stderr.write("\n### bun run build\n")
subprocess.run([bun, "run", "build"], check=True) # noqa: S603
finally:
os.chdir("..")

View File

@ -1,534 +0,0 @@
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser
// Global state
let currentUser = null
let currentCredentials = []
let aaguidInfo = {}
// ========================================
// Session Management
// ========================================
async function validateStoredToken() {
try {
const response = await fetch('/api/validate-token', {
method: 'GET',
credentials: 'include'
})
const result = await response.json()
return result.status === 'success'
} catch (error) {
return false
}
}
async function setSessionCookie(sessionToken) {
try {
const response = await fetch('/api/set-session', {
method: 'POST',
headers: {
'Authorization': `Bearer ${sessionToken}`,
'Content-Type': 'application/json'
},
credentials: 'include'
})
const result = await response.json()
if (result.error) {
throw new Error(result.error)
}
return result
} catch (error) {
throw new Error(`Failed to set session cookie: ${error.message}`)
}
}
// ========================================
// View Management
// ========================================
function showView(viewId) {
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'))
const targetView = document.getElementById(viewId)
if (targetView) {
targetView.classList.add('active')
}
}
function showLoginView() {
if (window.location.pathname !== '/auth/login') {
window.location.href = '/auth/login'
return
}
showView('loginView')
clearStatus('loginStatus')
}
function showRegisterView() {
if (window.location.pathname !== '/auth/register') {
window.location.href = '/auth/register'
return
}
showView('registerView')
clearStatus('registerStatus')
}
function showDeviceAdditionView() {
// This function is no longer needed as device addition is now a dialog
// Redirect to profile page if someone tries to access the old route
if (window.location.pathname === '/auth/add-device') {
window.location.href = '/auth/profile'
return
}
}
async function showDashboardView() {
if (window.location.pathname !== '/auth/profile') {
window.location.href = '/auth/profile'
return
}
showView('profileView')
clearStatus('profileStatus')
try {
await loadUserInfo()
updateUserInfo()
await loadCredentials()
} catch (error) {
showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
}
}
// ========================================
// Status Management
// ========================================
function showStatus(elementId, message, type = 'info') {
const statusEl = document.getElementById(elementId)
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
}
function clearStatus(elementId) {
document.getElementById(elementId).innerHTML = ''
}
// ========================================
// Device Addition & QR Code
// ========================================
async function copyDeviceLink() {
try {
if (window.currentDeviceLink) {
await navigator.clipboard.writeText(window.currentDeviceLink)
const copyButton = document.querySelector('.copy-button')
const originalText = copyButton.textContent
copyButton.textContent = 'Copied!'
copyButton.style.background = '#28a745'
setTimeout(() => {
copyButton.textContent = originalText
copyButton.style.background = '#28a745'
}, 2000)
}
} catch (error) {
console.error('Failed to copy link:', error)
const linkText = document.getElementById('deviceLinkText')
const range = document.createRange()
range.selectNode(linkText)
window.getSelection().removeAllRanges()
window.getSelection().addRange(range)
}
}
// ========================================
// WebAuthn Operations
// ========================================
async function register(user_name) {
const ws = await aWebSocket('/ws/new_user_registration')
ws.send(JSON.stringify({ user_name }))
const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error)
const registrationResponse = await startRegistration({ optionsJSON })
ws.send(JSON.stringify(registrationResponse))
const result = JSON.parse(await ws.recv())
if (result.error) throw new Error(`Server: ${result.error}`)
await setSessionCookie(result.session_token)
ws.close()
}
async function authenticate() {
const ws = await aWebSocket('/ws/authenticate')
const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error)
const authenticationResponse = await startAuthentication({ optionsJSON })
ws.send(JSON.stringify(authenticationResponse))
const result = JSON.parse(await ws.recv())
if (result.error) throw new Error(`Server: ${result.error}`)
await setSessionCookie(result.session_token)
ws.close()
}
async function addNewCredential() {
try {
showStatus('dashboardStatus', 'Adding new passkey...', 'info')
const ws = await aWebSocket('/ws/add_credential')
const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error)
const registrationResponse = await startRegistration({ optionsJSON })
ws.send(JSON.stringify(registrationResponse))
const result = JSON.parse(await ws.recv())
if (result.error) throw new Error(`Server: ${result.error}`)
ws.close()
showStatus('dashboardStatus', 'New passkey added successfully!', 'success')
setTimeout(() => {
loadCredentials()
clearStatus('dashboardStatus')
}, 2000)
} catch (error) {
showStatus('dashboardStatus', `Failed to add passkey: ${error.message}`, 'error')
}
}
// ========================================
// User Data Management
// ========================================
// User registration
async function register(user_name) {
try {
const ws = await aWebSocket('/ws/new_user_registration')
ws.send(JSON.stringify({user_name}))
// Registration chat
const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error)
showStatus('registerStatus', 'Save to your authenticator...', 'info')
const registrationResponse = await startRegistration({optionsJSON})
ws.send(JSON.stringify(registrationResponse))
const result = JSON.parse(await ws.recv())
if (result.error) throw new Error(`Server: ${result.error}`)
ws.close()
// Set session cookie using the JWT token
await setSessionCookie(result.session_token)
// Set current user from registration result
currentUser = {
user_id: result.user_id,
user_name: user_name,
last_seen: new Date().toISOString()
}
return result
} catch (error) {
throw error
}
}
// User authentication
async function authenticate() {
try {
const ws = await aWebSocket('/ws/authenticate')
const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error)
showStatus('loginStatus', 'Please touch your authenticator...', 'info')
const authResponse = await startAuthentication({optionsJSON})
await ws.send(JSON.stringify(authResponse))
const result = JSON.parse(await ws.recv())
if (result.error) throw new Error(`Server: ${result.error}`)
ws.close()
// Set session cookie using the JWT token
await setSessionCookie(result.session_token)
// Authentication successful, now get user info using HTTP endpoint
const userResponse = await fetch('/api/user-info', {
method: 'GET',
credentials: 'include'
})
const userInfo = await userResponse.json()
if (userInfo.error) throw new Error(`Server: ${userInfo.error}`)
currentUser = userInfo.user
return result
} catch (error) {
throw error
}
}
// Load user credentials
async function loadCredentials() {
try {
const statusElement = document.getElementById('profileStatus') ? 'profileStatus' : 'dashboardStatus'
showStatus(statusElement, 'Loading credentials...', 'info')
const response = await fetch('/api/user-credentials', {
method: 'GET',
credentials: 'include'
})
const result = await response.json()
if (result.error) throw new Error(`Server: ${result.error}`)
currentCredentials = result.credentials
aaguidInfo = result.aaguid_info || {}
updateCredentialList()
clearStatus(statusElement)
} catch (error) {
const statusElement = document.getElementById('profileStatus') ? 'profileStatus' : 'dashboardStatus'
showStatus(statusElement, `Failed to load credentials: ${error.message}`, 'error')
}
}
// Load user info using HTTP endpoint
async function loadUserInfo() {
try {
const response = await fetch('/api/user-info', {
method: 'GET',
credentials: 'include'
})
const result = await response.json()
if (result.error) throw new Error(`Server: ${result.error}`)
currentUser = result.user
} catch (error) {
throw error
}
}
// Update user info display
function updateUserInfo() {
const userInfoEl = document.getElementById('userInfo')
if (currentUser) {
userInfoEl.innerHTML = `
<h3>👤 ${currentUser.user_name}</h3>
<p><strong>Visits:</strong> ${currentUser.visits || 0}</p>
<p><strong>Member since:</strong> ${currentUser.created_at ? formatHumanReadableDate(currentUser.created_at) : 'N/A'}</p>
<p><strong>Last seen:</strong> ${currentUser.last_seen ? formatHumanReadableDate(currentUser.last_seen) : 'N/A'}</p>
`
}
}
// Update credential list display
function updateCredentialList() {
const credentialListEl = document.getElementById('credentialList')
if (currentCredentials.length === 0) {
credentialListEl.innerHTML = '<p>No passkeys found.</p>'
return
}
credentialListEl.innerHTML = currentCredentials.map(cred => {
// Get authenticator information from AAGUID
const authInfo = aaguidInfo[cred.aaguid]
const authName = authInfo ? authInfo.name : 'Unknown Authenticator'
// Determine which icon to use based on current theme (you can implement theme detection)
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
const authIcon = authInfo && authInfo[iconKey] ? authInfo[iconKey] : null
// Check if this is the current session credential
const isCurrentSession = cred.is_current_session || false
return `
<div class="credential-item${isCurrentSession ? ' current-session' : ''}">
<div class="credential-header">
<div class="credential-icon">
${authIcon ? `<img src="${authIcon}" alt="${authName}" class="auth-icon" width="32" height="32">` : '<span class="auth-emoji">🔑</span>'}
</div>
<div class="credential-info">
<h4>${authName}</h4>
</div>
<div class="credential-dates">
<span class="date-label">Created:</span>
<span class="date-value">${formatHumanReadableDate(cred.created_at)}</span>
<span class="date-label">Last used:</span>
<span class="date-value">${formatHumanReadableDate(cred.last_used)}</span>
</div>
<div class="credential-actions">
<button onclick="deleteCredential('${cred.credential_id}')"
class="btn-delete-credential"
${isCurrentSession ? 'disabled title="Cannot delete current session credential"' : ''}>
🗑
</button>
</div>
</div>
</div>
`
}).join('')
}
// Helper function to format dates in a human-readable way
function formatHumanReadableDate(dateString) {
if (!dateString) return 'Never'
const date = new Date(dateString)
const now = new Date()
const diffMs = now - date
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffHours < 1) {
return 'Just now'
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
} else if (diffDays <= 7) {
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
} else {
// For dates older than 7 days, show just the date without time
return date.toLocaleDateString()
}
}
// Logout
async function logout() {
try {
await fetch('/api/logout', {
method: 'POST',
credentials: 'include'
})
} catch (error) {
console.error('Logout error:', error)
}
currentUser = null
currentCredentials = []
aaguidInfo = {}
window.location.href = '/auth/login'
}
// Check if user is already logged in on page load
async function checkExistingSession() {
const isLoggedIn = await validateStoredToken()
const path = window.location.pathname
// Protected routes that require authentication
const protectedRoutes = ['/auth/profile']
if (isLoggedIn) {
// User is logged in
if (path === '/auth/login' || path === '/auth/register' || path === '/') {
// Redirect to profile if accessing login/register pages while logged in
window.location.href = '/auth/profile'
} else if (path === '/auth/add-device') {
// Redirect old add-device route to profile
window.location.href = '/auth/profile'
} else if (protectedRoutes.includes(path)) {
// Stay on current protected page and load user data
if (path === '/auth/profile') {
loadUserInfo().then(() => {
updateUserInfo()
loadCredentials()
}).catch(error => {
showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
})
}
}
} else {
// User is not logged in
if (protectedRoutes.includes(path) || path === '/auth/add-device') {
// Redirect to login if accessing protected pages without authentication
window.location.href = '/auth/login'
}
}
}
// Initialize the app based on current page
function initializeApp() {
checkExistingSession()
}
// Form event handlers
document.addEventListener('DOMContentLoaded', function() {
// Check for existing session on page load
initializeApp()
// Registration form
const regForm = document.getElementById('registrationForm')
if (regForm) {
const regSubmitBtn = regForm.querySelector('button[type="submit"]')
regForm.addEventListener('submit', async (ev) => {
ev.preventDefault()
regSubmitBtn.disabled = true
clearStatus('registerStatus')
const user_name = (new FormData(regForm)).get('username')
try {
showStatus('registerStatus', 'Starting registration...', 'info')
await register(user_name)
showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success')
// Auto-login after successful registration
setTimeout(() => {
window.location.href = '/auth/profile'
}, 1500)
} catch (err) {
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error')
} finally {
regSubmitBtn.disabled = false
}
})
}
// Authentication form
const authForm = document.getElementById('authenticationForm')
if (authForm) {
const authSubmitBtn = authForm.querySelector('button[type="submit"]')
authForm.addEventListener('submit', async (ev) => {
ev.preventDefault()
authSubmitBtn.disabled = true
clearStatus('loginStatus')
try {
showStatus('loginStatus', 'Starting authentication...', 'info')
await authenticate()
showStatus('loginStatus', 'Authentication successful!', 'success')
// Navigate to profile
setTimeout(() => {
window.location.href = '/auth/profile'
}, 1000)
} catch (err) {
showStatus('loginStatus', `Authentication failed: ${err.message}`, 'error')
} finally {
authSubmitBtn.disabled = false
}
})
}
})

View File

@ -1,43 +0,0 @@
class AwaitableWebSocket extends WebSocket {
#received = []
#waiting = []
#err = null
#opened = false
constructor(resolve, reject, url, protocols) {
super(url, protocols)
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))
}
}
recv() {
// 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 }))
}
}
// Construct an async WebSocket with await aWebSocket(url)
function aWebSocket(url, protocols) {
return new Promise((resolve, reject) => {
new AwaitableWebSocket(resolve, reject, url, protocols)
})
}

View File

@ -1,92 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/qrcodejs/qrcode.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
</head>
<body>
<div class="container">
<!-- Login View -->
<div id="loginView" class="view active">
<h1>🔐 Passkey Login</h1>
<div id="loginStatus"></div>
<form id="authenticationForm">
<button type="submit" class="btn-primary">Login with Your Device</button>
</form>
<p class="toggle-link" onclick="showRegisterView()">
Don't have an account? Register here
</p>
</div>
<!-- Register View -->
<div id="registerView" class="view">
<h1>🔐 Create Account</h1>
<div id="registerStatus"></div>
<form id="registrationForm">
<input type="text" name="username" placeholder="Enter username" required>
<button type="submit" class="btn-primary">Register Passkey</button>
</form>
<p class="toggle-link" onclick="showLoginView()">
Already have an account? Login here
</p>
</div>
<!-- Dashboard View -->
<div id="dashboardView" class="view">
<h1>👋 Welcome!</h1>
<div id="userInfo" class="user-info"></div>
<div id="dashboardStatus"></div>
<h2>Your Passkeys</h2>
<div id="credentialList" class="credential-list">
<p>Loading credentials...</p>
</div>
<button onclick="addNewCredential()" class="btn-primary">
Add New Passkey
</button>
<button onclick="generateAndShowDeviceLink()" class="btn-secondary">
Generate Device Link
</button>
<button onclick="logout()" class="btn-danger">
Logout
</button>
</div>
<!-- Device Addition View -->
<div id="deviceAdditionView" class="view">
<h1>📱 Add Device</h1>
<div id="deviceAdditionStatus"></div>
<div id="deviceLinkSection">
<h2>Device Addition Link</h2>
<div class="token-info">
<p><strong>Share this link to add this account to another device:</strong></p>
<div class="qr-container">
<div id="qrCode" class="qr-code"></div>
<p><small>Scan this QR code with your other device</small></p>
</div>
<div class="link-container">
<p class="link-text" id="deviceLinkText">Loading...</p>
<button class="copy-button" onclick="copyDeviceLink()">Copy Link</button>
</div>
<p><small>⚠️ This link expires in 24 hours and can only be used once.</small></p>
<p><strong>Human-readable code:</strong> <code id="deviceToken"></code></p>
</div>
</div>
<button onclick="showDashboardView()" class="btn-secondary">
Back to Dashboard
</button>
</div>
</div>
<script src="static/app.js"></script>
</body>
</html>

View File

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Login - Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
</head>
<body>
<div class="container">
<!-- Login View -->
<div id="loginView" class="view active">
<h1>🔐 Passkey Login</h1>
<div id="loginStatus"></div>
<form id="authenticationForm">
<button type="submit" class="btn-primary">Login with Your Device</button>
</form>
<p class="toggle-link" onclick="window.location.href='/auth/register'">
Don't have an account? Register here
</p>
</div>
</div>
<script src="/static/app.js"></script>
<script src="/static/util.js"></script>
<script src="/static/login.js"></script>
</body>
</html>

View File

@ -1,33 +0,0 @@
// Login page specific functionality
document.addEventListener('DOMContentLoaded', function() {
// Initialize the app
initializeApp()
// Authentication form handler
const authForm = document.getElementById('authenticationForm')
if (authForm) {
const authSubmitBtn = authForm.querySelector('button[type="submit"]')
authForm.addEventListener('submit', async (ev) => {
ev.preventDefault()
authSubmitBtn.disabled = true
clearStatus('loginStatus')
try {
showStatus('loginStatus', 'Starting authentication...', 'info')
await authenticate()
showStatus('loginStatus', 'Authentication successful!', 'success')
// Navigate to profile
setTimeout(() => {
window.location.href = '/auth/profile'
}, 1000)
} catch (err) {
showStatus('loginStatus', `Authentication failed: ${err.message}`, 'error')
} finally {
authSubmitBtn.disabled = false
}
})
}
})

View File

@ -1,36 +0,0 @@
/* Profile page dialog styles */
.container.dialog-open {
filter: blur(2px);
pointer-events: none;
user-select: none;
}
/* Dialog styling */
#deviceLinkDialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 999;
color: black;
background: white;
border: none;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
#deviceLinkDialog::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
/* Prevent scrolling when dialog is open */
body.dialog-open {
overflow: hidden;
}

View File

@ -1,64 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Profile - Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/profile-dialog.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/qrcodejs/qrcode.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
</head>
<body>
<div class="container">
<!-- Profile View -->
<div id="profileView" class="view active">
<h1>👋 Welcome!</h1>
<div id="userInfo" class="user-info"></div>
<div id="profileStatus"></div>
<h2>Your Passkeys</h2>
<div id="credentialList" class="credential-list">
<p>Loading credentials...</p>
</div>
<button onclick="addNewCredential()" class="btn-primary">
Add New Passkey
</button>
<button onclick="openDeviceLinkDialog()" class="btn-secondary">
Generate Device Link
</button>
<button onclick="logout()" class="btn-danger">
Logout
</button>
</div>
<!-- Device Link Dialog -->
<dialog id="deviceLinkDialog">
<h1>📱 Add Device</h1>
<div id="deviceAdditionStatus"></div>
<div id="deviceLinkSection">
<h2>Device Addition Link</h2>
<div class="token-info">
<div class="qr-container">
<div id="qrCode" class="qr-code"></div>
<p><a href="#" id="deviceLinkText"></a></p>
</div>
<p>
<strong>Scan the above code and visit the URL on another device.</strong><br>
<small>⚠️ Expires in 24 hours and can only be used once.</small>
</p>
</div>
</div>
<button onclick="closeDeviceLinkDialog()" class="btn-secondary">
Close
</button>
</dialog>
</div>
<script src="/static/app.js"></script>
<script src="/static/profile.js"></script>
</body>
</html>

View File

@ -1,124 +0,0 @@
// Profile page specific functionality
document.addEventListener('DOMContentLoaded', function() {
// Initialize the app
initializeApp()
// Setup dialog event handlers
setupDialogHandlers()
})
// Setup dialog event handlers
function setupDialogHandlers() {
// Close dialog when clicking outside
const dialog = document.getElementById('deviceLinkDialog')
if (dialog) {
dialog.addEventListener('click', function(e) {
if (e.target === this) {
closeDeviceLinkDialog()
}
})
}
// Close dialog when pressing Escape key
document.addEventListener('keydown', function(e) {
const dialog = document.getElementById('deviceLinkDialog')
if (e.key === 'Escape' && dialog && dialog.open) {
closeDeviceLinkDialog()
}
})
}
// Open device link dialog
function openDeviceLinkDialog() {
const dialog = document.getElementById('deviceLinkDialog')
const container = document.querySelector('.container')
const body = document.body
if (dialog && container && body) {
// Add blur and disable effects
container.classList.add('dialog-open')
body.classList.add('dialog-open')
dialog.showModal()
generateDeviceLink()
}
}
// Close device link dialog
function closeDeviceLinkDialog() {
const dialog = document.getElementById('deviceLinkDialog')
const container = document.querySelector('.container')
const body = document.body
if (dialog && container && body) {
// Remove blur and disable effects
container.classList.remove('dialog-open')
body.classList.remove('dialog-open')
dialog.close()
}
}
// Generate device link function
async function generateDeviceLink() {
clearStatus('deviceAdditionStatus')
showStatus('deviceAdditionStatus', 'Generating device link...', 'info')
try {
const response = await fetch('/api/create-device-link', {
method: 'POST',
credentials: 'include'
})
const result = await response.json()
if (result.error) {
throw new Error(result.error)
}
// Update UI with the link
const deviceLinkText = document.getElementById('deviceLinkText')
if (deviceLinkText) {
deviceLinkText.href = result.addition_link
deviceLinkText.textContent = result.addition_link.replace(/^[a-z]+:\/\//i, '')
// Add click event listener for copying the link
deviceLinkText.addEventListener('click', function(e) {
e.preventDefault() // Prevent navigation
navigator.clipboard.writeText(deviceLinkText.href).then(() => {
closeDeviceLinkDialog() // Close the dialog
showStatus('deviceAdditionStatus', 'Device registration link copied', 'success') // Display status
}).catch(() => {
showStatus('deviceAdditionStatus', 'Failed to copy device registration link', 'error')
})
})
}
// Store link globally for copy function
window.currentDeviceLink = result.addition_link
// Generate QR code
const qrCodeEl = document.getElementById('qrCode')
if (qrCodeEl && typeof QRCode !== 'undefined') {
qrCodeEl.innerHTML = ''
new QRCode(qrCodeEl, {
text: result.addition_link,
width: 200,
height: 200,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
})
}
clearStatus('deviceAdditionStatus')
} catch (error) {
showStatus('deviceAdditionStatus', `Failed to generate device link: ${error.message}`, 'error')
}
}
// Make functions available globally for onclick handlers
window.openDeviceLinkDialog = openDeviceLinkDialog
window.closeDeviceLinkDialog = closeDeviceLinkDialog

View File

@ -1,4 +0,0 @@
.DS_Store
.idea
.project

View File

@ -1,14 +0,0 @@
The MIT License (MIT)
---------------------
Copyright (c) 2012 davidshimjs
Permission is hereby granted, free of charge,
to any person obtaining a copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,46 +0,0 @@
# QRCode.js
QRCode.js is javascript library for making QRCode. QRCode.js supports Cross-browser with HTML5 Canvas and table tag in DOM.
QRCode.js has no dependencies.
## Basic Usages
```
<div id="qrcode"></div>
<script type="text/javascript">
new QRCode(document.getElementById("qrcode"), "http://jindo.dev.naver.com/collie");
</script>
```
or with some options
```
<div id="qrcode"></div>
<script type="text/javascript">
var qrcode = new QRCode(document.getElementById("qrcode"), {
text: "http://jindo.dev.naver.com/collie",
width: 128,
height: 128,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRCode.CorrectLevel.H
});
</script>
```
and you can use some methods
```
qrcode.clear(); // clear the code.
qrcode.makeCode("http://naver.com"); // make another code.
```
## Browser Compatibility
IE6~10, Chrome, Firefox, Safari, Opera, Mobile Safari, Android, Windows Mobile, ETC.
## License
MIT License
## Contact
twitter @davidshimjs
[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/davidshimjs/qrcodejs/trend.png)](https://bitdeli.com/free "Bitdeli Badge")

View File

@ -1,18 +0,0 @@
{
"name": "qrcode.js",
"version": "0.0.1",
"homepage": "https://github.com/davidshimjs/qrcodejs",
"authors": [
"Sangmin Shim", "Sangmin Shim <ssm0123@gmail.com> (http://jaguarjs.com)"
],
"description": "Cross-browser QRCode generator for javascript",
"main": "qrcode.js",
"ignore": [
"bower_components",
"node_modules",
"index.html",
"index.svg",
"jquery.min.js",
"qrcode.min.js"
]
}

View File

@ -1,47 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko" lang="ko">
<head>
<title>Cross-Browser QRCode generator for Javascript</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
<script type="text/javascript" src="jquery.min.js"></script>
<script type="text/javascript" src="qrcode.js"></script>
</head>
<body>
<input id="text" type="text" value="http://jindo.dev.naver.com/collie" style="width:80%" />
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="qrcode"/>
</svg>
<script type="text/javascript">
var qrcode = new QRCode(document.getElementById("qrcode"), {
width : 100,
height : 100,
useSVG: true
});
function makeCode () {
var elText = document.getElementById("text");
if (!elText.value) {
alert("Input a text");
elText.focus();
return;
}
qrcode.makeCode(elText.value);
}
makeCode();
$("#text").
on("blur", function () {
makeCode();
}).
on("keydown", function (e) {
if (e.keyCode == 13) {
makeCode();
}
});
</script>
</body>
</html>

View File

@ -1,44 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko" lang="ko">
<head>
<title>Cross-Browser QRCode generator for Javascript</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
<script type="text/javascript" src="jquery.min.js"></script>
<script type="text/javascript" src="qrcode.js"></script>
</head>
<body>
<input id="text" type="text" value="http://jindo.dev.naver.com/collie" style="width:80%" /><br />
<div id="qrcode" style="width:100px; height:100px; margin-top:15px;"></div>
<script type="text/javascript">
var qrcode = new QRCode(document.getElementById("qrcode"), {
width : 100,
height : 100
});
function makeCode () {
var elText = document.getElementById("text");
if (!elText.value) {
alert("Input a text");
elText.focus();
return;
}
qrcode.makeCode(elText.value);
}
makeCode();
$("#text").
on("blur", function () {
makeCode();
}).
on("keydown", function (e) {
if (e.keyCode == 13) {
makeCode();
}
});
</script>
</body>

View File

@ -1,37 +0,0 @@
<?xml version="1.0" standalone="yes"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-50 0 200 100">
<g id="qrcode"/>
<foreignObject x="-50" y="0" width="100" height="100">
<body xmlns="http://www.w3.org/1999/xhtml" style="padding:0; margin:0">
<div style="padding:inherit; margin:inherit; height:100%">
<textarea id="text" style="height:100%; width:100%; position:absolute; margin:inherit; padding:inherit">james</textarea>
</div>
<script type="application/ecmascript" src="qrcode.js"></script>
<script type="application/ecmascript">
var elem = document.getElementById("qrcode");
var qrcode = new QRCode(elem, {
width : 100,
height : 100
});
function makeCode () {
var elText = document.getElementById("text");
if (elText.value === "") {
//alert("Input a text");
//elText.focus();
return;
}
qrcode.makeCode(elText.value);
}
makeCode();
document.getElementById("text").onkeyup = function (e) {
makeCode();
};
</script>
</body>
</foreignObject>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,614 +0,0 @@
/**
* @fileoverview
* - Using the 'QRCode for Javascript library'
* - Fixed dataset of 'QRCode for Javascript library' for support full-spec.
* - this library has no dependencies.
*
* @author davidshimjs
* @see <a href="http://www.d-project.com/" target="_blank">http://www.d-project.com/</a>
* @see <a href="http://jeromeetienne.github.com/jquery-qrcode/" target="_blank">http://jeromeetienne.github.com/jquery-qrcode/</a>
*/
var QRCode;
(function () {
//---------------------------------------------------------------------
// QRCode for JavaScript
//
// Copyright (c) 2009 Kazuhiko Arase
//
// URL: http://www.d-project.com/
//
// Licensed under the MIT license:
// http://www.opensource.org/licenses/mit-license.php
//
// The word "QR Code" is registered trademark of
// DENSO WAVE INCORPORATED
// http://www.denso-wave.com/qrcode/faqpatent-e.html
//
//---------------------------------------------------------------------
function QR8bitByte(data) {
this.mode = QRMode.MODE_8BIT_BYTE;
this.data = data;
this.parsedData = [];
// Added to support UTF-8 Characters
for (var i = 0, l = this.data.length; i < l; i++) {
var byteArray = [];
var code = this.data.charCodeAt(i);
if (code > 0x10000) {
byteArray[0] = 0xF0 | ((code & 0x1C0000) >>> 18);
byteArray[1] = 0x80 | ((code & 0x3F000) >>> 12);
byteArray[2] = 0x80 | ((code & 0xFC0) >>> 6);
byteArray[3] = 0x80 | (code & 0x3F);
} else if (code > 0x800) {
byteArray[0] = 0xE0 | ((code & 0xF000) >>> 12);
byteArray[1] = 0x80 | ((code & 0xFC0) >>> 6);
byteArray[2] = 0x80 | (code & 0x3F);
} else if (code > 0x80) {
byteArray[0] = 0xC0 | ((code & 0x7C0) >>> 6);
byteArray[1] = 0x80 | (code & 0x3F);
} else {
byteArray[0] = code;
}
this.parsedData.push(byteArray);
}
this.parsedData = Array.prototype.concat.apply([], this.parsedData);
if (this.parsedData.length != this.data.length) {
this.parsedData.unshift(191);
this.parsedData.unshift(187);
this.parsedData.unshift(239);
}
}
QR8bitByte.prototype = {
getLength: function (buffer) {
return this.parsedData.length;
},
write: function (buffer) {
for (var i = 0, l = this.parsedData.length; i < l; i++) {
buffer.put(this.parsedData[i], 8);
}
}
};
function QRCodeModel(typeNumber, errorCorrectLevel) {
this.typeNumber = typeNumber;
this.errorCorrectLevel = errorCorrectLevel;
this.modules = null;
this.moduleCount = 0;
this.dataCache = null;
this.dataList = [];
}
QRCodeModel.prototype={addData:function(data){var newData=new QR8bitByte(data);this.dataList.push(newData);this.dataCache=null;},isDark:function(row,col){if(row<0||this.moduleCount<=row||col<0||this.moduleCount<=col){throw new Error(row+","+col);}
return this.modules[row][col];},getModuleCount:function(){return this.moduleCount;},make:function(){this.makeImpl(false,this.getBestMaskPattern());},makeImpl:function(test,maskPattern){this.moduleCount=this.typeNumber*4+17;this.modules=new Array(this.moduleCount);for(var row=0;row<this.moduleCount;row++){this.modules[row]=new Array(this.moduleCount);for(var col=0;col<this.moduleCount;col++){this.modules[row][col]=null;}}
this.setupPositionProbePattern(0,0);this.setupPositionProbePattern(this.moduleCount-7,0);this.setupPositionProbePattern(0,this.moduleCount-7);this.setupPositionAdjustPattern();this.setupTimingPattern();this.setupTypeInfo(test,maskPattern);if(this.typeNumber>=7){this.setupTypeNumber(test);}
if(this.dataCache==null){this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList);}
this.mapData(this.dataCache,maskPattern);},setupPositionProbePattern:function(row,col){for(var r=-1;r<=7;r++){if(row+r<=-1||this.moduleCount<=row+r)continue;for(var c=-1;c<=7;c++){if(col+c<=-1||this.moduleCount<=col+c)continue;if((0<=r&&r<=6&&(c==0||c==6))||(0<=c&&c<=6&&(r==0||r==6))||(2<=r&&r<=4&&2<=c&&c<=4)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}},getBestMaskPattern:function(){var minLostPoint=0;var pattern=0;for(var i=0;i<8;i++){this.makeImpl(true,i);var lostPoint=QRUtil.getLostPoint(this);if(i==0||minLostPoint>lostPoint){minLostPoint=lostPoint;pattern=i;}}
return pattern;},createMovieClip:function(target_mc,instance_name,depth){var qr_mc=target_mc.createEmptyMovieClip(instance_name,depth);var cs=1;this.make();for(var row=0;row<this.modules.length;row++){var y=row*cs;for(var col=0;col<this.modules[row].length;col++){var x=col*cs;var dark=this.modules[row][col];if(dark){qr_mc.beginFill(0,100);qr_mc.moveTo(x,y);qr_mc.lineTo(x+cs,y);qr_mc.lineTo(x+cs,y+cs);qr_mc.lineTo(x,y+cs);qr_mc.endFill();}}}
return qr_mc;},setupTimingPattern:function(){for(var r=8;r<this.moduleCount-8;r++){if(this.modules[r][6]!=null){continue;}
this.modules[r][6]=(r%2==0);}
for(var c=8;c<this.moduleCount-8;c++){if(this.modules[6][c]!=null){continue;}
this.modules[6][c]=(c%2==0);}},setupPositionAdjustPattern:function(){var pos=QRUtil.getPatternPosition(this.typeNumber);for(var i=0;i<pos.length;i++){for(var j=0;j<pos.length;j++){var row=pos[i];var col=pos[j];if(this.modules[row][col]!=null){continue;}
for(var r=-2;r<=2;r++){for(var c=-2;c<=2;c++){if(r==-2||r==2||c==-2||c==2||(r==0&&c==0)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}}}},setupTypeNumber:function(test){var bits=QRUtil.getBCHTypeNumber(this.typeNumber);for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[Math.floor(i/3)][i%3+this.moduleCount-8-3]=mod;}
for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[i%3+this.moduleCount-8-3][Math.floor(i/3)]=mod;}},setupTypeInfo:function(test,maskPattern){var data=(this.errorCorrectLevel<<3)|maskPattern;var bits=QRUtil.getBCHTypeInfo(data);for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<6){this.modules[i][8]=mod;}else if(i<8){this.modules[i+1][8]=mod;}else{this.modules[this.moduleCount-15+i][8]=mod;}}
for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<8){this.modules[8][this.moduleCount-i-1]=mod;}else if(i<9){this.modules[8][15-i-1+1]=mod;}else{this.modules[8][15-i-1]=mod;}}
this.modules[this.moduleCount-8][8]=(!test);},mapData:function(data,maskPattern){var inc=-1;var row=this.moduleCount-1;var bitIndex=7;var byteIndex=0;for(var col=this.moduleCount-1;col>0;col-=2){if(col==6)col--;while(true){for(var c=0;c<2;c++){if(this.modules[row][col-c]==null){var dark=false;if(byteIndex<data.length){dark=(((data[byteIndex]>>>bitIndex)&1)==1);}
var mask=QRUtil.getMask(maskPattern,row,col-c);if(mask){dark=!dark;}
this.modules[row][col-c]=dark;bitIndex--;if(bitIndex==-1){byteIndex++;bitIndex=7;}}}
row+=inc;if(row<0||this.moduleCount<=row){row-=inc;inc=-inc;break;}}}}};QRCodeModel.PAD0=0xEC;QRCodeModel.PAD1=0x11;QRCodeModel.createData=function(typeNumber,errorCorrectLevel,dataList){var rsBlocks=QRRSBlock.getRSBlocks(typeNumber,errorCorrectLevel);var buffer=new QRBitBuffer();for(var i=0;i<dataList.length;i++){var data=dataList[i];buffer.put(data.mode,4);buffer.put(data.getLength(),QRUtil.getLengthInBits(data.mode,typeNumber));data.write(buffer);}
var totalDataCount=0;for(var i=0;i<rsBlocks.length;i++){totalDataCount+=rsBlocks[i].dataCount;}
if(buffer.getLengthInBits()>totalDataCount*8){throw new Error("code length overflow. ("
+buffer.getLengthInBits()
+">"
+totalDataCount*8
+")");}
if(buffer.getLengthInBits()+4<=totalDataCount*8){buffer.put(0,4);}
while(buffer.getLengthInBits()%8!=0){buffer.putBit(false);}
while(true){if(buffer.getLengthInBits()>=totalDataCount*8){break;}
buffer.put(QRCodeModel.PAD0,8);if(buffer.getLengthInBits()>=totalDataCount*8){break;}
buffer.put(QRCodeModel.PAD1,8);}
return QRCodeModel.createBytes(buffer,rsBlocks);};QRCodeModel.createBytes=function(buffer,rsBlocks){var offset=0;var maxDcCount=0;var maxEcCount=0;var dcdata=new Array(rsBlocks.length);var ecdata=new Array(rsBlocks.length);for(var r=0;r<rsBlocks.length;r++){var dcCount=rsBlocks[r].dataCount;var ecCount=rsBlocks[r].totalCount-dcCount;maxDcCount=Math.max(maxDcCount,dcCount);maxEcCount=Math.max(maxEcCount,ecCount);dcdata[r]=new Array(dcCount);for(var i=0;i<dcdata[r].length;i++){dcdata[r][i]=0xff&buffer.buffer[i+offset];}
offset+=dcCount;var rsPoly=QRUtil.getErrorCorrectPolynomial(ecCount);var rawPoly=new QRPolynomial(dcdata[r],rsPoly.getLength()-1);var modPoly=rawPoly.mod(rsPoly);ecdata[r]=new Array(rsPoly.getLength()-1);for(var i=0;i<ecdata[r].length;i++){var modIndex=i+modPoly.getLength()-ecdata[r].length;ecdata[r][i]=(modIndex>=0)?modPoly.get(modIndex):0;}}
var totalCodeCount=0;for(var i=0;i<rsBlocks.length;i++){totalCodeCount+=rsBlocks[i].totalCount;}
var data=new Array(totalCodeCount);var index=0;for(var i=0;i<maxDcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<dcdata[r].length){data[index++]=dcdata[r][i];}}}
for(var i=0;i<maxEcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<ecdata[r].length){data[index++]=ecdata[r][i];}}}
return data;};var QRMode={MODE_NUMBER:1<<0,MODE_ALPHA_NUM:1<<1,MODE_8BIT_BYTE:1<<2,MODE_KANJI:1<<3};var QRErrorCorrectLevel={L:1,M:0,Q:3,H:2};var QRMaskPattern={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7};var QRUtil={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:(1<<10)|(1<<8)|(1<<5)|(1<<4)|(1<<2)|(1<<1)|(1<<0),G18:(1<<12)|(1<<11)|(1<<10)|(1<<9)|(1<<8)|(1<<5)|(1<<2)|(1<<0),G15_MASK:(1<<14)|(1<<12)|(1<<10)|(1<<4)|(1<<1),getBCHTypeInfo:function(data){var d=data<<10;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)>=0){d^=(QRUtil.G15<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)));}
return((data<<10)|d)^QRUtil.G15_MASK;},getBCHTypeNumber:function(data){var d=data<<12;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)>=0){d^=(QRUtil.G18<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)));}
return(data<<12)|d;},getBCHDigit:function(data){var digit=0;while(data!=0){digit++;data>>>=1;}
return digit;},getPatternPosition:function(typeNumber){return QRUtil.PATTERN_POSITION_TABLE[typeNumber-1];},getMask:function(maskPattern,i,j){switch(maskPattern){case QRMaskPattern.PATTERN000:return(i+j)%2==0;case QRMaskPattern.PATTERN001:return i%2==0;case QRMaskPattern.PATTERN010:return j%3==0;case QRMaskPattern.PATTERN011:return(i+j)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(i/2)+Math.floor(j/3))%2==0;case QRMaskPattern.PATTERN101:return(i*j)%2+(i*j)%3==0;case QRMaskPattern.PATTERN110:return((i*j)%2+(i*j)%3)%2==0;case QRMaskPattern.PATTERN111:return((i*j)%3+(i+j)%2)%2==0;default:throw new Error("bad maskPattern:"+maskPattern);}},getErrorCorrectPolynomial:function(errorCorrectLength){var a=new QRPolynomial([1],0);for(var i=0;i<errorCorrectLength;i++){a=a.multiply(new QRPolynomial([1,QRMath.gexp(i)],0));}
return a;},getLengthInBits:function(mode,type){if(1<=type&&type<10){switch(mode){case QRMode.MODE_NUMBER:return 10;case QRMode.MODE_ALPHA_NUM:return 9;case QRMode.MODE_8BIT_BYTE:return 8;case QRMode.MODE_KANJI:return 8;default:throw new Error("mode:"+mode);}}else if(type<27){switch(mode){case QRMode.MODE_NUMBER:return 12;case QRMode.MODE_ALPHA_NUM:return 11;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 10;default:throw new Error("mode:"+mode);}}else if(type<41){switch(mode){case QRMode.MODE_NUMBER:return 14;case QRMode.MODE_ALPHA_NUM:return 13;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 12;default:throw new Error("mode:"+mode);}}else{throw new Error("type:"+type);}},getLostPoint:function(qrCode){var moduleCount=qrCode.getModuleCount();var lostPoint=0;for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount;col++){var sameCount=0;var dark=qrCode.isDark(row,col);for(var r=-1;r<=1;r++){if(row+r<0||moduleCount<=row+r){continue;}
for(var c=-1;c<=1;c++){if(col+c<0||moduleCount<=col+c){continue;}
if(r==0&&c==0){continue;}
if(dark==qrCode.isDark(row+r,col+c)){sameCount++;}}}
if(sameCount>5){lostPoint+=(3+sameCount-5);}}}
for(var row=0;row<moduleCount-1;row++){for(var col=0;col<moduleCount-1;col++){var count=0;if(qrCode.isDark(row,col))count++;if(qrCode.isDark(row+1,col))count++;if(qrCode.isDark(row,col+1))count++;if(qrCode.isDark(row+1,col+1))count++;if(count==0||count==4){lostPoint+=3;}}}
for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount-6;col++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row,col+1)&&qrCode.isDark(row,col+2)&&qrCode.isDark(row,col+3)&&qrCode.isDark(row,col+4)&&!qrCode.isDark(row,col+5)&&qrCode.isDark(row,col+6)){lostPoint+=40;}}}
for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount-6;row++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row+1,col)&&qrCode.isDark(row+2,col)&&qrCode.isDark(row+3,col)&&qrCode.isDark(row+4,col)&&!qrCode.isDark(row+5,col)&&qrCode.isDark(row+6,col)){lostPoint+=40;}}}
var darkCount=0;for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount;row++){if(qrCode.isDark(row,col)){darkCount++;}}}
var ratio=Math.abs(100*darkCount/moduleCount/moduleCount-50)/5;lostPoint+=ratio*10;return lostPoint;}};var QRMath={glog:function(n){if(n<1){throw new Error("glog("+n+")");}
return QRMath.LOG_TABLE[n];},gexp:function(n){while(n<0){n+=255;}
while(n>=256){n-=255;}
return QRMath.EXP_TABLE[n];},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)};for(var i=0;i<8;i++){QRMath.EXP_TABLE[i]=1<<i;}
for(var i=8;i<256;i++){QRMath.EXP_TABLE[i]=QRMath.EXP_TABLE[i-4]^QRMath.EXP_TABLE[i-5]^QRMath.EXP_TABLE[i-6]^QRMath.EXP_TABLE[i-8];}
for(var i=0;i<255;i++){QRMath.LOG_TABLE[QRMath.EXP_TABLE[i]]=i;}
function QRPolynomial(num,shift){if(num.length==undefined){throw new Error(num.length+"/"+shift);}
var offset=0;while(offset<num.length&&num[offset]==0){offset++;}
this.num=new Array(num.length-offset+shift);for(var i=0;i<num.length-offset;i++){this.num[i]=num[i+offset];}}
QRPolynomial.prototype={get:function(index){return this.num[index];},getLength:function(){return this.num.length;},multiply:function(e){var num=new Array(this.getLength()+e.getLength()-1);for(var i=0;i<this.getLength();i++){for(var j=0;j<e.getLength();j++){num[i+j]^=QRMath.gexp(QRMath.glog(this.get(i))+QRMath.glog(e.get(j)));}}
return new QRPolynomial(num,0);},mod:function(e){if(this.getLength()-e.getLength()<0){return this;}
var ratio=QRMath.glog(this.get(0))-QRMath.glog(e.get(0));var num=new Array(this.getLength());for(var i=0;i<this.getLength();i++){num[i]=this.get(i);}
for(var i=0;i<e.getLength();i++){num[i]^=QRMath.gexp(QRMath.glog(e.get(i))+ratio);}
return new QRPolynomial(num,0).mod(e);}};function QRRSBlock(totalCount,dataCount){this.totalCount=totalCount;this.dataCount=dataCount;}
QRRSBlock.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]];QRRSBlock.getRSBlocks=function(typeNumber,errorCorrectLevel){var rsBlock=QRRSBlock.getRsBlockTable(typeNumber,errorCorrectLevel);if(rsBlock==undefined){throw new Error("bad rs block @ typeNumber:"+typeNumber+"/errorCorrectLevel:"+errorCorrectLevel);}
var length=rsBlock.length/3;var list=[];for(var i=0;i<length;i++){var count=rsBlock[i*3+0];var totalCount=rsBlock[i*3+1];var dataCount=rsBlock[i*3+2];for(var j=0;j<count;j++){list.push(new QRRSBlock(totalCount,dataCount));}}
return list;};QRRSBlock.getRsBlockTable=function(typeNumber,errorCorrectLevel){switch(errorCorrectLevel){case QRErrorCorrectLevel.L:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+0];case QRErrorCorrectLevel.M:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+1];case QRErrorCorrectLevel.Q:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+2];case QRErrorCorrectLevel.H:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+3];default:return undefined;}};function QRBitBuffer(){this.buffer=[];this.length=0;}
QRBitBuffer.prototype={get:function(index){var bufIndex=Math.floor(index/8);return((this.buffer[bufIndex]>>>(7-index%8))&1)==1;},put:function(num,length){for(var i=0;i<length;i++){this.putBit(((num>>>(length-i-1))&1)==1);}},getLengthInBits:function(){return this.length;},putBit:function(bit){var bufIndex=Math.floor(this.length/8);if(this.buffer.length<=bufIndex){this.buffer.push(0);}
if(bit){this.buffer[bufIndex]|=(0x80>>>(this.length%8));}
this.length++;}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]];
function _isSupportCanvas() {
return typeof CanvasRenderingContext2D != "undefined";
}
// android 2.x doesn't support Data-URI spec
function _getAndroid() {
var android = false;
var sAgent = navigator.userAgent;
if (/android/i.test(sAgent)) { // android
android = true;
var aMat = sAgent.toString().match(/android ([0-9]\.[0-9])/i);
if (aMat && aMat[1]) {
android = parseFloat(aMat[1]);
}
}
return android;
}
var svgDrawer = (function() {
var Drawing = function (el, htOption) {
this._el = el;
this._htOption = htOption;
};
Drawing.prototype.draw = function (oQRCode) {
var _htOption = this._htOption;
var _el = this._el;
var nCount = oQRCode.getModuleCount();
var nWidth = Math.floor(_htOption.width / nCount);
var nHeight = Math.floor(_htOption.height / nCount);
this.clear();
function makeSVG(tag, attrs) {
var el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (var k in attrs)
if (attrs.hasOwnProperty(k)) el.setAttribute(k, attrs[k]);
return el;
}
var svg = makeSVG("svg" , {'viewBox': '0 0 ' + String(nCount) + " " + String(nCount), 'width': '100%', 'height': '100%', 'fill': _htOption.colorLight});
svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink");
_el.appendChild(svg);
svg.appendChild(makeSVG("rect", {"fill": _htOption.colorLight, "width": "100%", "height": "100%"}));
svg.appendChild(makeSVG("rect", {"fill": _htOption.colorDark, "width": "1", "height": "1", "id": "template"}));
for (var row = 0; row < nCount; row++) {
for (var col = 0; col < nCount; col++) {
if (oQRCode.isDark(row, col)) {
var child = makeSVG("use", {"x": String(col), "y": String(row)});
child.setAttributeNS("http://www.w3.org/1999/xlink", "href", "#template")
svg.appendChild(child);
}
}
}
};
Drawing.prototype.clear = function () {
while (this._el.hasChildNodes())
this._el.removeChild(this._el.lastChild);
};
return Drawing;
})();
var useSVG = document.documentElement.tagName.toLowerCase() === "svg";
// Drawing in DOM by using Table tag
var Drawing = useSVG ? svgDrawer : !_isSupportCanvas() ? (function () {
var Drawing = function (el, htOption) {
this._el = el;
this._htOption = htOption;
};
/**
* Draw the QRCode
*
* @param {QRCode} oQRCode
*/
Drawing.prototype.draw = function (oQRCode) {
var _htOption = this._htOption;
var _el = this._el;
var nCount = oQRCode.getModuleCount();
var nWidth = Math.floor(_htOption.width / nCount);
var nHeight = Math.floor(_htOption.height / nCount);
var aHTML = ['<table style="border:0;border-collapse:collapse;">'];
for (var row = 0; row < nCount; row++) {
aHTML.push('<tr>');
for (var col = 0; col < nCount; col++) {
aHTML.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:' + nWidth + 'px;height:' + nHeight + 'px;background-color:' + (oQRCode.isDark(row, col) ? _htOption.colorDark : _htOption.colorLight) + ';"></td>');
}
aHTML.push('</tr>');
}
aHTML.push('</table>');
_el.innerHTML = aHTML.join('');
// Fix the margin values as real size.
var elTable = _el.childNodes[0];
var nLeftMarginTable = (_htOption.width - elTable.offsetWidth) / 2;
var nTopMarginTable = (_htOption.height - elTable.offsetHeight) / 2;
if (nLeftMarginTable > 0 && nTopMarginTable > 0) {
elTable.style.margin = nTopMarginTable + "px " + nLeftMarginTable + "px";
}
};
/**
* Clear the QRCode
*/
Drawing.prototype.clear = function () {
this._el.innerHTML = '';
};
return Drawing;
})() : (function () { // Drawing in Canvas
function _onMakeImage() {
this._elImage.src = this._elCanvas.toDataURL("image/png");
this._elImage.style.display = "block";
this._elCanvas.style.display = "none";
}
// Android 2.1 bug workaround
// http://code.google.com/p/android/issues/detail?id=5141
if (this._android && this._android <= 2.1) {
var factor = 1 / window.devicePixelRatio;
var drawImage = CanvasRenderingContext2D.prototype.drawImage;
CanvasRenderingContext2D.prototype.drawImage = function (image, sx, sy, sw, sh, dx, dy, dw, dh) {
if (("nodeName" in image) && /img/i.test(image.nodeName)) {
for (var i = arguments.length - 1; i >= 1; i--) {
arguments[i] = arguments[i] * factor;
}
} else if (typeof dw == "undefined") {
arguments[1] *= factor;
arguments[2] *= factor;
arguments[3] *= factor;
arguments[4] *= factor;
}
drawImage.apply(this, arguments);
};
}
/**
* Check whether the user's browser supports Data URI or not
*
* @private
* @param {Function} fSuccess Occurs if it supports Data URI
* @param {Function} fFail Occurs if it doesn't support Data URI
*/
function _safeSetDataURI(fSuccess, fFail) {
var self = this;
self._fFail = fFail;
self._fSuccess = fSuccess;
// Check it just once
if (self._bSupportDataURI === null) {
var el = document.createElement("img");
var fOnError = function() {
self._bSupportDataURI = false;
if (self._fFail) {
self._fFail.call(self);
}
};
var fOnSuccess = function() {
self._bSupportDataURI = true;
if (self._fSuccess) {
self._fSuccess.call(self);
}
};
el.onabort = fOnError;
el.onerror = fOnError;
el.onload = fOnSuccess;
el.src = "data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; // the Image contains 1px data.
return;
} else if (self._bSupportDataURI === true && self._fSuccess) {
self._fSuccess.call(self);
} else if (self._bSupportDataURI === false && self._fFail) {
self._fFail.call(self);
}
};
/**
* Drawing QRCode by using canvas
*
* @constructor
* @param {HTMLElement} el
* @param {Object} htOption QRCode Options
*/
var Drawing = function (el, htOption) {
this._bIsPainted = false;
this._android = _getAndroid();
this._htOption = htOption;
this._elCanvas = document.createElement("canvas");
this._elCanvas.width = htOption.width;
this._elCanvas.height = htOption.height;
el.appendChild(this._elCanvas);
this._el = el;
this._oContext = this._elCanvas.getContext("2d");
this._bIsPainted = false;
this._elImage = document.createElement("img");
this._elImage.alt = "Scan me!";
this._elImage.style.display = "none";
this._el.appendChild(this._elImage);
this._bSupportDataURI = null;
};
/**
* Draw the QRCode
*
* @param {QRCode} oQRCode
*/
Drawing.prototype.draw = function (oQRCode) {
var _elImage = this._elImage;
var _oContext = this._oContext;
var _htOption = this._htOption;
var nCount = oQRCode.getModuleCount();
var nWidth = _htOption.width / nCount;
var nHeight = _htOption.height / nCount;
var nRoundedWidth = Math.round(nWidth);
var nRoundedHeight = Math.round(nHeight);
_elImage.style.display = "none";
this.clear();
for (var row = 0; row < nCount; row++) {
for (var col = 0; col < nCount; col++) {
var bIsDark = oQRCode.isDark(row, col);
var nLeft = col * nWidth;
var nTop = row * nHeight;
_oContext.strokeStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight;
_oContext.lineWidth = 1;
_oContext.fillStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight;
_oContext.fillRect(nLeft, nTop, nWidth, nHeight);
// 안티 앨리어싱 방지 처리
_oContext.strokeRect(
Math.floor(nLeft) + 0.5,
Math.floor(nTop) + 0.5,
nRoundedWidth,
nRoundedHeight
);
_oContext.strokeRect(
Math.ceil(nLeft) - 0.5,
Math.ceil(nTop) - 0.5,
nRoundedWidth,
nRoundedHeight
);
}
}
this._bIsPainted = true;
};
/**
* Make the image from Canvas if the browser supports Data URI.
*/
Drawing.prototype.makeImage = function () {
if (this._bIsPainted) {
_safeSetDataURI.call(this, _onMakeImage);
}
};
/**
* Return whether the QRCode is painted or not
*
* @return {Boolean}
*/
Drawing.prototype.isPainted = function () {
return this._bIsPainted;
};
/**
* Clear the QRCode
*/
Drawing.prototype.clear = function () {
this._oContext.clearRect(0, 0, this._elCanvas.width, this._elCanvas.height);
this._bIsPainted = false;
};
/**
* @private
* @param {Number} nNumber
*/
Drawing.prototype.round = function (nNumber) {
if (!nNumber) {
return nNumber;
}
return Math.floor(nNumber * 1000) / 1000;
};
return Drawing;
})();
/**
* Get the type by string length
*
* @private
* @param {String} sText
* @param {Number} nCorrectLevel
* @return {Number} type
*/
function _getTypeNumber(sText, nCorrectLevel) {
var nType = 1;
var length = _getUTF8Length(sText);
for (var i = 0, len = QRCodeLimitLength.length; i <= len; i++) {
var nLimit = 0;
switch (nCorrectLevel) {
case QRErrorCorrectLevel.L :
nLimit = QRCodeLimitLength[i][0];
break;
case QRErrorCorrectLevel.M :
nLimit = QRCodeLimitLength[i][1];
break;
case QRErrorCorrectLevel.Q :
nLimit = QRCodeLimitLength[i][2];
break;
case QRErrorCorrectLevel.H :
nLimit = QRCodeLimitLength[i][3];
break;
}
if (length <= nLimit) {
break;
} else {
nType++;
}
}
if (nType > QRCodeLimitLength.length) {
throw new Error("Too long data");
}
return nType;
}
function _getUTF8Length(sText) {
var replacedText = encodeURI(sText).toString().replace(/\%[0-9a-fA-F]{2}/g, 'a');
return replacedText.length + (replacedText.length != sText ? 3 : 0);
}
/**
* @class QRCode
* @constructor
* @example
* new QRCode(document.getElementById("test"), "http://jindo.dev.naver.com/collie");
*
* @example
* var oQRCode = new QRCode("test", {
* text : "http://naver.com",
* width : 128,
* height : 128
* });
*
* oQRCode.clear(); // Clear the QRCode.
* oQRCode.makeCode("http://map.naver.com"); // Re-create the QRCode.
*
* @param {HTMLElement|String} el target element or 'id' attribute of element.
* @param {Object|String} vOption
* @param {String} vOption.text QRCode link data
* @param {Number} [vOption.width=256]
* @param {Number} [vOption.height=256]
* @param {String} [vOption.colorDark="#000000"]
* @param {String} [vOption.colorLight="#ffffff"]
* @param {QRCode.CorrectLevel} [vOption.correctLevel=QRCode.CorrectLevel.H] [L|M|Q|H]
*/
QRCode = function (el, vOption) {
this._htOption = {
width : 256,
height : 256,
typeNumber : 4,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRErrorCorrectLevel.H
};
if (typeof vOption === 'string') {
vOption = {
text : vOption
};
}
// Overwrites options
if (vOption) {
for (var i in vOption) {
this._htOption[i] = vOption[i];
}
}
if (typeof el == "string") {
el = document.getElementById(el);
}
if (this._htOption.useSVG) {
Drawing = svgDrawer;
}
this._android = _getAndroid();
this._el = el;
this._oQRCode = null;
this._oDrawing = new Drawing(this._el, this._htOption);
if (this._htOption.text) {
this.makeCode(this._htOption.text);
}
};
/**
* Make the QRCode
*
* @param {String} sText link data
*/
QRCode.prototype.makeCode = function (sText) {
this._oQRCode = new QRCodeModel(_getTypeNumber(sText, this._htOption.correctLevel), this._htOption.correctLevel);
this._oQRCode.addData(sText);
this._oQRCode.make();
this._el.title = sText;
this._oDrawing.draw(this._oQRCode);
this.makeImage();
};
/**
* Make the Image from Canvas element
* - It occurs automatically
* - Android below 3 doesn't support Data-URI spec.
*
* @private
*/
QRCode.prototype.makeImage = function () {
if (typeof this._oDrawing.makeImage == "function" && (!this._android || this._android >= 3)) {
this._oDrawing.makeImage();
}
};
/**
* Clear the QRCode
*/
QRCode.prototype.clear = function () {
this._oDrawing.clear();
};
/**
* @name QRCode.CorrectLevel
*/
QRCode.CorrectLevel = QRErrorCorrectLevel;
})();

File diff suppressed because one or more lines are too long

View File

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Register - Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
</head>
<body>
<div class="container">
<!-- Register View -->
<div id="registerView" class="view active">
<h1>🔐 Create Account</h1>
<div id="registerStatus"></div>
<form id="registrationForm">
<input type="text" name="username" placeholder="Enter username" required>
<button type="submit" class="btn-primary">Register Passkey</button>
</form>
<p class="toggle-link" onclick="window.location.href='/auth/login'">
Already have an account? Login here
</p>
</div>
</div>
<script src="/static/app.js"></script>
<script src="/static/util.js"></script>
<script src="/static/register.js"></script>
</body>
</html>

View File

@ -1,35 +0,0 @@
// Register page specific functionality
document.addEventListener('DOMContentLoaded', function() {
// Initialize the app
initializeApp()
// Registration form handler
const regForm = document.getElementById('registrationForm')
if (regForm) {
const regSubmitBtn = regForm.querySelector('button[type="submit"]')
regForm.addEventListener('submit', async (ev) => {
ev.preventDefault()
regSubmitBtn.disabled = true
clearStatus('registerStatus')
const user_name = (new FormData(regForm)).get('username')
try {
showStatus('registerStatus', 'Starting registration...', 'info')
await register(user_name)
showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success')
// Auto-login after successful registration
setTimeout(() => {
window.location.href = '/auth/profile'
}, 1500)
} catch (err) {
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error')
} finally {
regSubmitBtn.disabled = false
}
})
}
})

View File

@ -1,185 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Add Device - Passkey Authentication</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/simplewebauthn-browser.min.js"></script>
<script src="/static/qrcodejs/qrcode.min.js"></script>
<script src="/static/awaitable-websocket.js"></script>
</head>
<body>
<div class="container">
<!-- Request Reset View -->
<div id="requestView" class="view">
<h1>🔓 Add Device</h1>
<p>This page is for adding a new device to an existing account. You need a device addition link to proceed.</p>
<div id="requestStatus" class="status info" style="display: block;">
<strong>How to get a device addition link:</strong><br>
1. Log into your account on a device you already have<br>
2. Click "Generate Device Link" in your dashboard<br>
3. Copy the link or scan the QR code to add this device
</div>
<p class="toggle-link" onclick="window.location.href='/'">
Back to Login
</p>
</div>
<!-- Add Passkey View -->
<div id="addPasskeyView" class="view">
<h1>🔑 Add New Passkey</h1>
<div id="userInfo" class="token-info">
<p><strong>Account:</strong> <span id="userName"></span></p>
<p><small>You are about to add a new passkey to this account.</small></p>
</div>
<div id="addPasskeyStatus" class="status"></div>
<button id="addPasskeyBtn" class="btn-primary">Add New Passkey</button>
<p class="toggle-link" onclick="window.location.href='/'">
Back to Login
</p>
</div>
<!-- Success Complete View -->
<div id="completeView" class="view">
<h1>🎉 Passkey Added Successfully!</h1>
<p>Your new passkey has been added to your account. You can now use it to log in.</p>
<button onclick="window.location.href='/'" class="btn-primary">Go to Login</button>
</div>
</div>
<script>
const { startRegistration } = SimpleWebAuthnBrowser;
// Global state
let currentToken = null;
let currentUser = null;
// View management
function showView(viewId) {
document.querySelectorAll('.view').forEach(view => {
view.classList.remove('active');
});
document.getElementById(viewId).classList.add('active');
}
function showAddPasskeyView() {
showView('addPasskeyView');
clearStatus('addPasskeyStatus');
}
function showCompleteView() {
showView('completeView');
}
// Status management
function showStatus(elementId, message, type = 'info') {
const statusEl = document.getElementById(elementId);
statusEl.textContent = message;
statusEl.className = `status ${type}`;
statusEl.style.display = 'block';
}
function clearStatus(elementId) {
const statusEl = document.getElementById(elementId);
statusEl.style.display = 'none';
}
// Validate reset token and show add passkey view
async function validateTokenAndShowAddView(token) {
try {
const response = await fetch('/api/validate-device-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token })
});
const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
currentToken = token;
currentUser = result;
document.getElementById('userName').textContent = result.user_name;
showAddPasskeyView();
} catch (error) {
showStatus('addPasskeyStatus', `Error: ${error.message}`, 'error');
}
}
// Add new passkey via reset token
async function addPasskeyWithToken(token) {
try {
const ws = await aWebSocket('/ws/add_device_credential');
// Send token to server
ws.send(JSON.stringify({ token }));
// Get registration options
const optionsJSON = JSON.parse(await ws.recv());
if (optionsJSON.error) throw new Error(optionsJSON.error);
showStatus('addPasskeyStatus', 'Save new passkey to your authenticator...', 'info');
const registrationResponse = await startRegistration({ optionsJSON });
ws.send(JSON.stringify(registrationResponse));
const result = JSON.parse(await ws.recv());
if (result.error) throw new Error(`Server: ${result.error}`);
ws.close();
showCompleteView();
} catch (error) {
showStatus('addPasskeyStatus', `Failed to add passkey: ${error.message}`, 'error');
}
}
// Check URL path for token on page load
function checkUrlParams() {
const path = window.location.pathname;
const pathParts = path.split('/');
// Check if URL is in format /reset/token
if (pathParts.length >= 3 && pathParts[1] === 'reset') {
const token = pathParts[2];
if (token) {
validateTokenAndShowAddView(token);
}
}
}
// Form event handlers
document.addEventListener('DOMContentLoaded', function() {
// Check for token in URL
checkUrlParams();
// Add passkey button
const addPasskeyBtn = document.getElementById('addPasskeyBtn');
addPasskeyBtn.addEventListener('click', async () => {
if (!currentToken) {
showStatus('addPasskeyStatus', 'No valid device addition token found', 'error');
return;
}
addPasskeyBtn.disabled = true;
clearStatus('addPasskeyStatus');
try {
showStatus('addPasskeyStatus', 'Starting passkey registration...', 'info');
await addPasskeyWithToken(currentToken);
} catch (err) {
showStatus('addPasskeyStatus', `Registration failed: ${err.message}`, 'error');
} finally {
addPasskeyBtn.disabled = false;
}
});
});
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,104 +0,0 @@
// Shared utility functions for all views
// Initialize the app based on current page
function initializeApp() {
checkExistingSession()
}
// Show status message
function showStatus(elementId, message, type = 'info') {
const statusEl = document.getElementById(elementId)
if (statusEl) {
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
}
}
// Clear status message
function clearStatus(elementId) {
const statusEl = document.getElementById(elementId)
if (statusEl) {
statusEl.innerHTML = ''
}
}
// Check if user is already logged in on page load
async function checkExistingSession() {
const isLoggedIn = await validateStoredToken()
const path = window.location.pathname
// Protected routes that require authentication
const protectedRoutes = ['/auth/profile']
if (isLoggedIn) {
// User is logged in
if (path === '/auth/login' || path === '/auth/register' || path === '/') {
// Redirect to profile if accessing login/register pages while logged in
window.location.href = '/auth/profile'
} else if (path === '/auth/add-device') {
// Redirect old add-device route to profile
window.location.href = '/auth/profile'
} else if (protectedRoutes.includes(path)) {
// Stay on current protected page and load user data
if (path === '/auth/profile') {
try {
await loadUserInfo()
updateUserInfo()
await loadCredentials()
} catch (error) {
showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
}
}
}
} else {
// User is not logged in
if (protectedRoutes.includes(path) || path === '/auth/add-device') {
// Redirect to login if accessing protected pages without authentication
window.location.href = '/auth/login'
}
}
}
// Validate stored token
async function validateStoredToken() {
try {
const response = await fetch('/api/validate-token', {
method: 'GET',
credentials: 'include'
})
const result = await response.json()
return result.status === 'success'
} catch (error) {
return false
}
}
// Copy device link to clipboard
async function copyDeviceLink() {
try {
if (window.currentDeviceLink) {
await navigator.clipboard.writeText(window.currentDeviceLink)
const copyButton = document.querySelector('.copy-button')
if (copyButton) {
const originalText = copyButton.textContent
copyButton.textContent = 'Copied!'
copyButton.style.background = '#28a745'
setTimeout(() => {
copyButton.textContent = originalText
copyButton.style.background = '#28a745'
}, 2000)
}
}
} catch (error) {
console.error('Failed to copy link:', error)
const linkText = document.getElementById('deviceLinkText')
if (linkText) {
const range = document.createRange()
range.selectNode(linkText)
window.getSelection().removeAllRanges()
window.getSelection().addRange(range)
}
}
}