Files
ServerKit/backend/app/models/passkey.py
T
Juan Denis 47c92e0571 feat(auth): WebAuthn / Passkey support
- Add webauthn dependency.
- Add PasskeyCredential model and Alembic migration 019_passkey_credentials.
- Add PasskeyService for registration/authentication challenge flow.
- Add auth API endpoints:
  - POST /auth/passkeys/options/register
  - POST /auth/passkeys/register
  - GET /auth/passkeys
  - DELETE /auth/passkeys/<id>
  - POST /auth/passkeys/options/authenticate
  - POST /auth/passkeys/authenticate
- Expose passkey_enabled flag in User.to_dict().
2026-06-19 01:33:45 -04:00

55 lines
1.9 KiB
Python

import json
import base64
from datetime import datetime
from app import db
def _b64decode_url(value: str) -> bytes:
"""URL-safe base64 decode with padding fix."""
if not value:
return b''
padding = 4 - len(value) % 4
if padding != 4:
value += '=' * padding
return base64.urlsafe_b64decode(value)
class PasskeyCredential(db.Model):
"""Stored WebAuthn credential for a user."""
__tablename__ = 'passkey_credentials'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
credential_id = db.Column(db.String(500), nullable=False, unique=True, index=True)
public_key = db.Column(db.Text, nullable=False) # base64url-encoded COSE key
sign_count = db.Column(db.Integer, default=0)
transports = db.Column(db.Text, nullable=True) # JSON list
device_name = db.Column(db.String(200), nullable=True)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_used_at = db.Column(db.DateTime, nullable=True)
user = db.relationship('User', backref=db.backref('passkeys', lazy='dynamic'))
def get_transports(self):
if not self.transports:
return []
try:
return json.loads(self.transports)
except Exception:
return []
def set_transports(self, transports):
self.transports = json.dumps(list(transports)) if transports else None
def to_dict(self):
return {
'id': self.id,
'credential_id': self.credential_id,
'device_name': self.device_name,
'transports': self.get_transports(),
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None,
}