Compare commits

...

3 Commits

14 changed files with 1124 additions and 485 deletions

364
API.md Normal file
View File

@ -0,0 +1,364 @@
# PassKey Auth API Documentation
This document describes all API endpoints available in the PassKey Auth FastAPI application.
## Base URL
- **Development**: `http://localhost:4401`
- All API endpoints are prefixed with `/auth`
## Authentication
The API uses JWT tokens stored in HTTP-only cookies for session management. Some endpoints require authentication via session cookies.
---
## HTTP API Endpoints
### User Management
#### `POST /auth/user-info`
Get detailed information about the current authenticated user and their credentials.
**Authentication**: Required (session cookie)
**Response**:
```json
{
"status": "success",
"user": {
"user_id": "string (UUID)",
"user_name": "string",
"created_at": "string (ISO 8601)",
"last_seen": "string (ISO 8601)",
"visits": "number"
},
"credentials": [
{
"credential_id": "string (hex)",
"aaguid": "string (UUID)",
"created_at": "string (ISO 8601)",
"last_used": "string (ISO 8601) | null",
"last_verified": "string (ISO 8601) | null",
"sign_count": "number",
"is_current_session": "boolean"
}
],
"aaguid_info": "object (AAGUID information)"
}
```
**Error Response**:
```json
{
"error": "Not authenticated" | "Failed to get user info: <error_message>"
}
```
---
### Session Management
#### `POST /auth/logout`
Log out the current user by clearing the session cookie.
**Authentication**: Not required
**Response**:
```json
{
"status": "success",
"message": "Logged out successfully"
}
```
#### `POST /auth/set-session`
Set session cookie using JWT token from request body or Authorization header.
**Authentication**: Not required
**Request Body** (alternative to Authorization header):
```json
{
"token": "string (JWT token)"
}
```
**Headers** (alternative to request body):
```
Authorization: Bearer <JWT_token>
```
**Response**:
```json
{
"status": "success",
"message": "Session cookie set successfully",
"user_id": "string (UUID)"
}
```
**Error Response**:
```json
{
"error": "No session token provided" | "Invalid or expired session token" | "Failed to set session: <error_message>"
}
```
#### `GET /auth/forward-auth`
Verification endpoint for use with Caddy forward_auth or Nginx auth_request.
**Authentication**: Required (session cookie)
**Success Response**:
- Status: `204 No Content`
- Headers: `x-auth-user-id: <user_id>`
**Error Response**:
- Status: `401 Unauthorized`
- Returns authentication app HTML page
- Headers: `www-authenticate: PrivateToken`
---
### Credential Management
#### `POST /auth/delete-credential`
Delete a specific passkey credential for the current user.
**Authentication**: Required (session cookie)
**Request Body**:
```json
{
"credential_id": "string (hex-encoded credential ID)"
}
```
**Response**:
```json
{
"status": "success",
"message": "Credential deleted successfully"
}
```
**Error Response**:
```json
{
"error": "Not authenticated" | "credential_id is required" | "Invalid credential_id format" | "Credential not found or access denied" | "Cannot delete current session credential" | "Cannot delete last remaining credential" | "Failed to delete credential: <error_message>"
}
```
---
### Device Addition
#### `POST /auth/create-device-link`
Generate a device addition link for authenticated users to add new passkeys to their account.
**Authentication**: Required (session cookie)
**Response**:
```json
{
"status": "success",
"message": "Device addition link generated successfully",
"addition_link": "string (URL)",
"expires_in_hours": 24
}
```
**Error Response**:
```json
{
"error": "Authentication required" | "Failed to create device addition link: <error_message>"
}
```
#### `POST /auth/validate-device-token`
Validate a device addition token and return associated user information.
**Authentication**: Not required
**Request Body**:
```json
{
"token": "string (device addition token)"
}
```
**Response**:
```json
{
"status": "success",
"valid": true,
"user_id": "string (UUID)",
"user_name": "string",
"token": "string (device addition token)"
}
```
**Error Response**:
```json
{
"error": "Device addition token is required" | "Invalid or expired device addition token" | "Device addition token has expired" | "Failed to validate device addition token: <error_message>"
}
```
---
### Static File Serving
#### `GET /auth/{passphrase}`
Handle passphrase-based authentication redirect with cookie setting.
**Parameters**:
- `passphrase`: String matching pattern `^\w+(\.\w+){2,}$` (e.g., "word1.word2.word3")
**Response**:
- Status: `303 See Other`
- Redirect to: `/`
- Sets temporary cookie: `auth-token` (expires in 2 seconds)
#### `GET /auth`
Serve the main authentication app.
**Response**: Returns the main `index.html` file for the authentication SPA.
#### `GET /auth/assets/{path}`
Serve static assets (CSS, JS, images) for the authentication app.
#### `GET /{path:path}`
Catch-all route for SPA routing. Serves `index.html` for all non-API routes when requesting HTML content.
**Response**:
- For HTML requests: Returns `index.html`
- For non-HTML requests: Returns `404 Not Found` JSON response
---
## WebSocket API Endpoints
All WebSocket endpoints are mounted under `/auth/ws/`.
### Registration
#### `WS /auth/ws/register_new`
Register a new user with a new passkey credential.
**Flow**:
1. Client connects to WebSocket
2. Server sends registration options
3. Client performs WebAuthn ceremony and sends response
4. Server validates and creates new user + credential
5. Server sends JWT token for session establishment
**Server Messages**:
```json
// Registration options
{
"rp": { "id": "localhost", "name": "Passkey Auth" },
"user": { "id": "base64", "name": "string", "displayName": "string" },
"challenge": "base64",
"pubKeyCredParams": [...],
"timeout": 60000,
"attestation": "none",
"authenticatorSelection": {...}
}
// Success response
{
"status": "success",
"message": "User registered successfully",
"token": "string (JWT)"
}
```
#### `WS /auth/ws/add_credential`
Add a new passkey credential to an existing authenticated user.
**Authentication**: Required (session cookie)
**Flow**:
1. Client connects with valid session
2. Server sends registration options for existing user
3. Client performs WebAuthn ceremony and sends response
4. Server validates and adds new credential
5. Server sends success confirmation
#### `WS /auth/ws/add_device_credential`
Add a new passkey credential using a device addition token.
**Flow**:
1. Client connects and sends device addition token
2. Server validates token and sends registration options
3. Client performs WebAuthn ceremony and sends response
4. Server validates, adds credential, and cleans up token
5. Server sends JWT token for session establishment
**Initial Client Message**:
```json
{
"token": "string (device addition token)"
}
```
### Authentication
#### `WS /auth/ws/authenticate`
Authenticate using existing passkey credentials.
**Flow**:
1. Client connects to WebSocket
2. Server sends authentication options
3. Client performs WebAuthn ceremony and sends response
4. Server validates credential and updates usage stats
5. Server sends JWT token for session establishment
**Server Messages**:
```json
// Authentication options
{
"challenge": "base64",
"timeout": 60000,
"rpId": "localhost",
"allowCredentials": [...] // Optional, for non-discoverable credentials
}
// Success response
{
"status": "success",
"message": "Authentication successful",
"token": "string (JWT)"
}
// Error response
{
"status": "error",
"message": "error description"
}
```
---
## Error Handling
All endpoints return consistent error responses:
```json
{
"error": "string (error description)"
}
```
## Security Features
- **HTTP-only Cookies**: Session tokens are stored in secure, HTTP-only cookies
- **CSRF Protection**: SameSite cookie attributes prevent CSRF attacks
- **Token Validation**: All JWT tokens are validated and automatically refreshed
- **Credential Isolation**: Users can only access and modify their own credentials
- **Time-based Expiration**: Device addition tokens expire after 24 hours
- **Rate Limiting**: WebSocket connections are limited and validated
## CORS and Headers
The application includes appropriate CORS headers and security headers for production use with reverse proxies like Caddy or Nginx.

View File

@ -5,39 +5,36 @@
<RegisterView v-if="store.currentView === 'register'" /> <RegisterView v-if="store.currentView === 'register'" />
<ProfileView v-if="store.currentView === 'profile'" /> <ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" /> <DeviceLinkView v-if="store.currentView === 'device-link'" />
<AddDeviceCredentialView v-if="store.currentView === 'add-device-credential'" /> <AddCredentialView v-if="store.currentView === 'add-credential'" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import LoginView from '@/components/LoginView.vue' import LoginView from '@/components/LoginView.vue'
import RegisterView from '@/components/RegisterView.vue' import RegisterView from '@/components/RegisterView.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
import DeviceLinkView from '@/components/DeviceLinkView.vue' import DeviceLinkView from '@/components/DeviceLinkView.vue'
import AddDeviceCredentialView from '@/components/AddDeviceCredentialView.vue' import AddCredentialView from '@/components/AddCredentialView.vue'
import { getCookie } from './utils/helpers'
const store = useAuthStore() const store = useAuthStore()
let isLoggedIn
onMounted(async () => { onMounted(async () => {
if (getCookie('auth-token')) { // Check for device addition session first
store.currentView = 'add-device-credential'
return
}
isLoggedIn = await store.validateStoredToken()
if (isLoggedIn) {
// User is logged in, load their data and go to profile
try { try {
await store.loadUserInfo() await store.loadUserInfo()
store.currentView = 'profile'
} catch (error) { } catch (error) {
console.error('Failed to load user info:', error) console.log('Failed to load user info:', error)
store.currentView = 'login' store.currentView = 'login'
} }
if (store.currentCredentials.length) {
// User is logged in, go to profile
store.currentView = 'profile'
} else if (store.currentUser) {
// User is logged in via reset link, allow adding a credential
store.currentView = 'add-credential'
} else { } else {
// User is not logged in, show login // User is not logged in, show login
store.currentView = 'login' store.currentView = 'login'

View File

@ -15,30 +15,42 @@
<script setup> <script setup>
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { registerWithToken } from '@/utils/passkey' import { registerWithSession } from '@/utils/passkey'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { getCookie } from '@/utils/helpers'
const authStore = useAuthStore() const authStore = useAuthStore()
const token = ref(null) const hasDeviceSession = ref(false)
// Check existing session on app load // Check existing session on app load
onMounted(() => { onMounted(async () => {
// Check for 'auth-token' cookie try {
token.value = getCookie('auth-token') // Check if we have a device addition session
if (!token.value) { const response = await fetch('/auth/device-session-check', {
authStore.showMessage('No registration token cookie found.', 'error') credentials: 'include'
})
const data = await response.json()
if (data.device_addition_session) {
hasDeviceSession.value = true
} else {
authStore.showMessage('No device addition session found.', 'error')
authStore.currentView = 'login'
}
} catch (error) {
authStore.showMessage('Failed to check device addition session.', 'error')
authStore.currentView = 'login' authStore.currentView = 'login'
return
} }
// Delete the cookie
document.cookie = 'auth-token=; Max-Age=0; path=/'
}) })
function register() { function register() {
if (!hasDeviceSession.value) {
authStore.showMessage('No valid device addition session', 'error')
return
}
authStore.isLoading = true authStore.isLoading = true
authStore.showMessage('Starting registration...', 'info') authStore.showMessage('Starting registration...', 'info')
registerWithToken(token.value).finally(() => { registerWithSession().finally(() => {
authStore.isLoading = false authStore.isLoading = false
}).then(() => { }).then(() => {
authStore.showMessage('Passkey registered successfully!', 'success', 2000) authStore.showMessage('Passkey registered successfully!', 'success', 2000)

View File

@ -17,12 +17,12 @@
<div v-if="authStore.isLoading"> <div v-if="authStore.isLoading">
<p>Loading credentials...</p> <p>Loading credentials...</p>
</div> </div>
<div v-else-if="userCredentialsData.credentials.length === 0"> <div v-else-if="authStore.currentCredentials.length === 0">
<p>No passkeys found.</p> <p>No passkeys found.</p>
</div> </div>
<div v-else> <div v-else>
<div <div
v-for="credential in userCredentialsData.credentials" v-for="credential in authStore.currentCredentials"
:key="credential.credential_id" :key="credential.credential_id"
:class="['credential-item', { 'current-session': credential.is_current_session }]" :class="['credential-item', { 'current-session': credential.is_current_session }]"
> >
@ -84,36 +84,21 @@ import { formatDate } from '@/utils/helpers'
import { registerCredential } from '@/utils/passkey' import { registerCredential } from '@/utils/passkey'
const authStore = useAuthStore() const authStore = useAuthStore()
const currentCredentials = ref([])
const userCredentialsData = ref({ credentials: [], aaguid_info: {} })
const updateInterval = ref(null) const updateInterval = ref(null)
onMounted(async () => { onMounted(async () => {
try { try {
await authStore.loadUserInfo() await authStore.loadUserInfo()
currentCredentials.value = await authStore.loadCredentials()
} catch (error) { } catch (error) {
authStore.showMessage(`Failed to load user info: ${error.message}`, 'error') authStore.showMessage(`Failed to load user info: ${error.message}`, 'error')
authStore.currentView = 'login' authStore.currentView = 'login'
return 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(() => { updateInterval.value = setInterval(() => {
// Trigger Vue reactivity to update formatDate fields // Trigger Vue reactivity to update formatDate fields
authStore.currentUser = { ...authStore.currentUser } authStore.currentUser = { ...authStore.currentUser }
userCredentialsData.value.credentials = [...userCredentialsData.value.credentials] authStore.currentCredentials = [...authStore.currentCredentials]
}, 60000) // Update every minute }, 60000) // Update every minute
}) })
@ -124,12 +109,12 @@ onUnmounted(() => {
}) })
const getCredentialAuthName = (credential) => { const getCredentialAuthName = (credential) => {
const authInfo = userCredentialsData.value.aaguid_info[credential.aaguid] const authInfo = authStore.aaguidInfo[credential.aaguid]
return authInfo ? authInfo.name : 'Unknown Authenticator' return authInfo ? authInfo.name : 'Unknown Authenticator'
} }
const getCredentialAuthIcon = (credential) => { const getCredentialAuthIcon = (credential) => {
const authInfo = userCredentialsData.value.aaguid_info[credential.aaguid] const authInfo = authStore.aaguidInfo[credential.aaguid]
if (!authInfo) return null if (!authInfo) return null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
@ -142,7 +127,7 @@ const addNewCredential = async () => {
authStore.isLoading = true authStore.isLoading = true
authStore.showMessage('Adding new passkey...', 'info') authStore.showMessage('Adding new passkey...', 'info')
const result = await registerCredential() const result = await registerCredential()
currentCredentials.value = await authStore.loadCredentials() await authStore.loadUserInfo()
authStore.showMessage('New passkey added successfully!', 'success', 3000) authStore.showMessage('New passkey added successfully!', 'success', 3000)
} catch (error) { } catch (error) {
console.error('Failed to add new passkey:', error) console.error('Failed to add new passkey:', error)
@ -157,7 +142,6 @@ const deleteCredential = async (credentialId) => {
try { try {
await authStore.deleteCredential(credentialId) await authStore.deleteCredential(credentialId)
currentCredentials.value = await authStore.loadCredentials()
authStore.showMessage('Passkey deleted successfully!', 'success', 3000) authStore.showMessage('Passkey deleted successfully!', 'success', 3000)
} catch (error) { } catch (error) {
authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error')

View File

@ -5,6 +5,8 @@ export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
// Auth State // Auth State
currentUser: null, currentUser: null,
currentCredentials: [],
aaguidInfo: {},
isLoading: false, isLoading: false,
// UI State // UI State
@ -28,15 +30,6 @@ export const useAuthStore = defineStore('auth', {
}, duration) }, 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) { async setSessionCookie(sessionToken) {
const response = await fetch('/auth/set-session', { const response = await fetch('/auth/set-session', {
method: 'POST', method: 'POST',
@ -82,24 +75,13 @@ export const useAuthStore = defineStore('auth', {
} }
}, },
async loadUserInfo() { async loadUserInfo() {
const response = await fetch('/auth/user-info') const response = await fetch('/auth/user-info', {method: 'POST'})
const result = await response.json() const result = await response.json()
if (result.error) throw new Error(`Server: ${result.error}`) if (result.error) throw new Error(`Server: ${result.error}`)
this.currentUser = result.user this.currentUser = result.user
}, this.currentCredentials = result.credentials || []
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 || {} this.aaguidInfo = result.aaguid_info || {}
} finally {
this.isLoading = false
}
}, },
async deleteCredential(credentialId) { async deleteCredential(credentialId) {
const response = await fetch('/auth/delete-credential', { const response = await fetch('/auth/delete-credential', {
@ -112,7 +94,7 @@ export const useAuthStore = defineStore('auth', {
const result = await response.json() const result = await response.json()
if (result.error) throw new Error(`Server: ${result.error}`) if (result.error) throw new Error(`Server: ${result.error}`)
await this.loadCredentials() await this.loadUserInfo()
}, },
async logout() { async logout() {
try { try {

View File

@ -24,6 +24,9 @@ export async function registerCredential() {
export async function registerWithToken(token) { export async function registerWithToken(token) {
return register('/auth/ws/add_device_credential', { token }) return register('/auth/ws/add_device_credential', { token })
} }
export async function registerWithSession() {
return register('/auth/ws/add_device_credential_session')
}
export async function authenticateUser() { export async function authenticateUser() {
const ws = await aWebSocket('/auth/ws/authenticate') const ws = await aWebSocket('/auth/ws/authenticate')

View File

@ -21,7 +21,7 @@ from sqlalchemy import (
select, select,
update, update,
) )
from sqlalchemy.dialects.sqlite import BLOB from sqlalchemy.dialects.sqlite import BLOB, JSON
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
@ -52,8 +52,8 @@ class UserModel(Base):
class CredentialModel(Base): class CredentialModel(Base):
__tablename__ = "credentials" __tablename__ = "credentials"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
credential_id: Mapped[bytes] = mapped_column(LargeBinary(64), primary_key=True) credential_id: Mapped[bytes] = mapped_column(LargeBinary(64), unique=True)
user_id: Mapped[bytes] = mapped_column( user_id: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("users.user_id", ondelete="CASCADE") LargeBinary(16), ForeignKey("users.user_id", ondelete="CASCADE")
) )
@ -68,14 +68,18 @@ class CredentialModel(Base):
user: Mapped["UserModel"] = relationship("UserModel", back_populates="credentials") user: Mapped["UserModel"] = relationship("UserModel", back_populates="credentials")
class ResetTokenModel(Base): class SessionModel(Base):
__tablename__ = "reset_tokens" __tablename__ = "sessions"
token: Mapped[str] = mapped_column(String(64), primary_key=True) token: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[bytes] = mapped_column( user_id: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("users.user_id", ondelete="CASCADE") LargeBinary(16), ForeignKey("users.user_id", ondelete="CASCADE")
) )
credential_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("credentials.id", ondelete="SET NULL")
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Relationship to user # Relationship to user
user: Mapped["UserModel"] = relationship("UserModel") user: Mapped["UserModel"] = relationship("UserModel")
@ -90,13 +94,6 @@ class User:
visits: int = 0 visits: int = 0
@dataclass
class ResetToken:
token: str
user_id: UUID
created_at: datetime
# Global engine and session factory # Global engine and session factory
engine = create_async_engine(DB_PATH, echo=False) engine = create_async_engine(DB_PATH, echo=False)
async_session_factory = async_sessionmaker(engine, expire_on_commit=False) async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
@ -243,45 +240,6 @@ class DB:
await self.session.execute(stmt) await self.session.execute(stmt)
await self.session.commit() await self.session.commit()
async def create_reset_token(self, user_id: UUID, token: str | None = None) -> str:
"""Create a new reset token for a user."""
if token is None:
token = secrets.token_urlsafe(32)
reset_token_model = ResetTokenModel(
token=token,
user_id=user_id.bytes,
created_at=datetime.now(),
)
self.session.add(reset_token_model)
await self.session.flush()
return token
async def get_reset_token(self, token: str) -> ResetToken | None:
"""Get reset token by token string."""
stmt = select(ResetTokenModel).where(ResetTokenModel.token == token)
result = await self.session.execute(stmt)
token_model = result.scalar_one_or_none()
if token_model:
return ResetToken(
token=token_model.token,
user_id=UUID(bytes=token_model.user_id),
created_at=token_model.created_at,
)
return None
async def delete_reset_token(self, token: str) -> None:
"""Delete a reset token (used after successful credential addition)."""
stmt = delete(ResetTokenModel).where(ResetTokenModel.token == token)
await self.session.execute(stmt)
async def cleanup_expired_tokens(self) -> None:
"""Remove expired reset tokens (older than 24 hours)."""
expiry_time = datetime.now() - timedelta(hours=24)
stmt = delete(ResetTokenModel).where(ResetTokenModel.created_at < expiry_time)
await self.session.execute(stmt)
async def get_user_by_username(self, user_name: str) -> User | None: async def get_user_by_username(self, user_name: str) -> User | None:
"""Get user by username.""" """Get user by username."""
stmt = select(UserModel).where(UserModel.user_name == user_name) stmt = select(UserModel).where(UserModel.user_name == user_name)
@ -298,6 +256,98 @@ class DB:
) )
return None return None
async def create_session(
self,
user_id: UUID,
credential_id: int | None = None,
token: str | None = None,
info: dict | None = None,
) -> str:
"""Create a new authentication session for a user. If credential_id is None, creates a session without a specific credential."""
if token is None:
token = secrets.token_urlsafe(12)
session_model = SessionModel(
token=token,
user_id=user_id.bytes,
credential_id=credential_id,
created_at=datetime.now(),
info=info,
)
self.session.add(session_model)
await self.session.flush()
return token
async def create_session_by_credential_id(
self,
user_id: UUID,
credential_id: bytes | None,
token: str | None = None,
info: dict | None = None,
) -> str:
"""Create a new authentication session for a user using WebAuthn credential ID. If credential_id is None, creates a session without a specific credential."""
if credential_id is None:
return await self.create_session(user_id, None, token, info)
# Get the database ID from the credential
stmt = select(CredentialModel.id).where(
CredentialModel.credential_id == credential_id
)
result = await self.session.execute(stmt)
db_credential_id = result.scalar_one()
return await self.create_session(user_id, db_credential_id, token, info)
async def get_session(self, token: str) -> dict | None:
"""Get session by token string."""
stmt = select(SessionModel).where(SessionModel.token == token)
result = await self.session.execute(stmt)
session_model = result.scalar_one_or_none()
if session_model:
# Check if session is expired (24 hours)
expiry_time = session_model.created_at + timedelta(hours=24)
if datetime.now() > expiry_time:
# Clean up expired session
await self.delete_session(token)
return None
return {
"token": session_model.token,
"user_id": UUID(bytes=session_model.user_id),
"credential_id": session_model.credential_id,
"created_at": session_model.created_at,
"info": session_model.info or {},
}
return None
async def delete_session(self, token: str) -> None:
"""Delete a session by token."""
stmt = delete(SessionModel).where(SessionModel.token == token)
await self.session.execute(stmt)
async def cleanup_expired_sessions(self) -> None:
"""Remove expired sessions (older than 24 hours)."""
expiry_time = datetime.now() - timedelta(hours=24)
stmt = delete(SessionModel).where(SessionModel.created_at < expiry_time)
await self.session.execute(stmt)
async def refresh_session(self, token: str) -> str | None:
"""Refresh a session by updating its created_at timestamp."""
session_data = await self.get_session(token)
if not session_data:
return None
# Delete old session
await self.delete_session(token)
# Create new session with same user and credential
return await self.create_session(
session_data["user_id"],
session_data["credential_id"],
info=session_data["info"],
)
# Standalone functions that handle database connections internally # Standalone functions that handle database connections internally
async def init_database() -> None: async def init_database() -> None:
@ -358,31 +408,55 @@ async def create_new_session(user_id: UUID, credential: StoredCredential) -> Non
await db.create_new_session(user_id, credential) await db.create_new_session(user_id, credential)
async def create_reset_token(user_id: UUID, token: str | None = None) -> str:
"""Create a reset token for a user."""
async with connect() as db:
return await db.create_reset_token(user_id, token)
async def get_reset_token(token: str) -> ResetToken | None:
"""Get reset token by token string."""
async with connect() as db:
return await db.get_reset_token(token)
async def delete_reset_token(token: str) -> None:
"""Delete a reset token (used after successful credential addition)."""
async with connect() as db:
await db.delete_reset_token(token)
async def cleanup_expired_tokens() -> None:
"""Remove expired reset tokens (older than 24 hours)."""
async with connect() as db:
await db.cleanup_expired_tokens()
async def get_user_by_username(user_name: str) -> User | None: async def get_user_by_username(user_name: str) -> User | None:
"""Get user by username.""" """Get user by username."""
async with connect() as db: async with connect() as db:
return await db.get_user_by_username(user_name) return await db.get_user_by_username(user_name)
async def create_session(
user_id: UUID,
credential_id: int | None = None,
token: str | None = None,
info: dict | None = None,
) -> str:
"""Create a new authentication session for a user. If credential_id is None, creates a session without a specific credential."""
async with connect() as db:
return await db.create_session(user_id, credential_id, token, info)
async def create_session_by_credential_id(
user_id: UUID,
credential_id: bytes | None,
token: str | None = None,
info: dict | None = None,
) -> str:
"""Create a new authentication session for a user using WebAuthn credential ID. If credential_id is None, creates a session without a specific credential."""
async with connect() as db:
return await db.create_session_by_credential_id(
user_id, credential_id, token, info
)
async def get_session(token: str) -> dict | None:
"""Get session by token string."""
async with connect() as db:
return await db.get_session(token)
async def delete_session(token: str) -> None:
"""Delete a session by token."""
async with connect() as db:
await db.delete_session(token)
async def cleanup_expired_sessions() -> None:
"""Remove expired sessions (older than 24 hours)."""
async with connect() as db:
await db.cleanup_expired_sessions()
async def refresh_session(token: str) -> str | None:
"""Refresh a session by updating its created_at timestamp."""
async with connect() as db:
return await db.refresh_session(token)

View File

@ -8,12 +8,12 @@ This module contains all the HTTP API endpoints for:
- Login/logout functionality - Login/logout functionality
""" """
from fastapi import Request, Response from fastapi import FastAPI, Request, Response
from .. import aaguid from .. import aaguid
from ..db import sql from ..db import sql
from ..util.jwt import refresh_session_token, validate_session_token from ..util.session import refresh_session_token, validate_session_token
from .session_manager import ( from .session import (
clear_session_cookie, clear_session_cookie,
get_current_user, get_current_user,
get_session_token_from_bearer, get_session_token_from_bearer,
@ -22,29 +22,12 @@ from .session_manager import (
) )
async def get_user_info(request: Request) -> dict: def register_api_routes(app: FastAPI):
"""Get user information from session cookie.""" """Register all API routes on the FastAPI app."""
try:
user = await get_current_user(request)
if not user:
return {"error": "Not authenticated"}
return { @app.post("/auth/user-info")
"status": "success", async def api_user_info(request: Request, response: Response):
"user": { """Get user information and credentials from session cookie."""
"user_id": str(user.user_id),
"user_name": user.user_name,
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
"visits": user.visits,
},
}
except Exception as e:
return {"error": f"Failed to get user info: {str(e)}"}
async def get_user_credentials(request: Request) -> dict:
"""Get all credentials for a user using session cookie."""
try: try:
user = await get_current_user(request) user = await get_current_user(request)
if not user: if not user:
@ -54,7 +37,7 @@ async def get_user_credentials(request: Request) -> dict:
current_credential_id = None current_credential_id = None
session_token = get_session_token_from_cookie(request) session_token = get_session_token_from_cookie(request)
if session_token: if session_token:
token_data = validate_session_token(session_token) token_data = await 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")
@ -98,66 +81,44 @@ async def get_user_credentials(request: Request) -> dict:
return { return {
"status": "success", "status": "success",
"user": {
"user_id": str(user.user_id),
"user_name": user.user_name,
"created_at": user.created_at.isoformat()
if user.created_at
else None,
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
"visits": user.visits,
},
"credentials": credentials, "credentials": credentials,
"aaguid_info": aaguid_info, "aaguid_info": aaguid_info,
} }
except Exception as e: except Exception as e:
return {"error": f"Failed to get credentials: {str(e)}"} return {"error": f"Failed to get user info: {str(e)}"}
@app.post("/auth/logout")
async def refresh_token(request: Request, response: Response) -> dict: async def api_logout(request: Request, response: Response):
"""Refresh the session token.""" """Log out the current user by clearing the session cookie and deleting from database."""
try: # Get the session token before clearing the cookie
session_token = get_session_token_from_cookie(request) session_token = get_session_token_from_cookie(request)
if not session_token:
return {"error": "No session token found"}
# Validate and refresh the token # Clear the cookie
new_token = refresh_session_token(session_token)
if new_token:
set_session_cookie(response, new_token)
return {"status": "success", "refreshed": True}
else:
clear_session_cookie(response) clear_session_cookie(response)
return {"error": "Invalid or expired session token"}
except Exception as e: # Delete the session from the database if it exists
return {"error": f"Failed to refresh token: {str(e)}"} if session_token:
from ..util.session import logout_session
async def validate_token(request: Request) -> dict:
"""Validate a session token and return user info."""
try: try:
session_token = get_session_token_from_cookie(request) await logout_session(session_token)
if not session_token: except Exception:
return {"error": "No session token found"} # Continue even if session deletion fails
pass
# Validate the session token
token_data = validate_session_token(session_token)
if not token_data:
return {"error": "Invalid or expired session token"}
return {
"status": "success",
"valid": True,
"user_id": str(token_data["user_id"]),
"credential_id": token_data["credential_id"].hex(),
"issued_at": token_data["issued_at"],
"expires_at": token_data["expires_at"],
}
except Exception as e:
return {"error": f"Failed to validate token: {str(e)}"}
async def logout(response: Response) -> dict:
"""Log out the current user by clearing the session cookie."""
clear_session_cookie(response)
return {"status": "success", "message": "Logged out successfully"} return {"status": "success", "message": "Logged out successfully"}
@app.post("/auth/set-session")
async def set_session(request: Request, response: Response) -> dict: async def api_set_session(request: Request, response: Response):
"""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_bearer(request) session_token = await get_session_token_from_bearer(request)
@ -166,7 +127,7 @@ async def set_session(request: Request, response: Response) -> dict:
return {"error": "No session token provided"} return {"error": "No session token provided"}
# Validate the session token # Validate the session token
token_data = validate_session_token(session_token) token_data = await validate_session_token(session_token)
if not token_data: if not token_data:
return {"error": "Invalid or expired session token"} return {"error": "Invalid or expired session token"}
@ -182,8 +143,8 @@ async def set_session(request: Request, response: Response) -> dict:
except Exception as e: except Exception as e:
return {"error": f"Failed to set session: {str(e)}"} return {"error": f"Failed to set session: {str(e)}"}
@app.post("/auth/delete-credential")
async def delete_credential(request: Request) -> dict: async def api_delete_credential(request: Request):
"""Delete a specific credential for the current user.""" """Delete a specific credential for the current user."""
try: try:
user = await get_current_user(request) user = await get_current_user(request)
@ -216,8 +177,11 @@ async def delete_credential(request: Request) -> dict:
# Check if this is the current session credential # Check if this is the current session credential
session_token = get_session_token_from_cookie(request) session_token = get_session_token_from_cookie(request)
if session_token: if session_token:
token_data = validate_session_token(session_token) token_data = await 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
@ -232,3 +196,97 @@ async def delete_credential(request: Request) -> dict:
except Exception as e: except Exception as e:
return {"error": f"Failed to delete credential: {str(e)}"} return {"error": f"Failed to delete credential: {str(e)}"}
@app.get("/auth/sessions")
async def api_get_sessions(request: Request):
"""Get all active sessions for the current user."""
try:
user = await get_current_user(request)
if not user:
return {"error": "Authentication required"}
# Get all sessions for this user
from sqlalchemy import select
from ..db.sql import SessionModel, connect
async with connect() as db:
stmt = select(SessionModel).where(
SessionModel.user_id == user.user_id.bytes
)
result = await db.session.execute(stmt)
session_models = result.scalars().all()
sessions = []
current_token = get_session_token_from_cookie(request)
for session in session_models:
# Check if session is expired
from datetime import datetime, timedelta
expiry_time = session.created_at + timedelta(hours=24)
is_expired = datetime.now() > expiry_time
sessions.append(
{
"token": session.token[:8]
+ "...", # Only show first 8 chars for security
"created_at": session.created_at.isoformat(),
"client_ip": session.info.get("client_ip")
if session.info
else None,
"user_agent": session.info.get("user_agent")
if session.info
else None,
"connection_type": session.info.get(
"connection_type", "http"
)
if session.info
else "http",
"is_current": session.token == current_token,
"is_reset_token": session.credential_id is None,
"is_expired": is_expired,
}
)
return {
"status": "success",
"sessions": sessions,
"total_sessions": len(sessions),
}
except Exception as e:
return {"error": f"Failed to get sessions: {str(e)}"}
async def validate_token(request: Request, response: Response) -> dict:
"""Validate a session token and return user info. Also refreshes the token if valid."""
try:
session_token = get_session_token_from_cookie(request)
if not session_token:
return {"error": "No session token found"}
# Validate the session token
token_data = await validate_session_token(session_token)
if not token_data:
clear_session_cookie(response)
return {"error": "Invalid or expired session token"}
# Refresh the token if valid
new_token = await refresh_session_token(session_token)
if new_token:
set_session_cookie(response, new_token)
return {
"status": "success",
"valid": True,
"refreshed": bool(new_token),
"user_id": str(token_data["user_id"]),
"credential_id": token_data["credential_id"].hex()
if token_data["credential_id"]
else None,
"created_at": token_data["created_at"].isoformat(),
}
except Exception as e:
return {"error": f"Failed to validate token: {str(e)}"}

View File

@ -18,26 +18,18 @@ from fastapi import (
Request, Request,
Response, Response,
) )
from fastapi import (
Path as FastAPIPath,
)
from fastapi.responses import ( from fastapi.responses import (
FileResponse, FileResponse,
RedirectResponse, JSONResponse,
) )
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from ..db import sql from ..db import sql
from .api_handlers import ( from .api_handlers import (
delete_credential, register_api_routes,
get_user_credentials,
get_user_info,
logout,
refresh_token,
set_session,
validate_token, validate_token,
) )
from .reset_handlers import create_device_addition_link, validate_device_addition_token from .reset_handlers import register_reset_routes
from .ws_handlers import ws_app from .ws_handlers import ws_app
STATIC_DIR = Path(__file__).parent.parent / "frontend-build" STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
@ -49,43 +41,23 @@ async def lifespan(app: FastAPI):
yield yield
app = FastAPI(title="Passkey Auth", lifespan=lifespan) app = FastAPI(lifespan=lifespan)
# Mount the WebSocket subapp # Mount the WebSocket subapp
app.mount("/auth/ws", ws_app) app.mount("/auth/ws", ws_app)
# Register API routes
register_api_routes(app)
register_reset_routes(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") @app.get("/auth/forward-auth")
async def forward_authentication(request: Request): async def forward_authentication(request: Request):
"""A verification endpoint to use with Caddy forward_auth or Nginx auth_request.""" """A verification endpoint to use with Caddy forward_auth or Nginx auth_request."""
result = await validate_token(request) # Create a dummy response object for internal validation (we won't use it for cookies)
response = Response()
result = await validate_token(request, response)
if result.get("status") != "success": if result.get("status") != "success":
# Serve the index.html of the authentication app if not authenticated # Serve the index.html of the authentication app if not authenticated
return FileResponse( return FileResponse(
@ -101,52 +73,6 @@ async def forward_authentication(request: Request):
) )
@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 # Serve static files
app.mount( app.mount(
"/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets" "/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets"
@ -164,7 +90,7 @@ async def redirect_to_index():
async def spa_handler(request: Request, path: str): async def spa_handler(request: Request, path: str):
"""Serve the Vue SPA for all routes (except API and static)""" """Serve the Vue SPA for all routes (except API and static)"""
if "text/html" not in request.headers.get("accept", ""): if "text/html" not in request.headers.get("accept", ""):
return Response(content="Not Found", status_code=404) return JSONResponse({"error": "Not Found"}, status_code=404)
return FileResponse(STATIC_DIR / "index.html") return FileResponse(STATIC_DIR / "index.html")

View File

@ -7,16 +7,20 @@ This module provides endpoints for authenticated users to:
- Add new passkeys to existing accounts via tokens - Add new passkeys to existing accounts via tokens
""" """
from datetime import datetime, timedelta from fastapi import FastAPI, Path, Request
from fastapi.responses import RedirectResponse
from fastapi import Request
from ..db import sql from ..db import sql
from ..util.passphrase import generate from ..util.passphrase import generate
from .session_manager import get_current_user from ..util.session import get_client_info
from .session import get_current_user, is_device_addition_session, set_session_cookie
async def create_device_addition_link(request: Request) -> dict: def register_reset_routes(app: FastAPI):
"""Register all device addition/reset routes on the FastAPI app."""
@app.post("/auth/create-device-link")
async def api_create_device_link(request: Request):
"""Create a device addition link for the authenticated user.""" """Create a device addition link for the authenticated user."""
try: try:
# Require authentication # Require authentication
@ -27,8 +31,9 @@ async def create_device_addition_link(request: Request) -> dict:
# 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 session token in database with credential_id=None for device addition
await sql.create_reset_token(user.user_id, token) client_info = get_client_info(request)
await sql.create_session(user.user_id, None, token, client_info)
# Generate the device addition link with pretty URL # Generate the device addition link with pretty URL
addition_link = f"{request.headers.get('origin', '')}/auth/{token}" addition_link = f"{request.headers.get('origin', '')}/auth/{token}"
@ -43,56 +48,60 @@ async def create_device_addition_link(request: Request) -> dict:
except Exception as e: except Exception as e:
return {"error": f"Failed to create device addition link: {str(e)}"} return {"error": f"Failed to create device addition link: {str(e)}"}
@app.get("/auth/device-session-check")
async def check_device_session(request: Request):
"""Check if the current session is for device addition."""
is_device_session = await is_device_addition_session(request)
return {"device_addition_session": is_device_session}
async def validate_device_addition_token(request: Request) -> dict: @app.get("/auth/{passphrase}")
"""Validate a device addition token and return user info.""" async def reset_authentication(
request: Request,
passphrase: str = Path(pattern=r"^\w+(\.\w+){2,}$"),
):
try: try:
body = await request.json() # Get session token to validate it exists and get user_id
token = body.get("token") session_data = await sql.get_session(passphrase)
if not session_data:
# Token doesn't exist, redirect to home
return RedirectResponse(url="/", status_code=303)
if not token: # Check if this is a device addition session (credential_id is None)
return {"error": "Device addition token is required"} if session_data["credential_id"] is not None:
# Not a device addition session, redirect to home
return RedirectResponse(url="/", status_code=303)
# Get reset token # Create a device addition session token for the user
reset_token = await sql.get_reset_token(token) client_info = get_client_info(request)
if not reset_token: session_token = await sql.create_session(
return {"error": "Invalid or expired device addition token"} session_data["user_id"], None, None, client_info
)
# Check if token is expired (24 hours) # Create response and set session cookie
expiry_time = reset_token.created_at + timedelta(hours=24) response = RedirectResponse(url="/auth/", status_code=303)
if datetime.now() > expiry_time: set_session_cookie(response, session_token)
return {"error": "Device addition token has expired"}
# Get user info return response
user = await sql.get_user_by_id(reset_token.user_id)
return { except Exception:
"status": "success", # On any error, redirect to home
"valid": True, return RedirectResponse(url="/", status_code=303)
"user_id": str(user.user_id),
"user_name": user.user_name,
"token": token,
}
except Exception as e:
return {"error": f"Failed to validate device addition token: {str(e)}"}
async def use_device_addition_token(token: str) -> dict: 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 session token first to validate it exists and is not expired
reset_token = await sql.get_reset_token(token) session_data = await sql.get_session(token)
if not reset_token: if not session_data:
return {"error": "Invalid or expired device addition token"} return {"error": "Invalid or expired device addition token"}
# Check if token is expired (24 hours) # Check if this is a device addition session (credential_id is None)
expiry_time = reset_token.created_at + timedelta(hours=24) if session_data["credential_id"] is not None:
if datetime.now() > expiry_time: return {"error": "Invalid device addition token"}
return {"error": "Device addition token has expired"}
# Delete the token (it's now used) # Delete the token (it's now used)
await sql.delete_reset_token(token) await sql.delete_session(token)
return { return {
"status": "success", "status": "success",

View File

@ -12,7 +12,7 @@ from uuid import UUID
from fastapi import Request, Response from fastapi import Request, Response
from ..db.sql import User, get_user_by_id from ..db.sql import User, get_user_by_id
from ..util.jwt import validate_session_token from ..util.session import validate_session_token
COOKIE_NAME = "auth" COOKIE_NAME = "auth"
COOKIE_MAX_AGE = 86400 # 24 hours COOKIE_MAX_AGE = 86400 # 24 hours
@ -24,7 +24,7 @@ async def get_current_user(request: Request) -> User | None:
if not session_token: if not session_token:
return None return None
token_data = validate_session_token(session_token) token_data = await validate_session_token(session_token)
if not token_data: if not token_data:
return None return None
@ -63,7 +63,7 @@ async def validate_session_from_request(request: Request) -> dict | None:
if not session_token: if not session_token:
return None return None
return validate_session_token(session_token) return await validate_session_token(session_token)
async def get_session_token_from_bearer(request: Request) -> str | None: async def get_session_token_from_bearer(request: Request) -> str | None:
@ -91,8 +91,58 @@ async def get_user_from_cookie_string(cookie_header: str) -> UUID | None:
if not session_token: if not session_token:
return None return None
token_data = validate_session_token(session_token) token_data = await validate_session_token(session_token)
if not token_data: if not token_data:
return None return None
return token_data["user_id"] return token_data["user_id"]
async def is_device_addition_session(request: Request) -> bool:
"""Check if the current session is for device addition."""
session_token = request.cookies.get(COOKIE_NAME)
if not session_token:
return False
token_data = await validate_session_token(session_token)
if not token_data:
return False
return token_data.get("device_addition", False)
async def get_device_addition_user_id(request: Request) -> UUID | None:
"""Get user ID from device addition session."""
session_token = request.cookies.get(COOKIE_NAME)
if not session_token:
return None
token_data = await validate_session_token(session_token)
if not token_data or not token_data.get("device_addition"):
return None
return token_data.get("user_id")
async def get_device_addition_user_id_from_cookie(cookie_header: str) -> UUID | None:
"""Parse cookie header and return user ID if valid device addition session exists."""
if not cookie_header:
return None
# Parse cookies from header (simple implementation)
cookies = {}
for cookie in cookie_header.split(";"):
cookie = cookie.strip()
if "=" in cookie:
name, value = cookie.split("=", 1)
cookies[name] = value
session_token = cookies.get(COOKIE_NAME)
if not session_token:
return None
token_data = await validate_session_token(session_token)
if not token_data or not token_data.get("device_addition"):
return None
return token_data["user_id"]

View File

@ -19,8 +19,8 @@ from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from ..db import sql from ..db import sql
from ..db.sql import User from ..db.sql import User
from ..sansio import Passkey from ..sansio import Passkey
from ..util.jwt import create_session_token from ..util.session import create_session_token, get_client_info_from_websocket
from .session_manager import get_user_from_cookie_string from .session import get_user_from_cookie_string
# Create a FastAPI subapp for WebSocket endpoints # Create a FastAPI subapp for WebSocket endpoints
ws_app = FastAPI() ws_app = FastAPI()
@ -69,7 +69,10 @@ async def websocket_register_new(ws: WebSocket, user_name: str):
) )
# Create a session token for the new user # Create a session token for the new user
session_token = create_session_token(user_id, credential.credential_id) client_info = get_client_info_from_websocket(ws)
session_token = await create_session_token(
user_id, credential.credential_id, client_info
)
await ws.send_json( await ws.send_json(
{ {
@ -181,6 +184,56 @@ async def websocket_add_device_credential(ws: WebSocket, token: str):
await ws.send_json({"error": "Internal Server Error"}) await ws.send_json({"error": "Internal Server Error"})
@ws_app.websocket("/add_device_credential_session")
async def websocket_add_device_credential_session(ws: WebSocket):
"""Add a new credential for an existing user via device addition session."""
await ws.accept()
origin = ws.headers.get("origin")
try:
# Get device addition user ID from session cookie
cookie_header = ws.headers.get("cookie", "")
from .session import get_device_addition_user_id_from_cookie
user_id = await get_device_addition_user_id_from_cookie(cookie_header)
if not user_id:
await ws.send_json({"error": "No valid device addition session found"})
return
# Get user information
user = await sql.get_user_by_id(user_id)
if not user:
await ws.send_json({"error": "User not found"})
return
# WebAuthn registration
# Fetch challenge IDs for the user
challenge_ids = await sql.get_user_credentials(user_id)
credential = await register_chat(
ws, user_id, user.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 via device addition session",
}
)
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") @ws_app.websocket("/authenticate")
async def websocket_authenticate(ws: WebSocket): async def websocket_authenticate(ws: WebSocket):
await ws.accept() await ws.accept()
@ -198,8 +251,9 @@ async def websocket_authenticate(ws: WebSocket):
await sql.login_user(stored_cred.user_id, stored_cred) await sql.login_user(stored_cred.user_id, stored_cred)
# Create a session token for the authenticated user # Create a session token for the authenticated user
session_token = create_session_token( client_info = get_client_info_from_websocket(ws)
stored_cred.user_id, stored_cred.credential_id session_token = await create_session_token(
stored_cred.user_id, stored_cred.credential_id, client_info
) )
await ws.send_json( await ws.send_json(

View File

@ -58,6 +58,28 @@ class JWTManager:
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm) return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def create_token_without_credential(self, user_id: UUID) -> str:
"""
Create a JWT token for device addition (without credential ID).
Args:
user_id: The user's UUID
Returns:
JWT token string for device addition
"""
now = datetime.now()
payload = {
"user_id": str(user_id),
"credential_id": None, # No credential for device addition
"device_addition": True, # Flag to indicate this is for device addition
"iat": now,
"exp": now + timedelta(hours=2), # Shorter expiry for device addition
"iss": "passkeyauth",
}
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def validate_token(self, token: str) -> Optional[dict]: def validate_token(self, token: str) -> Optional[dict]:
""" """
Validate a JWT token and return the payload. Validate a JWT token and return the payload.
@ -76,12 +98,23 @@ class JWTManager:
issuer="passkeyauth", issuer="passkeyauth",
) )
return { result = {
"user_id": UUID(payload["user_id"]), "user_id": UUID(payload["user_id"]),
"credential_id": bytes.fromhex(payload["credential_id"]),
"issued_at": payload["iat"], "issued_at": payload["iat"],
"expires_at": payload["exp"], "expires_at": payload["exp"],
} }
# Handle credential_id for regular tokens vs device addition tokens
if payload.get("credential_id") is not None:
result["credential_id"] = bytes.fromhex(payload["credential_id"])
else:
result["credential_id"] = None
# Add device addition flag if present
if payload.get("device_addition"):
result["device_addition"] = True
return result
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
return None return None
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
@ -122,6 +155,11 @@ def create_session_token(user_id: UUID, credential_id: bytes) -> str:
return get_jwt_manager().create_token(user_id, credential_id) return get_jwt_manager().create_token(user_id, credential_id)
def create_device_addition_token(user_id: UUID) -> str:
"""Create a token for device addition."""
return get_jwt_manager().create_token_without_credential(user_id)
def validate_session_token(token: str) -> Optional[dict]: def validate_session_token(token: str) -> Optional[dict]:
"""Validate a session token.""" """Validate a session token."""
return get_jwt_manager().validate_token(token) return get_jwt_manager().validate_token(token)

88
passkey/util/session.py Normal file
View File

@ -0,0 +1,88 @@
"""
Database session management for WebAuthn authentication.
This module provides session management using database tokens instead of JWT tokens.
Session tokens are stored in the database and validated on each request.
"""
from datetime import datetime
from typing import Optional
from uuid import UUID
from fastapi import Request
from ..db import sql
def get_client_info(request: Request) -> dict:
"""Extract client information from FastAPI request and return as dict."""
# Get client IP (handle X-Forwarded-For for proxies)
# Get user agent
return {
"client_ip": request.client.host if request.client else "",
"user_agent": request.headers.get("user-agent", "")[:500],
}
def get_client_info_from_websocket(ws) -> dict:
"""Extract client information from WebSocket connection and return as dict."""
# Get client IP from WebSocket
client_ip = None
if hasattr(ws, "client") and ws.client:
client_ip = ws.client.host
# Check for forwarded headers
if hasattr(ws, "headers"):
forwarded_for = ws.headers.get("x-forwarded-for")
if forwarded_for:
client_ip = forwarded_for.split(",")[0].strip()
# Get user agent from WebSocket headers
user_agent = None
if hasattr(ws, "headers"):
user_agent = ws.headers.get("user-agent")
# Truncate user agent if too long
if user_agent and len(user_agent) > 500: # Keep some margin
user_agent = user_agent[:500]
return {
"client_ip": client_ip,
"user_agent": user_agent,
"timestamp": datetime.now().isoformat(),
"connection_type": "websocket",
}
async def create_session_token(
user_id: UUID, credential_id: bytes, info: dict | None = None
) -> str:
"""Create a session token for a user."""
return await sql.create_session_by_credential_id(user_id, credential_id, None, info)
async def validate_session_token(token: str) -> Optional[dict]:
"""Validate a session token."""
session_data = await sql.get_session(token)
if not session_data:
return None
return {
"user_id": session_data["user_id"],
"credential_id": session_data["credential_id"],
"created_at": session_data["created_at"],
}
async def refresh_session_token(token: str) -> Optional[str]:
"""Refresh a session token."""
return await sql.refresh_session(token)
async def delete_session_token(token: str) -> None:
"""Delete a session token."""
await sql.delete_session(token)
async def logout_session(token: str) -> None:
"""Log out a user by deleting their session token."""
await sql.delete_session(token)