Everything works. Minor adjustments on frontend and backend for the new API.

This commit is contained in:
Leo Vasanko 2025-08-02 07:41:42 -06:00
parent a987f47988
commit 30ab73d625
9 changed files with 43 additions and 391 deletions

375
API.md
View File

@ -4,361 +4,20 @@ This document describes all API endpoints available in the PassKey Auth FastAPI
## Base URL ## Base URL
- **Development**: `http://localhost:4401` - **Development**: `http://localhost:4401`
- All API endpoints are prefixed with `/auth` - All endpoints are prefixed with `/auth/`
## Authentication ### HTTP Endpoints
The API uses JWT tokens stored in HTTP-only cookies for session management. Some endpoints require authentication via session cookies. GET /auth/ - Main authentication app
GET /auth/forward-auth - Authentication validation for Caddy/Nginx
--- POST /auth/validate - Token validation endpoint
POST /auth/user-info - Get authenticated user information
## HTTP API Endpoints POST /auth/logout - Logout current user
POST /auth/set-session - Set session cookie from Authorization header
### User Management DELETE /auth/credential/{uuid} - Delete specific credential
POST /auth/create-link - Create device addition link
#### `POST /auth/user-info` GET /auth/{reset_token} - Process reset token and redirect
Get detailed information about the current authenticated user and their credentials.
### WebSocket Endpoints
**Authentication**: Required (session cookie) WS /auth/ws/register - Register new user with passkey
WS /auth/ws/add_credential - Add new credential for existing user
**Response**: WS /auth/ws/authenticate - Authenticate user with passkey
```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

@ -1,6 +1,5 @@
(auth) { (auth) {
# Forward /auth to the authentication service # Forward /auth/ to the authentication service
redir /auth /auth/ 302
@auth path /auth/* @auth path /auth/*
handle @auth { handle @auth {
reverse_proxy localhost:4401 reverse_proxy localhost:4401
@ -9,7 +8,7 @@
# Check for authentication # Check for authentication
forward_auth localhost:4401 { forward_auth localhost:4401 {
uri /auth/forward-auth uri /auth/forward-auth
copy_headers x-auth-user-id copy_headers x-auth*
} }
{block} {block}
} }

View File

@ -60,7 +60,8 @@ onMounted(async () => {
}) })
} }
} catch (error) { } catch (error) {
console.error('Failed to create link:', error) authStore.showMessage(`Failed to create device link: ${error.message}`, 'error')
authStore.currentView = 'profile'
} }
}) })
</script> </script>

View File

View File

@ -79,6 +79,8 @@ export const useAuthStore = defineStore('auth', {
this.currentUser = result.user this.currentUser = result.user
this.currentCredentials = result.credentials || [] this.currentCredentials = result.credentials || []
this.aaguidInfo = result.aaguid_info || {} this.aaguidInfo = result.aaguid_info || {}
if (result.session_type === 'device addition') this.currentView = 'add-credential'
console.log('User info loaded:', result)
}, },
async deleteCredential(uuid) { async deleteCredential(uuid) {
const response = await fetch(`/auth/credential/${uuid}`, {method: 'DELETE'}) const response = await fetch(`/auth/credential/${uuid}`, {method: 'DELETE'})

View File

@ -5,7 +5,7 @@ This module provides dataclasses and database abstractions for managing
users, credentials, and sessions in a WebAuthn authentication system. users, credentials, and sessions in a WebAuthn authentication system.
""" """
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
@ -43,8 +43,8 @@ class Session:
key: bytes key: bytes
user_uuid: UUID user_uuid: UUID
expires: datetime expires: datetime
info: dict
credential_uuid: UUID | None = None credential_uuid: UUID | None = None
info: dict | None = None
__all__ = ["User", "Credential", "Session"] __all__ = ["User", "Credential", "Session"]

View File

@ -81,6 +81,7 @@ def register_api_routes(app: FastAPI):
return { return {
"status": "success", "status": "success",
"session_type": s.info["type"],
"user": { "user": {
"user_uuid": str(u.user_uuid), "user_uuid": str(u.user_uuid),
"user_name": u.user_name, "user_name": u.user_name,
@ -91,8 +92,10 @@ def register_api_routes(app: FastAPI):
"credentials": credentials, "credentials": credentials,
"aaguid_info": aaguid_info, "aaguid_info": aaguid_info,
} }
except Exception as e: except ValueError as e:
return {"error": f"Failed to get user info: {str(e)}"} return {"error": f"Failed to get user info: {e}"}
except Exception:
return {"error": "Failed to get user info"}
@app.post("/auth/logout") @app.post("/auth/logout")
async def api_logout(response: Response, auth=Cookie(None)): async def api_logout(response: Response, auth=Cookie(None)):
@ -123,7 +126,7 @@ def register_api_routes(app: FastAPI):
except ValueError as e: except ValueError as e:
return {"error": str(e)} return {"error": str(e)}
except Exception as e: except Exception as e:
return {"error": f"Failed to set session: {str(e)}"} return {"error": f"Failed to set session: {e}"}
@app.delete("/auth/credential/{uuid}") @app.delete("/auth/credential/{uuid}")
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)): async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):

View File

@ -17,7 +17,6 @@ from pathlib import Path
from fastapi import Cookie, FastAPI, Request, Response from fastapi import Cookie, FastAPI, Request, Response
from fastapi.responses import ( from fastapi.responses import (
FileResponse, FileResponse,
JSONResponse,
) )
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -52,7 +51,12 @@ async def forward_authentication(request: Request, auth=Cookie(None)):
s = await session.get_session(auth) s = await session.get_session(auth)
# If authenticated, return a success response # If authenticated, return a success response
if s.info and s.info["type"] == "authenticated": if s.info and s.info["type"] == "authenticated":
return Response(status_code=204, headers={"x-auth-user": str(s.user_uuid)}) return Response(
status_code=204,
headers={
"x-auth-user-uuid": str(s.user_uuid),
},
)
# 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(
@ -68,21 +72,12 @@ app.mount(
) )
@app.get("/auth") @app.get("/auth/")
async def redirect_to_index(): async def redirect_to_index():
"""Serve the main authentication app.""" """Serve the main authentication app."""
return FileResponse(STATIC_DIR / "index.html") 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 JSONResponse({"error": "Not Found"}, status_code=404)
return FileResponse(STATIC_DIR / "index.html")
def main(): def main():
"""Entry point for the application""" """Entry point for the application"""
import uvicorn import uvicorn

View File

@ -20,7 +20,7 @@ from passkey.fastapi import session
from ..db import User, sql from ..db import User, sql
from ..sansio import Passkey from ..sansio import Passkey
from ..util.tokens import create_token, reset_key, session_key from ..util.tokens import create_token, session_key
from .session import create_session, infodict from .session import create_session, infodict
# Create a FastAPI subapp for WebSocket endpoints # Create a FastAPI subapp for WebSocket endpoints
@ -96,20 +96,13 @@ async def websocket_register_new(
@app.websocket("/add_credential") @app.websocket("/add_credential")
async def websocket_register_add(ws: WebSocket, token: str | None = None): async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
"""Register a new credential for an existing user.""" """Register a new credential for an existing user."""
print(auth)
await ws.accept() await ws.accept()
origin = ws.headers.get("origin") origin = ws.headers.get("origin")
try: try:
if not token: s = await session.get_session(auth, reset_allowed=True)
await ws.send_json({"error": "Token is required"})
return
# If a token is provided, use it to look up the session
key = reset_key(token)
s = await sql.get_session(key)
if not s:
await ws.send_json({"error": "Invalid or expired token"})
return
user_uuid = s.user_uuid user_uuid = s.user_uuid
# Get user information to get the user_name # Get user information to get the user_name
@ -119,7 +112,7 @@ async def websocket_register_add(ws: WebSocket, token: str | None = None):
# WebAuthn registration # WebAuthn registration
credential = await register_chat( credential = await register_chat(
ws, user_uuid, user_name, challenge_ids, origin=origin ws, user_uuid, user_name, challenge_ids, origin
) )
# Store the new credential in the database # Store the new credential in the database
await sql.create_credential_for_user(credential) await sql.create_credential_for_user(credential)