-
-
đą Device Registration Link
+
@@ -14,28 +16,62 @@
Generating link...
-
- Scan and visit the URL on another device.
- â ī¸ Expires in 24 hours and one-time use.
+
+ The user should open this link on the device where they want to register.
+ Open or scan this link on the device you wish to register to your account.
+ {{ expirationMessage }}
-
Expires: {{ new Date(expires).toLocaleString() }}
-
+
+
+
+
+
+
+
+
+ {{ displayUrl }}
+
+
+ Generating link...
+
+
+ The user should open this link on the device where they want to register.
+ Open this link on the device you wish to connect with.
+ {{ expirationMessage }}
+
+
+
+
+ Copy Link
+ Close
+
+
+
+
diff --git a/frontend/src/components/SessionList.vue b/frontend/src/components/SessionList.vue
new file mode 100644
index 0000000..503234a
--- /dev/null
+++ b/frontend/src/components/SessionList.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+ đ
+
+
{{ sessionHostLabel(session) }}
+
+ Current
+ This host
+ đī¸
+
+
+
+
Last used: {{ formatDate(session.last_renewed) }}
+
{{ session.user_agent }} {{ session.ip }}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js
index 3bc05d9..e80be53 100644
--- a/frontend/src/stores/auth.js
+++ b/frontend/src/stores/auth.js
@@ -4,7 +4,7 @@ import { register, authenticate } from '@/utils/passkey'
export const useAuthStore = defineStore('auth', {
state: () => ({
// Auth State
- userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
+ userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
settings: null, // Server provided settings (/auth/settings)
isLoading: false,
@@ -91,8 +91,7 @@ export const useAuthStore = defineStore('auth', {
},
selectView() {
if (!this.userInfo) this.currentView = 'login'
- else if (this.userInfo.authenticated) this.currentView = 'profile'
- else this.currentView = 'login'
+ else this.currentView = 'profile'
},
async loadUserInfo() {
const response = await fetch('/auth/api/user-info', { method: 'POST' })
@@ -134,9 +133,44 @@ export const useAuthStore = defineStore('auth', {
await this.loadUserInfo()
},
+ async terminateSession(sessionId) {
+ try {
+ const res = await fetch(`/auth/api/session/${sessionId}`, { method: 'DELETE' })
+ let payload = null
+ try {
+ payload = await res.json()
+ } catch (_) {
+ // ignore JSON parse errors
+ }
+ if (!res.ok || payload?.detail) {
+ const message = payload?.detail || 'Failed to terminate session'
+ throw new Error(message)
+ }
+ if (payload?.current_session_terminated) {
+ sessionStorage.clear()
+ location.reload()
+ return
+ }
+ await this.loadUserInfo()
+ this.showMessage('Session terminated', 'success', 2500)
+ } catch (error) {
+ console.error('Terminate session error:', error)
+ throw error
+ }
+ },
async logout() {
try {
- await fetch('/auth/api/logout', {method: 'POST'})
+ const res = await fetch('/auth/api/logout', {method: 'POST'})
+ if (!res.ok) {
+ let message = 'Logout failed'
+ try {
+ const data = await res.json()
+ if (data?.detail) message = data.detail
+ } catch (_) {
+ // ignore JSON parse errors
+ }
+ throw new Error(message)
+ }
sessionStorage.clear()
location.reload()
} catch (error) {
@@ -144,5 +178,25 @@ export const useAuthStore = defineStore('auth', {
this.showMessage(error.message, 'error')
}
},
+ async logoutEverywhere() {
+ try {
+ const res = await fetch('/auth/api/logout-all', {method: 'POST'})
+ if (!res.ok) {
+ let message = 'Logout failed'
+ try {
+ const data = await res.json()
+ if (data?.detail) message = data.detail
+ } catch (_) {
+ // ignore JSON parse errors
+ }
+ throw new Error(message)
+ }
+ sessionStorage.clear()
+ location.reload()
+ } catch (error) {
+ console.error('Logout-all error:', error)
+ this.showMessage(error.message, 'error')
+ }
+ },
}
})
diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js
index 9b38ff3..200a299 100644
--- a/frontend/src/utils/helpers.js
+++ b/frontend/src/utils/helpers.js
@@ -5,16 +5,18 @@ export function formatDate(dateString) {
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))
+ const diffMs = date - now // Changed to date - now for future/past
+ const isFuture = diffMs > 0
+ const absDiffMs = Math.abs(diffMs)
+ const diffMinutes = Math.round(absDiffMs / (1000 * 60))
+ const diffHours = Math.round(absDiffMs / (1000 * 60 * 60))
+ const diffDays = Math.round(absDiffMs / (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`
+ if (absDiffMs < 1000 * 60) return 'Now'
+ if (diffMinutes <= 60) return isFuture ? `In ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}` : diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
+ if (diffHours <= 24) return isFuture ? `In ${diffHours} hour${diffHours === 1 ? '' : 's'}` : diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
+ if (diffDays <= 14) return isFuture ? `In ${diffDays} day${diffDays === 1 ? '' : 's'}` : diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
}
export function getCookie(name) {
diff --git a/passkey/authsession.py b/passkey/authsession.py
index 27abac2..4de957f 100644
--- a/passkey/authsession.py
+++ b/passkey/authsession.py
@@ -8,61 +8,107 @@ independent of any web framework:
- Credential management
"""
-from datetime import datetime, timedelta
+from datetime import datetime, timezone
from uuid import UUID
-from .db import Session
+from .config import SESSION_LIFETIME
+from .db import ResetToken, Session
from .globals import db
+from .util import hostutil
from .util.tokens import create_token, reset_key, session_key
-EXPIRES = timedelta(hours=24)
+EXPIRES = SESSION_LIFETIME
def expires() -> datetime:
- return datetime.now() + EXPIRES
+ return datetime.now(timezone.utc) + EXPIRES
-async def create_session(user_uuid: UUID, credential_uuid: UUID, info: dict) -> str:
+def reset_expires() -> datetime:
+ from .config import RESET_LIFETIME
+
+ return datetime.now(timezone.utc) + RESET_LIFETIME
+
+
+def session_expiry(session: Session) -> datetime:
+ """Calculate the expiration timestamp for a session (UTC aware)."""
+ # After migration all renewed timestamps are timezone-aware UTC
+ return session.renewed + EXPIRES
+
+
+async def create_session(
+ user_uuid: UUID,
+ credential_uuid: UUID,
+ *,
+ host: str,
+ ip: str,
+ user_agent: str,
+) -> str:
"""Create a new session and return a session token."""
+ normalized_host = hostutil.normalize_host(host)
+ if not normalized_host:
+ raise ValueError("Host required for session creation")
token = create_token()
+ now = datetime.now(timezone.utc)
await db.instance.create_session(
user_uuid=user_uuid,
credential_uuid=credential_uuid,
key=session_key(token),
- expires=datetime.now() + EXPIRES,
- info=info,
+ host=normalized_host,
+ ip=ip,
+ user_agent=user_agent,
+ renewed=now,
)
return token
-async def get_reset(token: str) -> Session:
+async def get_reset(token: str) -> ResetToken:
"""Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token)."""
- session = await db.instance.get_session(reset_key(token))
- if not session:
+ record = await db.instance.get_reset_token(reset_key(token))
+ if not record:
raise ValueError("Invalid or expired session token")
- return session
+ if record.expiry < datetime.now(timezone.utc):
+ await db.instance.delete_reset_token(record.key)
+ raise ValueError("Invalid or expired session token")
+ return record
-async def get_session(token: str) -> Session:
+async def get_session(token: str, host: str | None = None) -> Session:
"""Validate a session token and return session data if valid."""
session = await db.instance.get_session(session_key(token))
if not session:
raise ValueError("Invalid or expired session token")
+ if session_expiry(session) < datetime.now(timezone.utc):
+ await db.instance.delete_session(session.key)
+ raise ValueError("Invalid or expired session token")
+ if host is not None:
+ normalized_host = hostutil.normalize_host(host)
+ if not normalized_host:
+ raise ValueError("Invalid host")
+ if session.host is None:
+ await db.instance.set_session_host(session.key, normalized_host)
+ session.host = normalized_host
+ elif session.host != normalized_host:
+ raise ValueError("Invalid or expired session token")
return session
-async def refresh_session_token(token: str):
+async def refresh_session_token(token: str, *, ip: str, user_agent: str):
"""Refresh a session extending its expiry."""
- # Get the current session
- s = await db.instance.update_session(
- session_key(token), datetime.now() + EXPIRES, {}
+ session_record = await db.instance.get_session(session_key(token))
+ if not session_record:
+ raise ValueError("Session not found or expired")
+ updated = await db.instance.update_session(
+ session_key(token),
+ ip=ip,
+ user_agent=user_agent,
+ renewed=datetime.now(timezone.utc),
)
-
- if not s:
+ if not updated:
raise ValueError("Session not found or expired")
-async def delete_credential(credential_uuid: UUID, auth: str):
+async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
"""Delete a specific credential for the current user."""
- s = await get_session(auth)
+ s = await get_session(auth, host=host)
await db.instance.delete_credential(credential_uuid, s.user_uuid)
diff --git a/passkey/bootstrap.py b/passkey/bootstrap.py
index d0c08a1..3ee1a0b 100644
--- a/passkey/bootstrap.py
+++ b/passkey/bootstrap.py
@@ -8,7 +8,7 @@ generating a reset link for initial admin setup.
import asyncio
import logging
-from datetime import datetime
+from datetime import datetime, timezone
import uuid7
@@ -41,11 +41,12 @@ ADMIN_RESET_MESSAGE = """\
async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str:
"""Create an admin reset link and log it with the provided message."""
token = passphrase.generate()
- await globals.db.instance.create_session(
+ expiry = authsession.reset_expires()
+ await globals.db.instance.create_reset_token(
user_uuid=user_uuid,
key=tokens.reset_key(token),
- expires=authsession.expires(),
- info={"type": session_type},
+ expiry=expiry,
+ token_type=session_type,
)
reset_link = hostutil.reset_link_url(token)
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
@@ -90,7 +91,7 @@ async def bootstrap_system(
uuid=uuid7.create(),
display_name=user_name or "Admin",
role_uuid=role.uuid,
- created_at=datetime.now(),
+ created_at=datetime.now(timezone.utc),
visits=0,
)
await globals.db.instance.create_user(user)
diff --git a/passkey/config.py b/passkey/config.py
new file mode 100644
index 0000000..7859330
--- /dev/null
+++ b/passkey/config.py
@@ -0,0 +1,7 @@
+from datetime import timedelta
+
+# Shared configuration constants for session management.
+SESSION_LIFETIME = timedelta(hours=24)
+
+# Lifetime for reset links created by admins
+RESET_LIFETIME = timedelta(days=14)
diff --git a/passkey/db/__init__.py b/passkey/db/__init__.py
index 358f3cb..8e95bbb 100644
--- a/passkey/db/__init__.py
+++ b/passkey/db/__init__.py
@@ -63,9 +63,27 @@ class Credential:
class Session:
key: bytes
user_uuid: UUID
- expires: datetime
- info: dict
- credential_uuid: UUID | None = None
+ credential_uuid: UUID
+ host: str
+ ip: str
+ user_agent: str
+ renewed: datetime
+
+ def metadata(self) -> dict:
+ """Return session metadata for backwards compatibility."""
+ return {
+ "ip": self.ip,
+ "user_agent": self.user_agent,
+ "renewed": self.renewed.isoformat(),
+ }
+
+
+@dataclass
+class ResetToken:
+ key: bytes
+ user_uuid: UUID
+ expiry: datetime
+ token_type: str
@dataclass
@@ -146,9 +164,11 @@ class DatabaseInterface(ABC):
self,
user_uuid: UUID,
key: bytes,
- expires: datetime,
- info: dict,
- credential_uuid: UUID | None = None,
+ credential_uuid: UUID,
+ host: str,
+ ip: str,
+ user_agent: str,
+ renewed: datetime,
) -> None:
"""Create a new session."""
@@ -162,14 +182,50 @@ class DatabaseInterface(ABC):
@abstractmethod
async def update_session(
- self, key: bytes, expires: datetime, info: dict
+ self,
+ key: bytes,
+ *,
+ ip: str,
+ user_agent: str,
+ renewed: datetime,
) -> Session | None:
- """Update session expiry and info."""
+ """Update session metadata and touch renewed timestamp."""
+
+ @abstractmethod
+ async def set_session_host(self, key: bytes, host: str) -> None:
+ """Bind a session to a specific host if not already set."""
+
+ @abstractmethod
+ async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
+ """Return all sessions for a user (including other hosts)."""
@abstractmethod
async def cleanup(self) -> None:
"""Called periodically to clean up expired records."""
+ @abstractmethod
+ async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
+ """Delete all sessions belonging to the provided user."""
+
+ # Reset token operations
+ @abstractmethod
+ async def create_reset_token(
+ self,
+ user_uuid: UUID,
+ key: bytes,
+ expiry: datetime,
+ token_type: str,
+ ) -> None:
+ """Create a reset token for a user."""
+
+ @abstractmethod
+ async def get_reset_token(self, key: bytes) -> ResetToken | None:
+ """Retrieve a reset token by key."""
+
+ @abstractmethod
+ async def delete_reset_token(self, key: bytes) -> None:
+ """Delete a reset token by key."""
+
# Organization operations
@abstractmethod
async def create_organization(self, org: Org) -> None:
@@ -315,36 +371,41 @@ class DatabaseInterface(ABC):
"""Create a new user and their first credential in a transaction."""
@abstractmethod
- async def get_session_context(self, session_key: bytes) -> SessionContext | None:
+ async def get_session_context(
+ self, session_key: bytes, host: str | None = None
+ ) -> SessionContext | None:
"""Get complete session context including user, organization, role, and permissions."""
- # Combined atomic operations
- @abstractmethod
- async def create_credential_session(
- self,
- user_uuid: UUID,
- credential: Credential,
- reset_key: bytes | None,
- session_key: bytes,
- session_expires: datetime,
- session_info: dict,
- display_name: str | None = None,
- ) -> None:
- """Atomically add a credential and create a session.
+ # Combined atomic operations
+ @abstractmethod
+ async def create_credential_session(
+ self,
+ user_uuid: UUID,
+ credential: Credential,
+ reset_key: bytes | None,
+ session_key: bytes,
+ *,
+ display_name: str | None = None,
+ host: str | None = None,
+ ip: str | None = None,
+ user_agent: str | None = None,
+ ) -> None:
+ """Atomically add a credential and create a session.
- Steps (single transaction):
- 1. Insert credential
- 2. Optionally delete old session (e.g. reset token) if provided
- 3. Optionally update user's display name
- 4. Insert new session referencing the credential
- 5. Update user's last_seen and increment visits (treat as a login)
- """
+ Steps (single transaction):
+ 1. Insert credential
+ 2. Optionally delete old reset token if provided
+ 3. Optionally update user's display name
+ 4. Insert new session referencing the credential
+ 5. Update user's last_seen and increment visits (treat as a login)
+ """
__all__ = [
"User",
"Credential",
"Session",
+ "ResetToken",
"SessionContext",
"Org",
"Role",
diff --git a/passkey/db/sql.py b/passkey/db/sql.py
index 6856a92..57ef29b 100644
--- a/passkey/db/sql.py
+++ b/passkey/db/sql.py
@@ -6,7 +6,7 @@ for managing users and credentials in a WebAuthn authentication system.
"""
from contextlib import asynccontextmanager
-from datetime import datetime
+from datetime import datetime, timezone
from uuid import UUID
from sqlalchemy import (
@@ -19,18 +19,21 @@ from sqlalchemy import (
event,
insert,
select,
+ text,
update,
)
-from sqlalchemy.dialects.sqlite import BLOB, JSON
+from sqlalchemy.dialects.sqlite import BLOB
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+from ..config import SESSION_LIFETIME
from ..globals import db
from . import (
Credential,
DatabaseInterface,
Org,
Permission,
+ ResetToken,
Role,
Session,
SessionContext,
@@ -40,6 +43,14 @@ from . import (
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite"
+def _normalize_dt(value: datetime | None) -> datetime | None:
+ if value is None:
+ return None
+ if value.tzinfo is None:
+ return value.replace(tzinfo=timezone.utc)
+ return value.astimezone(timezone.utc)
+
+
async def init(*args, **kwargs):
db.instance = DB()
await db.instance.init_db()
@@ -98,8 +109,12 @@ class UserModel(Base):
role_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
)
- created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
- last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+ )
+ last_seen: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
def as_dataclass(self) -> User:
@@ -107,8 +122,8 @@ class UserModel(Base):
uuid=UUID(bytes=self.uuid),
display_name=self.display_name,
role_uuid=UUID(bytes=self.role_uuid),
- created_at=self.created_at,
- last_seen=self.last_seen,
+ created_at=_normalize_dt(self.created_at) or self.created_at,
+ last_seen=_normalize_dt(self.last_seen) or self.last_seen,
visits=self.visits,
)
@@ -118,7 +133,7 @@ class UserModel(Base):
uuid=user.uuid.bytes,
display_name=user.display_name,
role_uuid=user.role_uuid.bytes,
- created_at=user.created_at or datetime.now(),
+ created_at=user.created_at or datetime.now(timezone.utc),
last_seen=user.last_seen,
visits=user.visits,
)
@@ -137,9 +152,29 @@ class CredentialModel(Base):
aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False)
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
- created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
- last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
- last_verified: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+ )
+ # Columns declared timezone-aware going forward; legacy rows may still be naive in storage
+ last_used: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ last_verified: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+
+ def as_dataclass(self): # type: ignore[override]
+ return Credential(
+ uuid=UUID(bytes=self.uuid),
+ credential_id=self.credential_id,
+ user_uuid=UUID(bytes=self.user_uuid),
+ aaguid=UUID(bytes=self.aaguid),
+ public_key=self.public_key,
+ sign_count=self.sign_count,
+ created_at=_normalize_dt(self.created_at) or self.created_at,
+ last_used=_normalize_dt(self.last_used) or self.last_used,
+ last_verified=_normalize_dt(self.last_verified) or self.last_verified,
+ )
class SessionModel(Base):
@@ -147,23 +182,31 @@ class SessionModel(Base):
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
user_uuid: Mapped[bytes] = mapped_column(
- LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE")
+ LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
)
- credential_uuid: Mapped[bytes | None] = mapped_column(
- LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE")
+ credential_uuid: Mapped[bytes] = mapped_column(
+ LargeBinary(16),
+ ForeignKey("credentials.uuid", ondelete="CASCADE"),
+ nullable=False,
+ )
+ host: Mapped[str] = mapped_column(String, nullable=False)
+ ip: Mapped[str] = mapped_column(String(64), nullable=False)
+ user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
+ renewed: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ nullable=False,
)
- expires: Mapped[datetime] = mapped_column(DateTime, nullable=False)
- info: Mapped[dict] = mapped_column(JSON, default=dict)
def as_dataclass(self):
return Session(
key=self.key,
user_uuid=UUID(bytes=self.user_uuid),
- credential_uuid=(
- UUID(bytes=self.credential_uuid) if self.credential_uuid else None
- ),
- expires=self.expires,
- info=self.info,
+ credential_uuid=UUID(bytes=self.credential_uuid),
+ host=self.host,
+ ip=self.ip,
+ user_agent=self.user_agent,
+ renewed=_normalize_dt(self.renewed) or self.renewed,
)
@staticmethod
@@ -171,9 +214,30 @@ class SessionModel(Base):
return SessionModel(
key=session.key,
user_uuid=session.user_uuid.bytes,
- credential_uuid=session.credential_uuid and session.credential_uuid.bytes,
- expires=session.expires,
- info=session.info,
+ credential_uuid=session.credential_uuid.bytes,
+ host=session.host,
+ ip=session.ip,
+ user_agent=session.user_agent,
+ renewed=session.renewed,
+ )
+
+
+class ResetTokenModel(Base):
+ __tablename__ = "reset_tokens"
+
+ key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
+ user_uuid: Mapped[bytes] = mapped_column(
+ LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
+ )
+ token_type: Mapped[str] = mapped_column(String, nullable=False)
+ expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+
+ def as_dataclass(self) -> ResetToken:
+ return ResetToken(
+ key=self.key,
+ user_uuid=UUID(bytes=self.user_uuid),
+ token_type=self.token_type,
+ expiry=_normalize_dt(self.expiry) or self.expiry,
)
@@ -257,6 +321,58 @@ class DB(DatabaseInterface):
"""Initialize database tables."""
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+ result = await conn.execute(text("PRAGMA table_info('sessions')"))
+ columns = {row[1] for row in result}
+ expected = {
+ "key",
+ "user_uuid",
+ "credential_uuid",
+ "host",
+ "ip",
+ "user_agent",
+ "renewed",
+ }
+ needs_recreate = False
+ if columns and columns != expected:
+ await conn.execute(text("DROP TABLE sessions"))
+ needs_recreate = True
+ result = await conn.execute(text("PRAGMA table_info('reset_tokens')"))
+ if not list(result):
+ needs_recreate = True
+ if needs_recreate:
+ await conn.run_sync(Base.metadata.create_all)
+ # Run one-time migration to add UTC tzinfo to any naive datetimes
+ await self._migrate_naive_datetimes()
+
+ async def _migrate_naive_datetimes(self) -> None:
+ """Attach UTC tzinfo to any legacy naive datetime rows.
+
+ SQLite stores datetimes as text; older rows may have been inserted naive.
+ We treat naive timestamps as already UTC and rewrite them in ISO8601 with Z.
+ """
+ # Helper SQL fragment for detecting naive (no timezone offset) for ISO strings
+ # We only update rows whose textual representation lacks a 'Z' or '+' sign.
+ async with self.session() as session:
+ # Users
+ for model, fields in [
+ (UserModel, ["created_at", "last_seen"]),
+ (CredentialModel, ["created_at", "last_used", "last_verified"]),
+ (SessionModel, ["renewed"]),
+ (ResetTokenModel, ["expiry"]),
+ ]:
+ stmt = select(model)
+ result = await session.execute(stmt)
+ rows = result.scalars().all()
+ dirty = False
+ for row in rows:
+ for fname in fields:
+ value = getattr(row, fname, None)
+ if isinstance(value, datetime) and value.tzinfo is None:
+ setattr(row, fname, value.replace(tzinfo=timezone.utc))
+ dirty = True
+ if dirty:
+ # SQLAlchemy autoflush/commit in context manager will persist
+ pass
async def get_user_by_uuid(self, user_uuid: UUID) -> User:
async with self.session() as session:
@@ -409,9 +525,11 @@ class DB(DatabaseInterface):
credential: Credential,
reset_key: bytes | None,
session_key: bytes,
- session_expires: datetime,
- session_info: dict,
+ *,
display_name: str | None = None,
+ host: str | None = None,
+ ip: str | None = None,
+ user_agent: str | None = None,
) -> None:
"""Atomic credential + (optional old session delete) + (optional rename) + new session."""
async with self.session() as session:
@@ -434,10 +552,10 @@ class DB(DatabaseInterface):
last_verified=credential.last_verified,
)
)
- # Delete old session if provided
+ # Delete old reset token if provided
if reset_key:
await session.execute(
- delete(SessionModel).where(SessionModel.key == reset_key)
+ delete(ResetTokenModel).where(ResetTokenModel.key == reset_key)
)
# Optional rename
if display_name:
@@ -452,8 +570,9 @@ class DB(DatabaseInterface):
key=session_key,
user_uuid=user_uuid.bytes,
credential_uuid=credential.uuid.bytes,
- expires=session_expires,
- info=session_info,
+ host=host,
+ ip=ip,
+ user_agent=user_agent,
)
)
# Login side-effects: update user analytics (last_seen + visits increment)
@@ -476,17 +595,21 @@ class DB(DatabaseInterface):
self,
user_uuid: UUID,
key: bytes,
- expires: datetime,
- info: dict,
- credential_uuid: UUID | None = None,
+ credential_uuid: UUID,
+ host: str,
+ ip: str,
+ user_agent: str,
+ renewed: datetime,
) -> None:
async with self.session() as session:
session_model = SessionModel(
key=key,
user_uuid=user_uuid.bytes,
- credential_uuid=credential_uuid.bytes if credential_uuid else None,
- expires=expires,
- info=info,
+ credential_uuid=credential_uuid.bytes,
+ host=host,
+ ip=ip,
+ user_agent=user_agent,
+ renewed=renewed,
)
session.add(session_model)
@@ -497,29 +620,88 @@ class DB(DatabaseInterface):
session_model = result.scalar_one_or_none()
if session_model:
- return Session(
- key=session_model.key,
- user_uuid=UUID(bytes=session_model.user_uuid),
- credential_uuid=UUID(bytes=session_model.credential_uuid)
- if session_model.credential_uuid
- else None,
- expires=session_model.expires,
- info=session_model.info or {},
- )
+ return session_model.as_dataclass()
return None
async def delete_session(self, key: bytes) -> None:
async with self.session() as session:
await session.execute(delete(SessionModel).where(SessionModel.key == key))
- async def update_session(self, key: bytes, expires: datetime, info: dict) -> None:
+ async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
async with self.session() as session:
await session.execute(
- update(SessionModel)
- .where(SessionModel.key == key)
- .values(expires=expires, info=info)
+ delete(SessionModel).where(SessionModel.user_uuid == user_uuid.bytes)
)
+ async def create_reset_token(
+ self,
+ user_uuid: UUID,
+ key: bytes,
+ expiry: datetime,
+ token_type: str,
+ ) -> None:
+ async with self.session() as session:
+ model = ResetTokenModel(
+ key=key,
+ user_uuid=user_uuid.bytes,
+ token_type=token_type,
+ expiry=expiry,
+ )
+ session.add(model)
+
+ async def get_reset_token(self, key: bytes) -> ResetToken | None:
+ async with self.session() as session:
+ stmt = select(ResetTokenModel).where(ResetTokenModel.key == key)
+ result = await session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return model.as_dataclass() if model else None
+
+ async def delete_reset_token(self, key: bytes) -> None:
+ async with self.session() as session:
+ await session.execute(
+ delete(ResetTokenModel).where(ResetTokenModel.key == key)
+ )
+
+ async def update_session(
+ self,
+ key: bytes,
+ *,
+ ip: str,
+ user_agent: str,
+ renewed: datetime,
+ ) -> Session | None:
+ async with self.session() as session:
+ model = await session.get(SessionModel, key)
+ if not model:
+ return None
+ model.ip = ip
+ model.user_agent = user_agent
+ model.renewed = renewed
+ await session.flush()
+ return model.as_dataclass()
+
+ async def set_session_host(self, key: bytes, host: str) -> None:
+ async with self.session() as session:
+ model = await session.get(SessionModel, key)
+ if model and model.host is None:
+ model.host = host
+ await session.flush()
+
+ async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
+ async with self.session() as session:
+ stmt = (
+ select(SessionModel)
+ .where(SessionModel.user_uuid == user_uuid.bytes)
+ .order_by(SessionModel.renewed.desc())
+ )
+ result = await session.execute(stmt)
+ session_models = [
+ model
+ for model in result.scalars().all()
+ if model.key.startswith(b"sess")
+ ]
+ return [model.as_dataclass() for model in session_models]
+
# Organization operations
async def create_organization(self, org: Org) -> None:
async with self.session() as session:
@@ -1115,11 +1297,18 @@ class DB(DatabaseInterface):
async def cleanup(self) -> None:
async with self.session() as session:
- current_time = datetime.now()
- stmt = delete(SessionModel).where(SessionModel.expires < current_time)
- await session.execute(stmt)
+ current_time = datetime.now(timezone.utc)
+ session_threshold = current_time - SESSION_LIFETIME
+ await session.execute(
+ delete(SessionModel).where(SessionModel.renewed < session_threshold)
+ )
+ await session.execute(
+ delete(ResetTokenModel).where(ResetTokenModel.expiry < current_time)
+ )
- async def get_session_context(self, session_key: bytes) -> SessionContext | None:
+ async def get_session_context(
+ self, session_key: bytes, host: str | None = None
+ ) -> SessionContext | None:
"""Get complete session context including user, organization, role, and permissions.
Uses efficient JOINs to retrieve all related data in a single database query.
@@ -1156,15 +1345,18 @@ class DB(DatabaseInterface):
session_model, user_model, role_model, org_model, _ = first_row
# Create the session object
- session_obj = Session(
- key=session_model.key,
- user_uuid=UUID(bytes=session_model.user_uuid),
- credential_uuid=UUID(bytes=session_model.credential_uuid)
- if session_model.credential_uuid
- else None,
- expires=session_model.expires,
- info=session_model.info or {},
- )
+ if host is not None:
+ if session_model.host is None:
+ await session.execute(
+ update(SessionModel)
+ .where(SessionModel.key == session_key)
+ .values(host=host)
+ )
+ session_model.host = host
+ elif session_model.host != host:
+ return None
+
+ session_obj = session_model.as_dataclass()
# Create the user object
user_obj = user_model.as_dataclass()
diff --git a/passkey/fastapi/admin.py b/passkey/fastapi/admin.py
index 5d6817c..4bde091 100644
--- a/passkey/fastapi/admin.py
+++ b/passkey/fastapi/admin.py
@@ -1,12 +1,22 @@
import logging
+from datetime import timezone
from uuid import UUID, uuid4
from fastapi import Body, Cookie, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
-from ..authsession import expires
+from ..authsession import reset_expires
from ..globals import db
-from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens
+from ..util import (
+ frontend,
+ hostutil,
+ passphrase,
+ permutil,
+ querysafe,
+ tokens,
+ useragent,
+)
+from ..util.tokens import encode_session_key, session_key
from . import authz
app = FastAPI()
@@ -24,9 +34,14 @@ async def general_exception_handler(_request, exc: Exception):
@app.get("/")
-async def adminapp(auth=Cookie(None)):
+async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")):
try:
- await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
+ await authz.verify(
+ auth,
+ ["auth:admin", "auth:org:*"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
+ )
return FileResponse(frontend.file("admin/index.html"))
except HTTPException as e:
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
@@ -36,8 +51,13 @@ async def adminapp(auth=Cookie(None)):
@app.get("/orgs")
-async def admin_list_orgs(auth=Cookie(None)):
- ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
+async def admin_list_orgs(request: Request, auth=Cookie(None, alias="__Host-auth")):
+ ctx = await authz.verify(
+ auth,
+ ["auth:admin", "auth:org:*"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
+ )
orgs = await db.instance.list_organizations()
if "auth:admin" not in ctx.role.permissions:
orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions]
@@ -73,8 +93,12 @@ async def admin_list_orgs(auth=Cookie(None)):
@app.post("/orgs")
-async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
- await authz.verify(auth, ["auth:admin"])
+async def admin_create_org(
+ request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth")
+):
+ await authz.verify(
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
+ )
from ..db import Org as OrgDC # local import to avoid cycles
from ..db import Role as RoleDC # local import to avoid cycles
@@ -99,10 +123,16 @@ async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
@app.put("/orgs/{org_uuid}")
async def admin_update_org(
- org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
+ org_uuid: UUID,
+ request: Request,
+ payload: dict = Body(...),
+ auth=Cookie(None, alias="__Host-auth"),
):
ctx = await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
from ..db import Org as OrgDC # local import to avoid cycles
@@ -129,9 +159,14 @@ async def admin_update_org(
@app.delete("/orgs/{org_uuid}")
-async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
+async def admin_delete_org(
+ org_uuid: UUID, request: Request, auth=Cookie(None, alias="__Host-auth")
+):
ctx = await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
if ctx.org.uuid == org_uuid:
raise ValueError("Cannot delete the organization you belong to")
@@ -156,18 +191,28 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
@app.post("/orgs/{org_uuid}/permission")
async def admin_add_org_permission(
- org_uuid: UUID, permission_id: str, auth=Cookie(None)
+ org_uuid: UUID,
+ permission_id: str,
+ request: Request,
+ auth=Cookie(None, alias="__Host-auth"),
):
- await authz.verify(auth, ["auth:admin"])
+ await authz.verify(
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
+ )
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/permission")
async def admin_remove_org_permission(
- org_uuid: UUID, permission_id: str, auth=Cookie(None)
+ org_uuid: UUID,
+ permission_id: str,
+ request: Request,
+ auth=Cookie(None, alias="__Host-auth"),
):
- await authz.verify(auth, ["auth:admin"])
+ await authz.verify(
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
+ )
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
return {"status": "ok"}
@@ -177,10 +222,16 @@ async def admin_remove_org_permission(
@app.post("/orgs/{org_uuid}/roles")
async def admin_create_role(
- org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
+ org_uuid: UUID,
+ request: Request,
+ payload: dict = Body(...),
+ auth=Cookie(None, alias="__Host-auth"),
):
await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
from ..db import Role as RoleDC
@@ -205,11 +256,18 @@ async def admin_create_role(
@app.put("/orgs/{org_uuid}/roles/{role_uuid}")
async def admin_update_role(
- org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
+ org_uuid: UUID,
+ role_uuid: UUID,
+ request: Request,
+ payload: dict = Body(...),
+ auth=Cookie(None, alias="__Host-auth"),
):
# Verify caller is global admin or admin of provided org
ctx = await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
role = await db.instance.get_role(role_uuid)
if role.org_uuid != org_uuid:
@@ -247,9 +305,17 @@ async def admin_update_role(
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
-async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
+async def admin_delete_role(
+ org_uuid: UUID,
+ role_uuid: UUID,
+ request: Request,
+ auth=Cookie(None, alias="__Host-auth"),
+):
ctx = await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
role = await db.instance.get_role(role_uuid)
if role.org_uuid != org_uuid:
@@ -268,10 +334,16 @@ async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)):
@app.post("/orgs/{org_uuid}/users")
async def admin_create_user(
- org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
+ org_uuid: UUID,
+ request: Request,
+ payload: dict = Body(...),
+ auth=Cookie(None, alias="__Host-auth"),
):
await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
display_name = payload.get("display_name")
role_name = payload.get("role")
@@ -297,10 +369,17 @@ async def admin_create_user(
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
async def admin_update_user_role(
- org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
+ org_uuid: UUID,
+ user_uuid: UUID,
+ request: Request,
+ payload: dict = Body(...),
+ auth=Cookie(None, alias="__Host-auth"),
):
ctx = await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
new_role = payload.get("role")
if not new_role:
@@ -334,7 +413,10 @@ async def admin_update_user_role(
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
async def admin_create_user_registration_link(
- org_uuid: UUID, user_uuid: UUID, request: Request, auth=Cookie(None)
+ org_uuid: UUID,
+ user_uuid: UUID,
+ request: Request,
+ auth=Cookie(None, alias="__Host-auth"),
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
@@ -343,7 +425,10 @@ async def admin_create_user_registration_link(
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
@@ -351,20 +436,33 @@ async def admin_create_user_registration_link(
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
token = passphrase.generate()
- await db.instance.create_session(
+ expiry = reset_expires()
+ await db.instance.create_reset_token(
user_uuid=user_uuid,
key=tokens.reset_key(token),
- expires=expires(),
- info={"type": "device addition", "created_by_admin": True},
+ expiry=expiry,
+ token_type="device addition",
)
url = hostutil.reset_link_url(
token, request.url.scheme, request.headers.get("host")
)
- return {"url": url, "expires": expires().isoformat()}
+ return {
+ "url": url,
+ "expires": (
+ expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+ if expiry.tzinfo
+ else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
+ ),
+ }
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
-async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(None)):
+async def admin_get_user_detail(
+ org_uuid: UUID,
+ user_uuid: UUID,
+ request: Request,
+ auth=Cookie(None, alias="__Host-auth"),
+):
try:
user_org, role_name = await db.instance.get_user_organization(user_uuid)
except ValueError:
@@ -372,7 +470,10 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
@@ -394,9 +495,41 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
{
"credential_uuid": str(c.uuid),
"aaguid": aaguid_str,
- "created_at": c.created_at.isoformat(),
- "last_used": c.last_used.isoformat() if c.last_used else None,
- "last_verified": c.last_verified.isoformat()
+ "created_at": (
+ c.created_at.astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if c.created_at.tzinfo
+ else c.created_at.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ ),
+ "last_used": (
+ c.last_used.astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if c.last_used and c.last_used.tzinfo
+ else (
+ c.last_used.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if c.last_used
+ else None
+ )
+ ),
+ "last_verified": (
+ c.last_verified.astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if c.last_verified and c.last_verified.tzinfo
+ else (
+ c.last_verified.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if c.last_verified
+ else None
+ )
+ )
if c.last_verified
else None,
"sign_count": c.sign_count,
@@ -405,21 +538,77 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non
from .. import aaguid as aaguid_mod
aaguid_info = aaguid_mod.filter(aaguids)
+
+ # Get sessions for the user
+ normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
+ session_records = await db.instance.list_sessions_for_user(user_uuid)
+ current_session_key = session_key(auth)
+ sessions_payload: list[dict] = []
+ for entry in session_records:
+ sessions_payload.append(
+ {
+ "id": encode_session_key(entry.key),
+ "host": entry.host,
+ "ip": entry.ip,
+ "user_agent": useragent.compact_user_agent(entry.user_agent),
+ "last_renewed": (
+ entry.renewed.astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if entry.renewed.tzinfo
+ else entry.renewed.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ ),
+ "is_current": entry.key == current_session_key,
+ "is_current_host": bool(
+ normalized_request_host
+ and entry.host
+ and entry.host == normalized_request_host
+ ),
+ }
+ )
+
return {
"display_name": user.display_name,
"org": {"display_name": user_org.display_name},
"role": role_name,
"visits": user.visits,
- "created_at": user.created_at.isoformat() if user.created_at else None,
- "last_seen": user.last_seen.isoformat() if user.last_seen else None,
+ "created_at": (
+ user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+ if user.created_at and user.created_at.tzinfo
+ else (
+ user.created_at.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if user.created_at
+ else None
+ )
+ ),
+ "last_seen": (
+ user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+ if user.last_seen and user.last_seen.tzinfo
+ else (
+ user.last_seen.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if user.last_seen
+ else None
+ )
+ ),
"credentials": creds,
"aaguid_info": aaguid_info,
+ "sessions": sessions_payload,
}
@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
async def admin_update_user_display_name(
- org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
+ org_uuid: UUID,
+ user_uuid: UUID,
+ request: Request,
+ payload: dict = Body(...),
+ auth=Cookie(None, alias="__Host-auth"),
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
@@ -428,7 +617,10 @@ async def admin_update_user_display_name(
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
@@ -446,7 +638,11 @@ async def admin_update_user_display_name(
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential(
- org_uuid: UUID, user_uuid: UUID, credential_uuid: UUID, auth=Cookie(None)
+ org_uuid: UUID,
+ user_uuid: UUID,
+ credential_uuid: UUID,
+ request: Request,
+ auth=Cookie(None, alias="__Host-auth"),
):
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
@@ -455,7 +651,10 @@ async def admin_delete_user_credential(
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
- auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any
+ auth,
+ ["auth:admin", f"auth:org:{org_uuid}"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
)
if (
"auth:admin" not in ctx.role.permissions
@@ -470,8 +669,15 @@ async def admin_delete_user_credential(
@app.get("/permissions")
-async def admin_list_permissions(auth=Cookie(None)):
- ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any)
+async def admin_list_permissions(
+ request: Request, auth=Cookie(None, alias="__Host-auth")
+):
+ ctx = await authz.verify(
+ auth,
+ ["auth:admin", "auth:org:*"],
+ match=permutil.has_any,
+ host=request.headers.get("host"),
+ )
perms = await db.instance.list_permissions()
# Global admins see all permissions
@@ -485,8 +691,14 @@ async def admin_list_permissions(auth=Cookie(None)):
@app.post("/permissions")
-async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
- await authz.verify(auth, ["auth:admin"])
+async def admin_create_permission(
+ request: Request,
+ payload: dict = Body(...),
+ auth=Cookie(None, alias="__Host-auth"),
+):
+ await authz.verify(
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
+ )
from ..db import Permission as PermDC
perm_id = payload.get("id")
@@ -500,9 +712,14 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
@app.put("/permission")
async def admin_update_permission(
- permission_id: str, display_name: str, auth=Cookie(None)
+ permission_id: str,
+ display_name: str,
+ request: Request,
+ auth=Cookie(None, alias="__Host-auth"),
):
- await authz.verify(auth, ["auth:admin"])
+ await authz.verify(
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
+ )
from ..db import Permission as PermDC
if not display_name:
@@ -515,8 +732,14 @@ async def admin_update_permission(
@app.post("/permission/rename")
-async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
- await authz.verify(auth, ["auth:admin"])
+async def admin_rename_permission(
+ request: Request,
+ payload: dict = Body(...),
+ auth=Cookie(None, alias="__Host-auth"),
+):
+ await authz.verify(
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
+ )
old_id = payload.get("old_id")
new_id = payload.get("new_id")
display_name = payload.get("display_name")
@@ -540,8 +763,14 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
@app.delete("/permission")
-async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
- await authz.verify(auth, ["auth:admin"])
+async def admin_delete_permission(
+ permission_id: str,
+ request: Request,
+ auth=Cookie(None, alias="__Host-auth"),
+):
+ await authz.verify(
+ auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
+ )
querysafe.assert_safe(permission_id, field="permission_id")
# Sanity check: prevent deleting critical permissions
diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py
index b0af8b0..d1d3cbd 100644
--- a/passkey/fastapi/api.py
+++ b/passkey/fastapi/api.py
@@ -1,6 +1,6 @@
import logging
from contextlib import suppress
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import (
@@ -16,7 +16,7 @@ from fastapi import (
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
-from passkey.util import frontend
+from passkey.util import frontend, useragent
from .. import aaguid
from ..authsession import (
@@ -26,11 +26,12 @@ from ..authsession import (
get_reset,
get_session,
refresh_session_token,
+ session_expiry,
)
from ..globals import db
from ..globals import passkey as global_passkey
from ..util import hostutil, passphrase, permutil, tokens
-from ..util.tokens import session_key
+from ..util.tokens import decode_session_key, encode_session_key, session_key
from . import authz, session
bearer_auth = HTTPBearer(auto_error=True)
@@ -56,7 +57,10 @@ async def general_exception_handler(_request: Request, exc: Exception):
@app.post("/validate")
async def validate_token(
- response: Response, perm: list[str] = Query([]), auth=Cookie(None)
+ request: Request,
+ response: Response,
+ perm: list[str] = Query([]),
+ auth=Cookie(None, alias="__Host-auth"),
):
"""Validate the current session and extend its expiry.
@@ -64,13 +68,18 @@ async def validate_token(
renewed max-age. This keeps active users logged in without needing a separate
refresh endpoint.
"""
- ctx = await authz.verify(auth, perm)
+ ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
renewed = False
if auth:
- consumed = EXPIRES - (ctx.session.expires - datetime.now())
+ current_expiry = session_expiry(ctx.session)
+ consumed = EXPIRES - (current_expiry - datetime.now())
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
try:
- await refresh_session_token(auth)
+ await refresh_session_token(
+ auth,
+ ip=request.client.host if request.client else "",
+ user_agent=request.headers.get("user-agent") or "",
+ )
session.set_session_cookie(response, auth)
renewed = True
except ValueError:
@@ -84,7 +93,11 @@ async def validate_token(
@app.get("/forward")
-async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)):
+async def forward_authentication(
+ request: Request,
+ perm: list[str] = Query([]),
+ auth=Cookie(None, alias="__Host-auth"),
+):
"""Forward auth validation for Caddy/Nginx.
Query Params:
@@ -94,7 +107,7 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
"""
try:
- ctx = await authz.verify(auth, perm)
+ ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
role_permissions = set(ctx.role.permissions or [])
if ctx.permissions:
role_permissions.update(permission.id for permission in ctx.permissions)
@@ -107,7 +120,17 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
"Remote-Org-Name": ctx.org.display_name,
"Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name,
- "Remote-Session-Expires": ctx.session.expires.isoformat(),
+ "Remote-Session-Expires": (
+ session_expiry(ctx.session)
+ .astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if session_expiry(ctx.session).tzinfo
+ else session_expiry(ctx.session)
+ .replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ ),
"Remote-Credential": str(ctx.session.credential_uuid),
}
return Response(status_code=204, headers=remote_headers)
@@ -129,34 +152,43 @@ async def get_settings():
@app.post("/user-info")
-async def api_user_info(reset: str | None = None, auth=Cookie(None)):
+async def api_user_info(
+ request: Request, reset: str | None = None, auth=Cookie(None, alias="__Host-auth")
+):
authenticated = False
+ session_record = None
+ reset_token = None
try:
if reset:
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
- s = await get_reset(reset)
+ reset_token = await get_reset(reset)
+ target_user_uuid = reset_token.user_uuid
else:
if auth is None:
raise ValueError("Authentication Required")
- s = await get_session(auth)
+ session_record = await get_session(auth, host=request.headers.get("host"))
authenticated = True
+ target_user_uuid = session_record.user_uuid
except ValueError as e:
raise HTTPException(401, str(e))
- u = await db.instance.get_user_by_uuid(s.user_uuid)
+ u = await db.instance.get_user_by_uuid(target_user_uuid)
- if not authenticated: # minimal response for reset tokens
+ if not authenticated and reset_token: # minimal response for reset tokens
return {
"authenticated": False,
- "session_type": s.info.get("type"),
+ "session_type": reset_token.token_type,
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
}
- assert authenticated and auth is not None
+ assert auth is not None
+ assert session_record is not None
- ctx = await permutil.session_context(auth)
- credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
+ ctx = await permutil.session_context(auth, request.headers.get("host"))
+ credential_ids = await db.instance.get_credentials_by_user_uuid(
+ session_record.user_uuid
+ )
credentials: list[dict] = []
user_aaguids: set[str] = set()
for cred_id in credential_ids:
@@ -170,13 +202,45 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
{
"credential_uuid": str(c.uuid),
"aaguid": aaguid_str,
- "created_at": c.created_at.isoformat(),
- "last_used": c.last_used.isoformat() if c.last_used else None,
- "last_verified": c.last_verified.isoformat()
+ "created_at": (
+ c.created_at.astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if c.created_at.tzinfo
+ else c.created_at.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ ),
+ "last_used": (
+ c.last_used.astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if c.last_used and c.last_used.tzinfo
+ else (
+ c.last_used.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if c.last_used
+ else None
+ )
+ ),
+ "last_verified": (
+ c.last_verified.astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if c.last_verified and c.last_verified.tzinfo
+ else (
+ c.last_verified.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if c.last_verified
+ else None
+ )
+ )
if c.last_verified
else None,
"sign_count": c.sign_count,
- "is_current_session": s.credential_uuid == c.uuid,
+ "is_current_session": session_record.credential_uuid == c.uuid,
}
)
credentials.sort(key=lambda cred: cred["created_at"])
@@ -204,14 +268,62 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
p.startswith("auth:org:") for p in (role_info["permissions"] or [])
)
+ normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
+ session_records = await db.instance.list_sessions_for_user(session_record.user_uuid)
+ current_session_key = session_key(auth)
+ sessions_payload: list[dict] = []
+ for entry in session_records:
+ sessions_payload.append(
+ {
+ "id": encode_session_key(entry.key),
+ "host": entry.host,
+ "ip": entry.ip,
+ "user_agent": useragent.compact_user_agent(entry.user_agent),
+ "last_renewed": (
+ entry.renewed.astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if entry.renewed.tzinfo
+ else entry.renewed.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ ),
+ "is_current": entry.key == current_session_key,
+ "is_current_host": bool(
+ normalized_request_host
+ and entry.host
+ and entry.host == normalized_request_host
+ ),
+ }
+ )
+
return {
"authenticated": True,
- "session_type": s.info.get("type"),
"user": {
"user_uuid": str(u.uuid),
"user_name": u.display_name,
- "created_at": u.created_at.isoformat() if u.created_at else None,
- "last_seen": u.last_seen.isoformat() if u.last_seen else None,
+ "created_at": (
+ u.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+ if u.created_at and u.created_at.tzinfo
+ else (
+ u.created_at.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if u.created_at
+ else None
+ )
+ ),
+ "last_seen": (
+ u.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+ if u.last_seen and u.last_seen.tzinfo
+ else (
+ u.last_seen.replace(tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ if u.last_seen
+ else None
+ )
+ ),
"visits": u.visits,
},
"org": org_info,
@@ -221,14 +333,17 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
"is_org_admin": is_org_admin,
"credentials": credentials,
"aaguid_info": aaguid_info,
+ "sessions": sessions_payload,
}
@app.put("/user/display-name")
-async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
+async def user_update_display_name(
+ request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth")
+):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
- s = await get_session(auth)
+ s = await get_session(auth, host=request.headers.get("host"))
new_name = (payload.get("display_name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
@@ -239,18 +354,76 @@ async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None))
@app.post("/logout")
-async def api_logout(response: Response, auth=Cookie(None)):
+async def api_logout(
+ request: Request, response: Response, auth=Cookie(None, alias="__Host-auth")
+):
if not auth:
return {"message": "Already logged out"}
+ try:
+ await get_session(auth, host=request.headers.get("host"))
+ except ValueError:
+ response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
+ return {"message": "Already logged out"}
with suppress(Exception):
await db.instance.delete_session(session_key(auth))
- response.delete_cookie("auth")
+ response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
return {"message": "Logged out successfully"}
+@app.post("/logout-all")
+async def api_logout_all(
+ request: Request, response: Response, auth=Cookie(None, alias="__Host-auth")
+):
+ if not auth:
+ return {"message": "Already logged out"}
+ try:
+ s = await get_session(auth, host=request.headers.get("host"))
+ except ValueError:
+ response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
+ raise HTTPException(status_code=401, detail="Session expired")
+ await db.instance.delete_sessions_for_user(s.user_uuid)
+ response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
+ return {"message": "Logged out from all hosts"}
+
+
+@app.delete("/session/{session_id}")
+async def api_delete_session(
+ request: Request,
+ response: Response,
+ session_id: str,
+ auth=Cookie(None, alias="__Host-auth"),
+):
+ if not auth:
+ raise HTTPException(status_code=401, detail="Authentication Required")
+ try:
+ current_session = await get_session(auth, host=request.headers.get("host"))
+ except ValueError as exc:
+ response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
+ raise HTTPException(status_code=401, detail="Session expired") from exc
+
+ try:
+ target_key = decode_session_key(session_id)
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=400, detail="Invalid session identifier"
+ ) from exc
+
+ target_session = await db.instance.get_session(target_key)
+ if not target_session or target_session.user_uuid != current_session.user_uuid:
+ raise HTTPException(status_code=404, detail="Session not found")
+
+ await db.instance.delete_session(target_key)
+ current_terminated = target_key == session_key(auth)
+ if current_terminated:
+ response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
+ return {"status": "ok", "current_session_terminated": current_terminated}
+
+
@app.post("/set-session")
-async def api_set_session(response: Response, auth=Depends(bearer_auth)):
- user = await get_session(auth.credentials)
+async def api_set_session(
+ request: Request, response: Response, auth=Depends(bearer_auth)
+):
+ user = await get_session(auth.credentials, host=request.headers.get("host"))
session.set_session_cookie(response, auth.credentials)
return {
"message": "Session cookie set successfully",
@@ -259,20 +432,23 @@ async def api_set_session(response: Response, auth=Depends(bearer_auth)):
@app.delete("/credential/{uuid}")
-async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
- await delete_credential(uuid, auth)
+async def api_delete_credential(
+ request: Request, uuid: UUID, auth: str = Cookie(None, alias="__Host-auth")
+):
+ await delete_credential(uuid, auth, host=request.headers.get("host"))
return {"message": "Credential deleted successfully"}
@app.post("/create-link")
-async def api_create_link(request: Request, auth=Cookie(None)):
- s = await get_session(auth)
+async def api_create_link(request: Request, auth=Cookie(None, alias="__Host-auth")):
+ s = await get_session(auth, host=request.headers.get("host"))
token = passphrase.generate()
- await db.instance.create_session(
+ expiry = expires()
+ await db.instance.create_reset_token(
user_uuid=s.user_uuid,
key=tokens.reset_key(token),
- expires=expires(),
- info=session.infodict(request, "device addition"),
+ expiry=expiry,
+ token_type="device addition",
)
url = hostutil.reset_link_url(
token, request.url.scheme, request.headers.get("host")
@@ -280,5 +456,9 @@ async def api_create_link(request: Request, auth=Cookie(None)):
return {
"message": "Registration link generated successfully",
"url": url,
- "expires": expires().isoformat(),
+ "expires": (
+ expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+ if expiry.tzinfo
+ else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
+ ),
}
diff --git a/passkey/fastapi/authz.py b/passkey/fastapi/authz.py
index bbdd83d..90920f4 100644
--- a/passkey/fastapi/authz.py
+++ b/passkey/fastapi/authz.py
@@ -7,7 +7,12 @@ from ..util import permutil
logger = logging.getLogger(__name__)
-async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
+async def verify(
+ auth: str | None,
+ perm: list[str],
+ match=permutil.has_all,
+ host: str | None = None,
+):
"""Validate session token and optional list of required permissions.
Returns the session context.
@@ -19,7 +24,7 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
if not auth:
raise HTTPException(status_code=401, detail="Authentication required")
- ctx = await permutil.session_context(auth)
+ ctx = await permutil.session_context(auth, host)
if not ctx:
raise HTTPException(status_code=401, detail="Session not found")
diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py
index 23069ae..e617055 100644
--- a/passkey/fastapi/mainapp.py
+++ b/passkey/fastapi/mainapp.py
@@ -2,7 +2,7 @@ import logging
import os
from contextlib import asynccontextmanager
-from fastapi import Cookie, FastAPI, HTTPException
+from fastapi import Cookie, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -70,8 +70,8 @@ async def admin_root_redirect():
@app.get("/admin/", include_in_schema=False)
-async def admin_root(auth=Cookie(None)):
- return await admin.adminapp(auth) # Delegate to handler of /auth/admin/
+async def admin_root(request: Request, auth=Cookie(None, alias="__Host-auth")):
+ return await admin.adminapp(request, auth) # Delegate to handler of /auth/admin/
@app.get("/{reset}")
diff --git a/passkey/fastapi/reset.py b/passkey/fastapi/reset.py
index 05bc675..8f71bf7 100644
--- a/passkey/fastapi/reset.py
+++ b/passkey/fastapi/reset.py
@@ -63,11 +63,12 @@ async def _resolve_targets(query: str | None):
async def _create_reset(user, role_name: str):
token = passphrase.generate()
- await _g.db.instance.create_session(
+ expiry = _authsession.reset_expires()
+ await _g.db.instance.create_reset_token(
user_uuid=user.uuid,
key=_tokens.reset_key(token),
- expires=_authsession.expires(),
- info={"type": "manual reset", "role": role_name},
+ expiry=expiry,
+ token_type="manual reset",
)
return hostutil.reset_link_url(token), token
diff --git a/passkey/fastapi/session.py b/passkey/fastapi/session.py
index 78948a7..290a440 100644
--- a/passkey/fastapi/session.py
+++ b/passkey/fastapi/session.py
@@ -12,22 +12,26 @@ from fastapi import Request, Response, WebSocket
from ..authsession import EXPIRES
+AUTH_COOKIE_NAME = "__Host-auth"
+
def infodict(request: Request | WebSocket, type: str) -> dict:
"""Extract client information from request."""
return {
- "ip": request.client.host if request.client else "",
- "user_agent": request.headers.get("user-agent", "")[:500],
- "type": type,
+ "ip": request.client.host if request.client else None,
+ "user_agent": request.headers.get("user-agent", "")[:500] or None,
+ "session_type": type,
}
def set_session_cookie(response: Response, token: str) -> None:
"""Set the session token as an HTTP-only cookie."""
response.set_cookie(
- key="auth",
+ key=AUTH_COOKIE_NAME,
value=token,
max_age=int(EXPIRES.total_seconds()),
httponly=True,
secure=True,
+ path="/",
+ samesite="lax",
)
diff --git a/passkey/fastapi/ws.py b/passkey/fastapi/ws.py
index 5a7dd0f..f9277d3 100644
--- a/passkey/fastapi/ws.py
+++ b/passkey/fastapi/ws.py
@@ -5,9 +5,9 @@ from uuid import UUID
from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
-from ..authsession import create_session, expires, get_reset, get_session
+from ..authsession import create_session, get_reset, get_session
from ..globals import db, passkey
-from ..util import passphrase
+from ..util import hostutil, passphrase
from ..util.tokens import create_token, session_key
from .session import infodict
@@ -56,7 +56,10 @@ async def register_chat(
@app.websocket("/register")
@websocket_error_handler
async def websocket_register_add(
- ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None)
+ ws: WebSocket,
+ reset: str | None = None,
+ name: str | None = None,
+ auth=Cookie(None, alias="__Host-auth"),
):
"""Register a new credential for an existing user.
@@ -65,6 +68,9 @@ async def websocket_register_add(
- Reset token supplied as ?reset=... (auth cookie ignored)
"""
origin = ws.headers["origin"]
+ host = hostutil.normalize_host(ws.headers.get("host"))
+ if host is None:
+ raise ValueError("Missing host header")
if reset is not None:
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
@@ -72,7 +78,7 @@ async def websocket_register_add(
else:
if not auth:
raise ValueError("Authentication Required")
- s = await get_session(auth)
+ s = await get_session(auth, host=host)
user_uuid = s.user_uuid
# Get user information and determine effective user_name for this registration
@@ -89,14 +95,16 @@ async def websocket_register_add(
# Create a new session and store everything in database
token = create_token()
+ metadata = infodict(ws, "authenticated")
await db.instance.create_credential_session( # type: ignore[attr-defined]
user_uuid=user_uuid,
credential=credential,
reset_key=(s.key if reset is not None else None),
session_key=session_key(token),
- session_expires=expires(),
- session_info=infodict(ws, "authenticated"),
display_name=user_name,
+ host=host,
+ ip=metadata.get("ip"),
+ user_agent=metadata.get("user_agent"),
)
auth = token
@@ -115,6 +123,9 @@ async def websocket_register_add(
@websocket_error_handler
async def websocket_authenticate(ws: WebSocket):
origin = ws.headers["origin"]
+ host = hostutil.normalize_host(ws.headers.get("host"))
+ if host is None:
+ raise ValueError("Missing host header")
options, challenge = passkey.instance.auth_generate_options()
await ws.send_json(options)
# Wait for the client to use his authenticator to authenticate
@@ -128,10 +139,13 @@ async def websocket_authenticate(ws: WebSocket):
# Create a session token for the authenticated user
assert stored_cred.uuid is not None
+ metadata = infodict(ws, "auth")
token = await create_session(
user_uuid=stored_cred.user_uuid,
- info=infodict(ws, "auth"),
credential_uuid=stored_cred.uuid,
+ host=host,
+ ip=metadata.get("ip") or "",
+ user_agent=metadata.get("user_agent") or "",
)
await ws.send_json(
diff --git a/passkey/sansio.py b/passkey/sansio.py
index 28904e6..9acb99a 100644
--- a/passkey/sansio.py
+++ b/passkey/sansio.py
@@ -8,7 +8,7 @@ This module provides a unified interface for WebAuthn operations including:
"""
import json
-from datetime import datetime
+from datetime import datetime, timezone
from urllib.parse import urlparse
from uuid import UUID
@@ -163,7 +163,7 @@ class Passkey:
aaguid=UUID(registration.aaguid),
public_key=registration.credential_public_key,
sign_count=registration.sign_count,
- created_at=datetime.now(),
+ created_at=datetime.now(timezone.utc),
)
### Authentication Methods ###
@@ -227,7 +227,7 @@ class Passkey:
credential_current_sign_count=stored_cred.sign_count,
)
stored_cred.sign_count = verification.new_sign_count
- now = datetime.now()
+ now = datetime.now(timezone.utc)
stored_cred.last_used = now
if verification.user_verified:
stored_cred.last_verified = now
diff --git a/passkey/util/hostutil.py b/passkey/util/hostutil.py
index 2493638..0f23746 100644
--- a/passkey/util/hostutil.py
+++ b/passkey/util/hostutil.py
@@ -2,7 +2,7 @@
import os
from functools import lru_cache
-from urllib.parse import urlparse
+from urllib.parse import urlparse, urlsplit
from ..globals import passkey as global_passkey
@@ -70,3 +70,17 @@ def reset_link_url(
def reload_config() -> None:
_load_config.cache_clear()
+
+
+def normalize_host(raw_host: str | None) -> str | None:
+ """Normalize a Host header or hostname by stripping port and lowercasing."""
+ if not raw_host:
+ return None
+ candidate = raw_host.strip()
+ if not candidate:
+ return None
+ # Ensure urlsplit can parse bare hosts (prepend //)
+ parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
+ host = parsed.hostname or parsed.path or ""
+ host = host.strip("[]") # Remove IPv6 brackets if present
+ return host.lower() if host else None
diff --git a/passkey/util/permutil.py b/passkey/util/permutil.py
index a6e3397..82a1a8c 100644
--- a/passkey/util/permutil.py
+++ b/passkey/util/permutil.py
@@ -4,6 +4,7 @@ from collections.abc import Sequence
from fnmatch import fnmatchcase
from ..globals import db
+from .hostutil import normalize_host
from .tokens import session_key
__all__ = ["has_any", "has_all", "session_context"]
@@ -24,5 +25,8 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
return all(_match(ctx.role.permissions, patterns)) if ctx else False
-async def session_context(auth: str | None):
- return await db.instance.get_session_context(session_key(auth)) if auth else None
+async def session_context(auth: str | None, host: str | None = None):
+ if not auth:
+ return None
+ normalized_host = normalize_host(host) if host else None
+ return await db.instance.get_session_context(session_key(auth), normalized_host)
diff --git a/passkey/util/tokens.py b/passkey/util/tokens.py
index bd3b09f..5a94c5c 100644
--- a/passkey/util/tokens.py
+++ b/passkey/util/tokens.py
@@ -15,6 +15,25 @@ def session_key(token: str) -> bytes:
return b"sess" + base64.urlsafe_b64decode(token)
+def encode_session_key(key: bytes) -> str:
+ """Encode an opaque session key for external representation."""
+ return base64.urlsafe_b64encode(key).decode().rstrip("=")
+
+
+def decode_session_key(encoded: str) -> bytes:
+ """Decode an opaque session key from its public representation."""
+ if not encoded:
+ raise ValueError("Invalid session identifier")
+ padding = "=" * (-len(encoded) % 4)
+ try:
+ raw = base64.urlsafe_b64decode(encoded + padding)
+ except Exception as exc: # pragma: no cover - defensive
+ raise ValueError("Invalid session identifier") from exc
+ if not raw.startswith(b"sess"):
+ raise ValueError("Invalid session identifier")
+ return raw
+
+
def reset_key(passphrase: str) -> bytes:
if not is_well_formed(passphrase):
raise ValueError(
diff --git a/passkey/util/useragent.py b/passkey/util/useragent.py
new file mode 100644
index 0000000..418d004
--- /dev/null
+++ b/passkey/util/useragent.py
@@ -0,0 +1,10 @@
+import user_agents
+
+
+def compact_user_agent(ua: str | None) -> str:
+ if not ua:
+ return "-"
+ u = user_agents.parse(ua)
+ ver = u.browser.version_string.split(".")[0]
+ dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
+ return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()
diff --git a/pyproject.toml b/pyproject.toml
index 5e4f46d..93ed8a8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@ dependencies = [
"aiosqlite>=0.19.0",
"uuid7-standard>=1.0.0",
"pyjwt>=2.8.0",
+ "user-agents>=2.2.0",
]
requires-python = ">=3.10"