diff --git a/.gitignore b/.gitignore index 97a64ba81..db632fb19 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ testsdk/ # Next.js build cache (legacy) web/.next/ +web/.pnpm-home +.tmp +Caddyfile diff --git a/pyproject.toml b/pyproject.toml index 3c0d0e711..74bd2b7fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ dependencies = [ "botocore>=1.42.39", "litellm>=1.0.0", "valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published + "webauthn>=3.0.0", ] keywords = [ "bot", diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 844be406e..31e6d7daf 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import quart import argon2 import asyncio import datetime import hmac import time +import typing import uuid from urllib.parse import parse_qs, urlsplit @@ -64,6 +67,22 @@ class UserRouterGroup(group.RouterGroup): return redirect_uri + def _extract_origin_and_rp_id(self, json_data: dict[str, typing.Any] | None = None) -> tuple[str, str]: + origin = '' + if json_data and isinstance(json_data, dict): + origin = json_data.get('origin', '') + if not origin: + origin = quart.request.headers.get('Origin', '') + if not origin: + origin = quart.request.headers.get('Referer', '') + if not origin: + origin = quart.request.url_root.rstrip('/') + + parsed = urlsplit(origin) + rp_id = parsed.hostname or 'localhost' + clean_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else origin.rstrip('/') + return clean_origin, rp_id + async def initialize(self) -> None: @self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) async def _() -> str: @@ -387,6 +406,8 @@ class UserRouterGroup(group.RouterGroup): capabilities['password_login_enabled'] = False capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode capabilities['invitation_registration_enabled'] = not cloud_mode + capabilities['passkey_login_enabled'] = True + capabilities['passkey_supported'] = True return self.success(data={'initialized': True, **capabilities}) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @@ -477,6 +498,182 @@ class UserRouterGroup(group.RouterGroup): except Exception: raise + @self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Generate WebAuthn registration options for current account.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + json_data = (await quart.request.json) or {} + origin, rp_id = self._extract_origin_and_rp_id(json_data) + + try: + options, challenge_token = await self.ap.user_service.generate_passkey_registration_options( + account_uuid=user_obj.uuid, + rp_id=rp_id, + origin=origin, + rp_name='LangBot', + ) + return self.success(data={'options': options, 'challenge_token': challenge_token}) + except Exception as e: + return self.fail(1, str(e)) + + @self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """Verify WebAuthn registration response and save credential.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + json_data = await quart.request.json + challenge_token = json_data.get('challenge_token') + credential = json_data.get('credential') or json_data.get('response') + name = json_data.get('name') + + if not challenge_token or not credential: + return self.fail(1, 'Missing challenge_token or credential') + + try: + cred = await self.ap.user_service.verify_and_save_passkey_registration( + challenge_token=challenge_token, + credential_data=credential, + name=name, + ) + return self.success( + data={ + 'uuid': cred.uuid, + 'name': cred.name, + 'created_at': cred.created_at.isoformat() if cred.created_at else None, + } + ) + except Exception as e: + return self.fail(1, str(e)) + + @self.route('/passkey/auth/options', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Generate WebAuthn authentication options for passkey login.""" + json_data = (await quart.request.json) or {} + email = json_data.get('email') + origin, rp_id = self._extract_origin_and_rp_id(json_data) + + try: + options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options( + rp_id=rp_id, + origin=origin, + email=email, + ) + return self.success(data={'options': options, 'challenge_token': challenge_token}) + except Exception as e: + return self.fail(1, str(e)) + + @self.route('/passkey/auth/verify', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + """Verify WebAuthn authentication response and log in.""" + json_data = await quart.request.json + challenge_token = json_data.get('challenge_token') + credential = json_data.get('credential') or json_data.get('response') + + if not challenge_token or not credential: + return self.fail(1, 'Missing challenge_token or credential') + + try: + token, user_obj = await self.ap.user_service.verify_passkey_authentication( + challenge_token=challenge_token, + credential_data=credential, + ) + return self.success( + data={ + 'token': token, + 'user': user_obj.user, + } + ) + except Exception as e: + return self.fail(1, str(e)) + + @self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + """List registered passkeys for the current user.""" + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + passkeys = await self.ap.user_service.get_user_passkeys(user_obj.uuid) + return self.success( + data=[ + { + 'uuid': pk.uuid, + 'name': pk.name, + 'aaguid': pk.aaguid, + 'transports': pk.transports, + 'backed_up': pk.backed_up, + 'created_at': pk.created_at.isoformat() if pk.created_at else None, + 'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None, + } + for pk in passkeys + ] + ) + + @self.route('/passkey/', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str, passkey_uuid: str) -> str: + """Rename a registered passkey.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + json_data = await quart.request.json + name = (json_data.get('name') or '').strip() + if not name: + return self.fail(1, 'Passkey name cannot be empty') + + updated = await self.ap.user_service.rename_user_passkey( + account_uuid=user_obj.uuid, + passkey_uuid=passkey_uuid, + new_name=name, + ) + if not updated: + return self.http_status(404, -1, 'Passkey not found') + return self.success(data={'uuid': updated.uuid, 'name': updated.name}) + + @self.route('/passkey/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str, passkey_uuid: str) -> str: + """Delete/revoke a registered passkey.""" + allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get( + 'allow_modify_login_info', True + ) + if not allow_modify_login_info: + return self.http_status(403, -1, 'Modifying login info is disabled') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + if user_obj is None: + return self.http_status(404, -1, 'User not found') + + deleted = await self.ap.user_service.delete_user_passkey( + account_uuid=user_obj.uuid, + passkey_uuid=passkey_uuid, + ) + if not deleted: + return self.http_status(404, -1, 'Passkey not found') + return self.success() + async def _handle_space_direct_launch( self, launch_assertion: str, diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 7fadccf6f..d0edf2e9b 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -4,6 +4,7 @@ import sqlalchemy import argon2 import jwt import datetime +import json import typing import asyncio import dataclasses @@ -12,10 +13,19 @@ import hashlib import secrets import time import uuid +import webauthn +from webauthn.helpers import bytes_to_base64url, base64url_to_bytes +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, +) from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ....entity.persistence import user +from ....entity.persistence import passkey from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership from ....utils import constants from ....entity.errors import account as account_errors @@ -29,6 +39,9 @@ if typing.TYPE_CHECKING: _SPACE_OAUTH_STATE_MAX_ENTRIES = 4096 _SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR = 64 _SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER = 4 +_PASSKEY_CHALLENGE_MAX_ENTRIES = 4096 +_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR = 64 +_PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER = 4 class AccountExistsLoginRequiredError(ValueError): @@ -54,6 +67,17 @@ class SpaceOAuthStateConsumption: launch_workspace_uuid: str | None = None +@dataclasses.dataclass(frozen=True, slots=True) +class PasskeyChallengeData: + challenge: bytes + purpose: typing.Literal['register', 'auth'] + rp_id: str + origin: str + expires_at: float + account_uuid: str | None = None + user_email: str | None = None + + class UserService: ap: Application _create_user_lock: asyncio.Lock @@ -65,6 +89,9 @@ class UserService: self._space_oauth_state_lock = asyncio.Lock() self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {} self._space_oauth_state_expiry_heap: list[tuple[float, str]] = [] + self._passkey_challenge_lock = asyncio.Lock() + self._passkey_challenges: dict[str, PasskeyChallengeData] = {} + self._passkey_challenge_expiry_heap: list[tuple[float, str]] = [] @staticmethod def _space_oauth_state_digest(state: str) -> str: @@ -850,3 +877,309 @@ class UserService: await self._update_space_provider_for_account(local_account, api_key) return await self.get_user_by_email(space_email) + + def _prune_passkey_challenges(self, now: float) -> None: + while self._passkey_challenge_expiry_heap: + expires_at, token = self._passkey_challenge_expiry_heap[0] + entry = self._passkey_challenges.get(token) + if entry is None or entry.expires_at != expires_at: + heapq.heappop(self._passkey_challenge_expiry_heap) + continue + if expires_at > now: + break + heapq.heappop(self._passkey_challenge_expiry_heap) + self._passkey_challenges.pop(token, None) + + max_heap_entries = max( + _PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR, + len(self._passkey_challenges) * _PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER, + ) + if len(self._passkey_challenge_expiry_heap) > max_heap_entries: + self._passkey_challenge_expiry_heap[:] = [ + (entry.expires_at, token) for token, entry in self._passkey_challenges.items() + ] + heapq.heapify(self._passkey_challenge_expiry_heap) + + async def issue_passkey_challenge( + self, + purpose: typing.Literal['register', 'auth'], + rp_id: str, + origin: str, + *, + account_uuid: str | None = None, + user_email: str | None = None, + ttl_seconds: int = 300, + ) -> tuple[str, bytes]: + now = time.monotonic() + challenge_bytes = secrets.token_bytes(32) + challenge_token = secrets.token_urlsafe(32) + expires_at = now + ttl_seconds + + async with self._passkey_challenge_lock: + self._prune_passkey_challenges(now) + while len(self._passkey_challenges) >= _PASSKEY_CHALLENGE_MAX_ENTRIES: + if not self._passkey_challenge_expiry_heap: + break + _, oldest_token = heapq.heappop(self._passkey_challenge_expiry_heap) + self._passkey_challenges.pop(oldest_token, None) + + self._passkey_challenges[challenge_token] = PasskeyChallengeData( + challenge=challenge_bytes, + purpose=purpose, + rp_id=rp_id, + origin=origin, + expires_at=expires_at, + account_uuid=account_uuid, + user_email=user_email, + ) + heapq.heappush(self._passkey_challenge_expiry_heap, (expires_at, challenge_token)) + + return challenge_token, challenge_bytes + + async def consume_passkey_challenge( + self, + challenge_token: str, + purpose: typing.Literal['register', 'auth'], + ) -> PasskeyChallengeData: + now = time.monotonic() + async with self._passkey_challenge_lock: + self._prune_passkey_challenges(now) + data = self._passkey_challenges.pop(challenge_token, None) + + if data is None or data.expires_at < now: + raise ValueError('Invalid or expired passkey challenge') + if data.purpose != purpose: + raise ValueError('Passkey challenge purpose mismatch') + return data + + async def get_user_passkeys(self, account_uuid: str) -> list[passkey.PasskeyCredential]: + statement = ( + sqlalchemy.select(passkey.PasskeyCredential) + .where(passkey.PasskeyCredential.account_uuid == account_uuid) + .order_by(passkey.PasskeyCredential.created_at.desc()) + ) + async with self._session_factory()() as session: + result = await session.scalars(statement) + return list(result.all()) + + async def get_passkey_by_credential_id(self, credential_id: str) -> passkey.PasskeyCredential | None: + statement = sqlalchemy.select(passkey.PasskeyCredential).where( + passkey.PasskeyCredential.credential_id == credential_id + ) + async with self._session_factory()() as session: + return await session.scalar(statement) + + async def get_passkey_by_uuid(self, passkey_uuid: str) -> passkey.PasskeyCredential | None: + statement = sqlalchemy.select(passkey.PasskeyCredential).where(passkey.PasskeyCredential.uuid == passkey_uuid) + async with self._session_factory()() as session: + return await session.scalar(statement) + + async def generate_passkey_registration_options( + self, + account_uuid: str, + rp_id: str, + origin: str, + rp_name: str = 'LangBot', + ) -> tuple[dict[str, typing.Any], str]: + account = await self.get_user_by_uuid(account_uuid) + if account is None: + raise ValueError('User not found') + self._require_active_account(account) + + challenge_token, challenge_bytes = await self.issue_passkey_challenge( + purpose='register', + rp_id=rp_id, + origin=origin, + account_uuid=account_uuid, + user_email=account.user, + ) + + existing_passkeys = await self.get_user_passkeys(account_uuid) + exclude_credentials = [ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in existing_passkeys + ] + + options = webauthn.generate_registration_options( + rp_id=rp_id, + rp_name=rp_name, + user_name=account.user, + user_id=account.uuid.encode('utf-8'), + user_display_name=account.user, + challenge=challenge_bytes, + exclude_credentials=exclude_credentials or None, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + ), + ) + + options_dict = json.loads(webauthn.options_to_json(options)) + return options_dict, challenge_token + + async def verify_and_save_passkey_registration( + self, + challenge_token: str, + credential_data: dict[str, typing.Any] | str, + name: str | None = None, + ) -> passkey.PasskeyCredential: + challenge_data = await self.consume_passkey_challenge(challenge_token, 'register') + if not challenge_data.account_uuid: + raise ValueError('Registration challenge must be bound to an account') + + verification = webauthn.verify_registration_response( + credential=credential_data, + expected_challenge=challenge_data.challenge, + expected_rp_id=challenge_data.rp_id, + expected_origin=challenge_data.origin, + require_user_verification=False, + ) + + cred_id_str = bytes_to_base64url(verification.credential_id) + pub_key_str = bytes_to_base64url(verification.credential_public_key) + + transports = None + if isinstance(credential_data, dict): + resp = credential_data.get('response', {}) + if isinstance(resp, dict) and 'transports' in resp: + t_list = resp.get('transports') + if isinstance(t_list, list): + transports = ','.join(str(x) for x in t_list) + + credential_name = (name or '').strip() + if not credential_name: + credential_name = f'Passkey ({datetime.datetime.now().strftime("%Y-%m-%d %H:%M")})' + + record = passkey.PasskeyCredential( + uuid=str(uuid.uuid4()), + account_uuid=challenge_data.account_uuid, + name=credential_name, + credential_id=cred_id_str, + public_key=pub_key_str, + sign_count=verification.sign_count, + aaguid=verification.aaguid, + transports=transports, + backed_up=verification.credential_backed_up, + ) + + async with self._session_factory()() as session: + async with session.begin(): + session.add(record) + await session.flush() + await session.refresh(record) + return record + + async def generate_passkey_authentication_options( + self, + rp_id: str, + origin: str, + email: str | None = None, + ) -> tuple[dict[str, typing.Any], str]: + challenge_token, challenge_bytes = await self.issue_passkey_challenge( + purpose='auth', + rp_id=rp_id, + origin=origin, + user_email=email, + ) + + allow_credentials: list[PublicKeyCredentialDescriptor] | None = None + if email: + user_obj = await self.get_user_by_email(email) + if user_obj: + user_passkeys = await self.get_user_passkeys(user_obj.uuid) + if user_passkeys: + allow_credentials = [ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in user_passkeys + ] + + options = webauthn.generate_authentication_options( + rp_id=rp_id, + challenge=challenge_bytes, + allow_credentials=allow_credentials or None, + user_verification=UserVerificationRequirement.PREFERRED, + ) + + options_dict = json.loads(webauthn.options_to_json(options)) + return options_dict, challenge_token + + async def verify_passkey_authentication( + self, + challenge_token: str, + credential_data: dict[str, typing.Any] | str, + ) -> tuple[str, user.User]: + challenge_data = await self.consume_passkey_challenge(challenge_token, 'auth') + + raw_id = credential_data.get('id') if isinstance(credential_data, dict) else None + if not raw_id: + raise ValueError('Missing credential id') + + stored_credential = await self.get_passkey_by_credential_id(raw_id) + if stored_credential is None: + raise ValueError('Passkey credential not recognized') + + user_obj = await self.get_user_by_uuid(stored_credential.account_uuid) + if user_obj is None: + raise ValueError('Associated user not found') + self._require_active_account(user_obj) + + verification = webauthn.verify_authentication_response( + credential=credential_data, + expected_challenge=challenge_data.challenge, + expected_rp_id=challenge_data.rp_id, + expected_origin=challenge_data.origin, + credential_public_key=base64url_to_bytes(stored_credential.public_key), + credential_current_sign_count=stored_credential.sign_count, + require_user_verification=False, + ) + + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar( + sqlalchemy.select(passkey.PasskeyCredential).where( + passkey.PasskeyCredential.id == stored_credential.id + ) + ) + if record: + record.sign_count = verification.new_sign_count + record.last_used_at = datetime.datetime.now() + record.backed_up = verification.credential_backed_up + + token = await self.generate_jwt_token(user_obj) + return token, user_obj + + async def rename_user_passkey( + self, + account_uuid: str, + passkey_uuid: str, + new_name: str, + ) -> passkey.PasskeyCredential | None: + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar( + sqlalchemy.select(passkey.PasskeyCredential).where( + passkey.PasskeyCredential.uuid == passkey_uuid, + passkey.PasskeyCredential.account_uuid == account_uuid, + ) + ) + if record is None: + return None + record.name = new_name + await session.flush() + await session.refresh(record) + return record + + async def delete_user_passkey( + self, + account_uuid: str, + passkey_uuid: str, + ) -> bool: + async with self._session_factory()() as session: + async with session.begin(): + record = await session.scalar( + sqlalchemy.select(passkey.PasskeyCredential).where( + passkey.PasskeyCredential.uuid == passkey_uuid, + passkey.PasskeyCredential.account_uuid == account_uuid, + ) + ) + if record is None: + return False + await session.delete(record) + return True diff --git a/src/langbot/pkg/entity/persistence/passkey.py b/src/langbot/pkg/entity/persistence/passkey.py new file mode 100644 index 000000000..210e228cb --- /dev/null +++ b/src/langbot/pkg/entity/persistence/passkey.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import uuid as uuid_lib + +import sqlalchemy + +from .base import Base + + +class PasskeyCredential(Base): + __tablename__ = 'passkey_credentials' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + uuid = sqlalchemy.Column( + sqlalchemy.String(36), + nullable=False, + default=lambda: str(uuid_lib.uuid4()), + ) + account_uuid = sqlalchemy.Column( + sqlalchemy.String(36), + sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'), + nullable=False, + ) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + credential_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + public_key = sqlalchemy.Column(sqlalchemy.Text, nullable=False) + sign_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + aaguid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True) + transports = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) + backed_up = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True) + + __table_args__ = ( + sqlalchemy.Index('uq_passkey_credentials_uuid', 'uuid', unique=True), + sqlalchemy.Index('uq_passkey_credentials_cred_id', 'credential_id', unique=True), + sqlalchemy.Index('ix_passkey_credentials_account', 'account_uuid'), + ) diff --git a/src/langbot/pkg/persistence/alembic/versions/0024_passkey_credentials.py b/src/langbot/pkg/persistence/alembic/versions/0024_passkey_credentials.py new file mode 100644 index 000000000..4941f4e34 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0024_passkey_credentials.py @@ -0,0 +1,54 @@ +"""add passkey credentials table + +Revision ID: 0024_passkey_credentials +Revises: 0023_bot_scoped_sessions +Create Date: 2026-09-12 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0024_passkey_credentials' +down_revision = '0023_bot_scoped_sessions' +branch_labels = None +depends_on = None + +_TABLE_NAME = 'passkey_credentials' + + +def upgrade() -> None: + conn = op.get_bind() + existing_tables = set(sa.inspect(conn).get_table_names()) + if _TABLE_NAME not in existing_tables: + op.create_table( + _TABLE_NAME, + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('uuid', sa.String(36), nullable=False), + sa.Column( + 'account_uuid', + sa.String(36), + sa.ForeignKey('users.uuid', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('credential_id', sa.String(255), nullable=False), + sa.Column('public_key', sa.Text(), nullable=False), + sa.Column('sign_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('aaguid', sa.String(64), nullable=True), + sa.Column('transports', sa.String(255), nullable=True), + sa.Column('backed_up', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + ) + op.create_index('uq_passkey_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True) + op.create_index('uq_passkey_credentials_cred_id', _TABLE_NAME, ['credential_id'], unique=True) + op.create_index('ix_passkey_credentials_account', _TABLE_NAME, ['account_uuid'], unique=False) + + +def downgrade() -> None: + op.drop_index('ix_passkey_credentials_account', table_name=_TABLE_NAME) + op.drop_index('uq_passkey_credentials_cred_id', table_name=_TABLE_NAME) + op.drop_index('uq_passkey_credentials_uuid', table_name=_TABLE_NAME) + op.drop_table(_TABLE_NAME) diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index 5d226675b..e80624afb 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -63,6 +63,7 @@ _ALEMBIC_TENANT_TABLES = { 'mcp_servers', 'model_providers', 'codex_credentials', + 'passkey_credentials', 'llm_models', 'embedding_models', 'rerank_models', diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 642efaf2b..864fa374f 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -310,6 +310,8 @@ class TestUserInitEndpoint: 'invitation_registration_enabled': True, 'password_login_enabled': True, 'space_login_enabled': False, + 'passkey_login_enabled': True, + 'passkey_supported': True, } fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with() fake_api_app.user_service.get_first_user.assert_not_awaited() @@ -334,6 +336,8 @@ class TestUserInitEndpoint: 'invitation_registration_enabled': False, 'password_login_enabled': False, 'space_login_enabled': True, + 'passkey_login_enabled': True, + 'passkey_supported': True, } @pytest.mark.asyncio @@ -355,6 +359,8 @@ class TestUserInitEndpoint: 'invitation_registration_enabled': True, 'password_login_enabled': False, 'space_login_enabled': True, + 'passkey_login_enabled': True, + 'passkey_supported': True, } @pytest.mark.asyncio diff --git a/tests/integration/api/test_user_passkey_api.py b/tests/integration/api/test_user_passkey_api.py new file mode 100644 index 000000000..566b6a0bc --- /dev/null +++ b/tests/integration/api/test_user_passkey_api.py @@ -0,0 +1,190 @@ +""" +Integration smoke tests for Passkey API endpoints. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest + +from tests.factories import FakeApp +from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope + + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures('mock_circular_import_chain')] + + +@pytest.fixture(scope='module') +def mock_circular_import_chain(): + class FakeMinimalApplication: + pass + + mock_app = Mock() + mock_app.Application = FakeMinimalApplication + + mock_entities = Mock() + mock_entities.LifecycleControlScope = MockLifecycleControlScope + + clear = [ + 'langbot.pkg.api.http.controller.group', + 'langbot.pkg.api.http.controller.groups', + 'langbot.pkg.api.http.controller.groups.system', + 'langbot.pkg.api.http.controller.groups.user', + 'langbot.pkg.api.http.controller.main', + ] + + with isolated_sys_modules( + mocks={ + 'langbot.pkg.core.app': mock_app, + 'langbot.pkg.core.entities': mock_entities, + }, + clear=clear, + ): + import langbot.pkg.api.http.controller.groups.user as _user_group # noqa: E402, F401 + + yield + + +@pytest.fixture +def fake_api_app(): + app = FakeApp() + app.instance_config.data.update( + { + 'api': {'port': 5300}, + 'system': {'allow_modify_login_info': True}, + } + ) + app.user_service = Mock() + app.user_service.verify_jwt_token = AsyncMock(side_effect=ValueError('Invalid token')) + app.user_service.get_user_by_email = AsyncMock(return_value=Mock()) + return app + + +@pytest.fixture +async def quart_test_client(fake_api_app, http_controller_cls): + controller = http_controller_cls(fake_api_app) + await controller.initialize() + + client = controller.quart_app.test_client() + yield client + + +class TestPasskeyPublicEndpoints: + @pytest.mark.asyncio + async def test_auth_options_endpoint(self, quart_test_client, fake_api_app): + fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock( + return_value=({'challenge': 'test_chal', 'rpId': 'localhost'}, 'token_123') + ) + + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/options', + json={'origin': 'http://localhost:3000'}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['challenge_token'] == 'token_123' + assert data['data']['options']['rpId'] == 'localhost' + + @pytest.mark.asyncio + async def test_auth_verify_missing_payload(self, quart_test_client, fake_api_app): + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/verify', + json={}, + ) + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] != 0 + assert 'Missing challenge_token or credential' in data['msg'] + + @pytest.mark.asyncio + async def test_auth_verify_success(self, quart_test_client, fake_api_app): + fake_api_app.user_service.verify_passkey_authentication = AsyncMock( + return_value=('jwt_token_abc', Mock(user='user@example.com')) + ) + + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/verify', + json={'challenge_token': 'token_123', 'credential': {'id': 'cred_id'}}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['token'] == 'jwt_token_abc' + assert data['data']['user'] == 'user@example.com' + + +class TestPasskeyProtectedEndpoints: + @pytest.mark.asyncio + async def test_register_options_requires_auth(self, quart_test_client): + response = await quart_test_client.post('/api/v1/user/passkey/register/options', json={}) + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_passkeys_list_requires_auth(self, quart_test_client): + response = await quart_test_client.get('/api/v1/user/passkeys') + assert response.status_code == 401 + + +class TestPasskeyReverseProxyScenarios: + @pytest.mark.asyncio + async def test_auth_options_respects_custom_origin_body_behind_proxy(self, quart_test_client, fake_api_app): + fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock( + return_value=({'challenge': 'test_chal', 'rpId': 'proxy.company.com'}, 'token_proxy') + ) + + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/options', + json={'origin': 'https://proxy.company.com:8443'}, + headers={'Host': '127.0.0.1:5300'}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with( + rp_id='proxy.company.com', + origin='https://proxy.company.com:8443', + email=None, + ) + + @pytest.mark.asyncio + async def test_auth_options_falls_back_to_origin_header(self, quart_test_client, fake_api_app): + fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock( + return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_header') + ) + + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/options', + json={}, + headers={'Origin': 'https://bot.example.com'}, + ) + + assert response.status_code == 200 + fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with( + rp_id='bot.example.com', + origin='https://bot.example.com', + email=None, + ) + + @pytest.mark.asyncio + async def test_auth_options_falls_back_to_referer_header(self, quart_test_client, fake_api_app): + fake_api_app.user_service.generate_passkey_authentication_options = AsyncMock( + return_value=({'challenge': 'test_chal', 'rpId': 'bot.example.com'}, 'token_referer') + ) + + response = await quart_test_client.post( + '/api/v1/user/passkey/auth/options', + json={}, + headers={'Referer': 'https://bot.example.com:9000/login'}, + ) + + assert response.status_code == 200 + fake_api_app.user_service.generate_passkey_authentication_options.assert_awaited_once_with( + rp_id='bot.example.com', + origin='https://bot.example.com:9000', + email=None, + ) diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index 11af89c59..a8283b2ff 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -550,11 +550,13 @@ class TestPostgreSQLWorkspaceMigration: ) assert 'workspaces' not in tables_before_migration assert 'codex_credentials' not in tables_before_migration + assert 'passkey_credentials' not in tables_before_migration await manager._initialize_managed_schema() async with postgres_engine.connect() as conn: assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names()) + assert 'passkey_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names()) account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one() workspace = ( (await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'})) diff --git a/tests/unit_tests/api/service/test_user_passkey.py b/tests/unit_tests/api/service/test_user_passkey.py new file mode 100644 index 000000000..a2aa38b74 --- /dev/null +++ b/tests/unit_tests/api/service/test_user_passkey.py @@ -0,0 +1,103 @@ +""" +Unit tests for Passkey WebAuthn service operations in UserService. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.api.http.service.user import UserService +from langbot.pkg.entity.persistence.user import AccountStatus, User + + +pytestmark = pytest.mark.asyncio + + +class TestPasskeyChallengeLifecycle: + async def test_challenge_issuance_and_consumption(self): + service = UserService(SimpleNamespace()) + token, challenge_bytes = await service.issue_passkey_challenge( + purpose='register', + rp_id='localhost', + origin='http://localhost:3000', + account_uuid='acc-123', + user_email='user@example.com', + ) + + assert len(token) > 20 + assert len(challenge_bytes) == 32 + + data = await service.consume_passkey_challenge(token, 'register') + assert data.challenge == challenge_bytes + assert data.rp_id == 'localhost' + assert data.origin == 'http://localhost:3000' + assert data.account_uuid == 'acc-123' + assert data.user_email == 'user@example.com' + + # Replay should fail + with pytest.raises(ValueError, match='Invalid or expired passkey challenge'): + await service.consume_passkey_challenge(token, 'register') + + async def test_challenge_purpose_mismatch_fails(self): + service = UserService(SimpleNamespace()) + token, _ = await service.issue_passkey_challenge( + purpose='register', + rp_id='localhost', + origin='http://localhost:3000', + ) + + with pytest.raises(ValueError, match='Passkey challenge purpose mismatch'): + await service.consume_passkey_challenge(token, 'auth') + + async def test_challenge_expiration(self): + service = UserService(SimpleNamespace()) + token, _ = await service.issue_passkey_challenge( + purpose='auth', + rp_id='localhost', + origin='http://localhost:3000', + ttl_seconds=0, + ) + + with pytest.raises(ValueError, match='Invalid or expired passkey challenge'): + await service.consume_passkey_challenge(token, 'auth') + + +class TestPasskeyOptionsGeneration: + async def test_generate_registration_options(self): + service = UserService(SimpleNamespace()) + mock_user = Mock(spec=User) + mock_user.uuid = 'acc-test-uuid' + mock_user.user = 'test@example.com' + mock_user.status = AccountStatus.ACTIVE.value + service.get_user_by_uuid = AsyncMock(return_value=mock_user) + service.get_user_passkeys = AsyncMock(return_value=[]) + + options, token = await service.generate_passkey_registration_options( + account_uuid='acc-test-uuid', + rp_id='localhost', + origin='http://localhost:3000', + rp_name='LangBot Test', + ) + + assert isinstance(options, dict) + assert options['rp']['name'] == 'LangBot Test' + assert options['rp']['id'] == 'localhost' + assert options['user']['name'] == 'test@example.com' + assert 'challenge' in options + assert len(token) > 0 + + async def test_generate_authentication_options_discoverable(self): + service = UserService(SimpleNamespace()) + + options, token = await service.generate_passkey_authentication_options( + rp_id='localhost', + origin='http://localhost:3000', + ) + + assert isinstance(options, dict) + assert options['rpId'] == 'localhost' + assert 'challenge' in options + assert len(token) > 0 diff --git a/uv.lock b/uv.lock index 70751b846..51b77be61 100644 --- a/uv.lock +++ b/uv.lock @@ -608,6 +608,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, ] +[[package]] +name = "cbor2" +version = "6.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/14/b02446bacfe44351b1689c04937ade007588f44570431880a6937e525e6c/cbor2-6.1.4.tar.gz", hash = "sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9", size = 90840, upload-time = "2026-08-01T20:41:39.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/84/1e363301c06f509963d134f5479e82b3ade87fb1495ddacf9bf7ff24ac42/cbor2-6.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8156fdeb73c3ff6c8cf67ad414fb5c887cd708ff0af6d61f62629f41cb4c17b2", size = 414947, upload-time = "2026-08-01T20:40:37.405Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/d8e1ed3e79ea20a3423a96b5c89ce794fa02cb428e4429e601f8ebcbac7c/cbor2-6.1.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e1fe2d62c50df290576280b18247ec63486f78be73e285bae269c2456c6ddff0", size = 457343, upload-time = "2026-08-01T20:40:38.868Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/5796c2ed2dcd0696fc4abedf0ea0dfd5361b3f022a311481f977fa51b2b8/cbor2-6.1.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c204a75f91f8cd9ed0881f6b88ec395c59aeac9fcf4d08155e7f899db2a1c46e", size = 464314, upload-time = "2026-08-01T20:40:40.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/88/de524c6c2c91b740e5df6e6955a113fb616e979b26fd2e6a0693082d36e0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28fa5db05a7eae8fd80709959988d8a7f12838c6d4e5c58ec951414058641195", size = 523053, upload-time = "2026-08-01T20:40:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/84/07/cb5fd92834633508d680a5b5695aeaf99d33ca0bdc5b844550d538f335b0/cbor2-6.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316e217a496640418d3137483279d0e70053b000cdd4b52a4dbf20ea478bc40a", size = 532177, upload-time = "2026-08-01T20:40:44.058Z" }, + { url = "https://files.pythonhosted.org/packages/c9/19/be98721365edfe6fc23e6bcd1385afa0e960b247c5f0b50bb67f5d05e2d9/cbor2-6.1.4-cp311-cp311-win32.whl", hash = "sha256:4903f24e0f9087275a0b6606c8b0aa586277001d51e4844fcdbc5b7211330aa8", size = 281660, upload-time = "2026-08-01T20:40:45.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/23/d54f679d4b155918f5a0879dab78203ce4fd514d311b7cfeba27dafe480b/cbor2-6.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:5b99305d4013867e059f147752b95f728680682ab03d75a3f4dcfbb270d8dfe9", size = 303207, upload-time = "2026-08-01T20:40:47.293Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/b3839d6213c88b249ba860525df05ff18b27bdc28ebc09cb1547790f001a/cbor2-6.1.4-cp311-cp311-win_arm64.whl", hash = "sha256:bd20ecc5c8ece24db952e48a91c8c47319eaa6358af707c85ac2bb388a79abc8", size = 296123, upload-time = "2026-08-01T20:40:48.808Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/fb64293c19cafb860060310c57b768fd9cfb7cf592449660b756538cc116/cbor2-6.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1fc15061553e4494dc10883237501e3402c645fe509248dd698e1faf2460d68b", size = 404608, upload-time = "2026-08-01T20:40:50.219Z" }, + { url = "https://files.pythonhosted.org/packages/96/ac/f58b3bafce7c86ada2ad8eaf189453136d2cf5bae526ea0540e1b9bc9d06/cbor2-6.1.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d9ada5a6ccfbb8ea7a3aa2aeb028421b52d8e0cd9323f0a2aeaa9c09d25fbce2", size = 449851, upload-time = "2026-08-01T20:40:51.725Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a5/10c6c126d59b07f2bd005094dd12a20afa46146f7e2673ed6f61a57641a7/cbor2-6.1.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:310f3dfb296ba48fe9b63c5cf26e691e3548a1eae6901d2f0c18e941d151f220", size = 461193, upload-time = "2026-08-01T20:40:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/15/e4/4445e6237088d1cca3b8536daeb90d6b4e23776de5609c9fa46773874757/cbor2-6.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e6c76004d674ad1c620660cb0bc5a8a0b72a5d8c7b70926d8e09e6d7e87332f", size = 516937, upload-time = "2026-08-01T20:40:54.952Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/9c0959510f7a402e5995c81ccfd82cb9f314140dc0cce88c12836e5b93f1/cbor2-6.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32a4663425fbca4a4a7aa918eb5789d844c406439e58424cf34511f79f559242", size = 529229, upload-time = "2026-08-01T20:40:56.365Z" }, + { url = "https://files.pythonhosted.org/packages/91/8e/6811e4ee84203ac657f6f461a37c7c9ba0287bde80eb83c7971e9b3fe156/cbor2-6.1.4-cp312-cp312-win32.whl", hash = "sha256:2310f07db3f9ba26f2a623774ff9f3dc7185af54f732ea119785a6b1bf7e1e7e", size = 278810, upload-time = "2026-08-01T20:40:57.76Z" }, + { url = "https://files.pythonhosted.org/packages/da/27/87440788fc0d9513534c3c699238e2a9ca6010f8cb72e9c203b7af20a9f6/cbor2-6.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:cc8cd300e236e9797b2e1ce306109dc481fcccf78bfa2682bf36d99e6eab1ec6", size = 299971, upload-time = "2026-08-01T20:40:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/77981e6e63092de19d7306a09a12b0eb3fd2907dc22c10dd5d389eb27faf/cbor2-6.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:553a46bda7d09552631a714e22b91e6ff2c867ecd91511596ce290d8879b8d5b", size = 290662, upload-time = "2026-08-01T20:41:00.89Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/0b20c88e76942ede86c98cdce138681690f95908c540c264fff847729cd4/cbor2-6.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c48a7c938fc5fa5300ff82b5df09068dcb4838685ae8556b5ee8279d74f97ab4", size = 403677, upload-time = "2026-08-01T20:41:02.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/3d/93eed770864540c5c9ea0841008208e9db686b7335f42520705b7d6dc6b2/cbor2-6.1.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4bd29f21529e279d50fc14f1a811f7b05b4d8e66a7969163cce98983b6817245", size = 449762, upload-time = "2026-08-01T20:41:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/e3/21/69e4d37f00319b3d37322355aedc83154b4d8b75dc9e9789c06e1fbd8a92/cbor2-6.1.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ae16d64b1f7b620c1af748e7b6947e20069ef80eee56871c5fbb84cc635905", size = 460420, upload-time = "2026-08-01T20:41:05.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/26/2cfdd5ee826205a88a826bb38b7a572c676ec3efa29574be5cdbd04b4859/cbor2-6.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:69978901302ecbc8cda57b520487c5c5240ed217de783eb7728fceb258311d76", size = 516490, upload-time = "2026-08-01T20:41:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d687cd1c2c9f9a986e8552ad1fdbd22411cc86389b5705dba6ec6f7e3226/cbor2-6.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad4efa23fee6447e56a269191044e06eb39e809458bcd674e164fe9445feafd0", size = 528810, upload-time = "2026-08-01T20:41:09.144Z" }, + { url = "https://files.pythonhosted.org/packages/40/08/88cecf20b8825bdd991c47b317415c08ef9e7d5f05a1def9acd346edabde/cbor2-6.1.4-cp313-cp313-win32.whl", hash = "sha256:d2560c2ba6a95904ba2a0ca257af878c4344409d9b46d8e646d8ebb617b1e0dd", size = 278058, upload-time = "2026-08-01T20:41:10.48Z" }, + { url = "https://files.pythonhosted.org/packages/0e/67/ba140234a6415c16dcfbe0585ce12f905157b70e9cb1bb63a2b6d5721e70/cbor2-6.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:c08b9c7d2ea013e24a0cb819b872b0119dde404f64a1182c0b24095b7bba781f", size = 299315, upload-time = "2026-08-01T20:41:12.067Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/35d53ff4252a5a85656480d3a81d5a5af823979ccd0c5cac95196a7548a6/cbor2-6.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:598710183daae69cbdeb177a870ec64aa601de8138a61491fd256826d15a860f", size = 289976, upload-time = "2026-08-01T20:41:13.63Z" }, + { url = "https://files.pythonhosted.org/packages/05/5d/c5374c76471ab41dff4420a276569a56352e83166374fba6f40fd0bde7ad/cbor2-6.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24da0a481294ac416e1e369e2d204b2b1d993cbd082d0d99fa3d6f5f27ae5e69", size = 407497, upload-time = "2026-08-01T20:41:15.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/f9/b9f12a5e24d5ae355e4c0f6d37330a2bbedad3331247a223a51c4cd39d5e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0859a0837e6e2d4fe5f5b849f6475797e4db545da98c19db4b1d3487bd47aa22", size = 452191, upload-time = "2026-08-01T20:41:16.705Z" }, + { url = "https://files.pythonhosted.org/packages/67/22/8224b01f95a6fe07b1a64082aea34d9f49068392b3de93f5f3a10c73c62e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c0f5f2d6d3b58e44146860c049f3c082207a4005588b8926d51bf937ab66773c", size = 462383, upload-time = "2026-08-01T20:41:18.17Z" }, + { url = "https://files.pythonhosted.org/packages/92/52/437e4aa4f5df1fb41020d64b3d99a8239f0f99a3a75eb6ffa5cb66004b7f/cbor2-6.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:239db0f92d537fd29eaec4e40195fc3b2b48bc34a5887059658162489a9eb6ae", size = 518700, upload-time = "2026-08-01T20:41:19.592Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/2f5ea5bfe0fd800b3739c7df8679bdffa9f7def6b2f2fee064ada1c63e85/cbor2-6.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3f4a434c36bb0d33aeb48ddae8e8b673ca7e1f14545ee7cf4a4c7c39380ea9a2", size = 531243, upload-time = "2026-08-01T20:41:21.21Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c6/0beac64cb74cd3217f295f9bb0d64675e1809c683a31ea2a49ac9d4d1504/cbor2-6.1.4-cp314-cp314-win32.whl", hash = "sha256:6abcf072b8c0fdc8ad7902ee26a906cafbf3427d026b662ff21166a253f85e18", size = 285248, upload-time = "2026-08-01T20:41:22.658Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7d/4afa096ddc94049f5a514690891b02a18319e146ceb14465ce30c8340a8b/cbor2-6.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:855764e02dc60ab9413acd044e997c3170000fdea6155d6c43a923a1d966dbe6", size = 313044, upload-time = "2026-08-01T20:41:24.066Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b5/e614cee861772f6b5c4d926b066d2e7dbc11e220b50ba716ba91e430fb0f/cbor2-6.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:c6b28b928c5f2dbf47dffa12dce9c8e36fe6ac1c1358bc326499c0736263b66f", size = 304088, upload-time = "2026-08-01T20:41:25.431Z" }, + { url = "https://files.pythonhosted.org/packages/9e/41/3b28184154f6cbf7e47c1b7fb4a7a291c54f27a6f3a0a2f64b078c6a13e1/cbor2-6.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7336ff4cb7d161ec43b65eef43bf3e9bcab44bd152efb54dd637b7afe711254f", size = 401042, upload-time = "2026-08-01T20:41:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/a8624023b84b41c43a150a89517c104aed0e467bd258866f13be4c3ac0c6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8f1019494b0ec81a3df3ebb01b6acb446d5b946fe35845b1726379abd66a71da", size = 445301, upload-time = "2026-08-01T20:41:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/07dd0ea957c1f48673d3947f97ee36826efd4a824053dd0ec4df2f0c89d6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:179a794bf4be1d46ff190695929f65f0b42019c156919846ae539d2a7ec42e54", size = 459816, upload-time = "2026-08-01T20:41:29.839Z" }, + { url = "https://files.pythonhosted.org/packages/23/8e/2015175132a27c1daed434f671ac6d9c1311461995df47f201307700e0da/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b904b8d0f4ddac9259197d21d121fae4cb8b555700d65bc12c5d46a2e6c2025", size = 511565, upload-time = "2026-08-01T20:41:31.939Z" }, + { url = "https://files.pythonhosted.org/packages/82/66/420991095d9473614b205d4c4e40b5d3b9f1ee4410eb3c48c1e902947837/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:71fcf4f237d68bf4445bf45070f36f82b333f2e6a62612aa2c256683b51378a9", size = 527709, upload-time = "2026-08-01T20:41:33.413Z" }, + { url = "https://files.pythonhosted.org/packages/cc/7c/73057e7a38488a816a0d40ff9e7cd9f418800894582e2e48fb2f47ce66a2/cbor2-6.1.4-cp314-cp314t-win32.whl", hash = "sha256:7deccc50fd0b55c4c7dd265b144c5358a645121e457c0ae3722b5ad59832b257", size = 281462, upload-time = "2026-08-01T20:41:35.127Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/d5db22837cb566de733b9d1c418cdf1912ccb1efc7b179e295430b1d81a2/cbor2-6.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:f3fc7d15cba4174373df2496070faa4a927fe3ed772130d281808120aec7b61c", size = 309165, upload-time = "2026-08-01T20:41:36.716Z" }, + { url = "https://files.pythonhosted.org/packages/29/5f/ff2c6da83553a692219a0a62a21b57a27ded4405200e50db758a17fbaf15/cbor2-6.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:164ca22b509408435b2d8236c80c964e4fc77c085ab034569cd04c40d5cc8883", size = 298386, upload-time = "2026-08-01T20:41:38.392Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -2085,6 +2133,7 @@ dependencies = [ { name = "urllib3" }, { name = "uv" }, { name = "valkey-glide", marker = "sys_platform != 'win32'" }, + { name = "webauthn" }, { name = "websockets" }, ] @@ -2180,6 +2229,7 @@ requires-dist = [ { name = "urllib3", specifier = ">=2.7.0" }, { name = "uv", specifier = ">=0.11.15" }, { name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" }, + { name = "webauthn", specifier = ">=3.0.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] provides-extras = ["seekdb"] @@ -4073,6 +4123,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pybase64" version = "1.4.3" @@ -4490,6 +4561,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, ] +[[package]] +name = "pyopenssl" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, +] + [[package]] name = "pypdf2" version = "3.0.1" @@ -6166,6 +6250,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, ] +[[package]] +name = "webauthn" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cbor2" }, + { name = "cryptography" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, + { name = "pyopenssl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/22/b19c91e850c4578b7d6cdb53453c5fe2f2e99d0c56e322c65c3caf1b3051/webauthn-3.0.0.tar.gz", hash = "sha256:324e54e1f6eeef486623b5d90df6fcd74ae04ff0c137d2b818a8f709b6ca3ab8", size = 160472, upload-time = "2026-06-29T22:40:33.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/d3/38d4efaedba74d854f88b60fd7b80ab37869032f9a9ad54d1892dab20241/webauthn-3.0.0-py3-none-any.whl", hash = "sha256:b5d0c02b6efa16be683f8a75abd2073f5e59a15f42623cc22c31f27600259e64", size = 73887, upload-time = "2026-06-29T22:40:32.171Z" }, +] + [[package]] name = "websocket-client" version = "1.9.0" diff --git a/web/package.json b/web/package.json index 715bc546d..c213ad44b 100644 --- a/web/package.json +++ b/web/package.json @@ -55,6 +55,7 @@ "@radix-ui/react-toggle": "^1.1.8", "@radix-ui/react-toggle-group": "^1.1.9", "@radix-ui/react-tooltip": "^1.2.7", + "@simplewebauthn/browser": "^14.0.0", "@tailwindcss/postcss": "^4.1.5", "@tanstack/react-table": "^8.21.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ce058c568..908595bde 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -93,6 +93,9 @@ dependencies: '@radix-ui/react-tooltip': specifier: ^1.2.7 version: 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@simplewebauthn/browser': + specifier: ^14.0.0 + version: 14.0.0 '@tailwindcss/postcss': specifier: ^4.1.5 version: 4.1.18 @@ -1846,6 +1849,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -1855,6 +1859,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -1864,6 +1869,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -1873,6 +1879,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -1882,6 +1889,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -1891,6 +1899,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -1942,6 +1951,10 @@ packages: resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} dev: false + /@simplewebauthn/browser@14.0.0: + resolution: {integrity: sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==} + dev: false + /@standard-schema/utils@0.3.0: resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} dev: false @@ -4240,6 +4253,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -4259,6 +4273,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true @@ -4278,6 +4293,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] requiresBuild: true dev: false optional: true @@ -4297,6 +4313,7 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] requiresBuild: true dev: false optional: true diff --git a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx index ec363c421..b03d5060b 100644 --- a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx +++ b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx @@ -12,7 +12,17 @@ import { } from '@/components/ui/item'; import { httpClient } from '@/app/infra/http/HttpClient'; import { systemInfo } from '@/app/infra/http'; -import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react'; +import { + Loader2, + ExternalLink, + KeyRound, + Layers, + Fingerprint, + Plus, + Trash2, + Pencil, +} from 'lucide-react'; +import { startRegistration } from '@simplewebauthn/browser'; import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog'; import { PanelBody } from '../settings-dialog/panel-layout'; @@ -22,6 +32,16 @@ interface AccountSettingsPanelProps { onEmailResolved?: (email: string) => void; } +interface PasskeyItem { + uuid: string; + name: string; + aaguid?: string; + transports?: string; + backed_up?: boolean; + created_at?: string; + last_used_at?: string; +} + export default function AccountSettingsPanel({ active, onEmailResolved, @@ -33,10 +53,14 @@ export default function AccountSettingsPanel({ const [loading, setLoading] = useState(true); const [spaceBindLoading, setSpaceBindLoading] = useState(false); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const [passkeys, setPasskeys] = useState([]); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [registeringPasskey, setRegisteringPasskey] = useState(false); useEffect(() => { if (active) { loadUserInfo(); + loadPasskeys(); } }, [active]); @@ -55,6 +79,67 @@ export default function AccountSettingsPanel({ } } + async function loadPasskeys() { + setPasskeyLoading(true); + try { + const list = await httpClient.getPasskeys(); + setPasskeys(list); + } catch { + // ignore + } finally { + setPasskeyLoading(false); + } + } + + const handleAddPasskey = async () => { + setRegisteringPasskey(true); + try { + const { options, challenge_token } = + await httpClient.getPasskeyRegisterOptions(window.location.origin); + const regResp = await startRegistration({ optionsJSON: options }); + const defaultName = + prompt(t('account.passkeyNamePlaceholder')) || undefined; + await httpClient.verifyPasskeyRegister( + challenge_token, + regResp, + defaultName, + ); + toast.success(t('account.passkeyAddedSuccess')); + await loadPasskeys(); + } catch (error: any) { + if (error?.name === 'NotAllowedError') { + // User cancelled + } else { + toast.error(error?.message || t('common.error')); + } + } finally { + setRegisteringPasskey(false); + } + }; + + const handleDeletePasskey = async (uuid: string) => { + if (!confirm(t('account.deletePasskeyConfirm'))) return; + try { + await httpClient.deletePasskey(uuid); + toast.success(t('account.passkeyDeleteSuccess')); + await loadPasskeys(); + } catch (error: any) { + toast.error(error?.message || t('common.error')); + } + }; + + const handleRenamePasskey = async (uuid: string, currentName: string) => { + const newName = prompt(t('account.passkeyName'), currentName); + if (!newName || !newName.trim() || newName === currentName) return; + try { + await httpClient.renamePasskey(uuid, newName.trim()); + toast.success(t('account.passkeyRenameSuccess')); + await loadPasskeys(); + } catch (error: any) { + toast.error(error?.message || t('common.error')); + } + }; + const handleBindSpace = async () => { setSpaceBindLoading(true); try { @@ -148,6 +233,105 @@ export default function AccountSettingsPanel({ )} + + {/* Passkey Section */} +
+
+
+

+ {t('account.passkeySectionTitle')} +

+

+ {t('account.passkeySectionDesc')} +

+
+ +
+ + {passkeyLoading ? ( +
+ +
+ ) : passkeys.length === 0 ? ( +
+ {t('account.noPasskeys')} +
+ ) : ( +
+ {passkeys.map((pk) => ( + + + + + + {pk.name} + + {pk.created_at && ( + + {t('account.passkeyCreated', { + date: new Date( + pk.created_at, + ).toLocaleDateString(), + })} + + )} + {pk.last_used_at && ( + + ·{' '} + {t('account.passkeyLastUsed', { + date: new Date( + pk.last_used_at, + ).toLocaleDateString(), + })} + + )} + + + + + + + + ))} +
+ )} +
)} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 426c9ab83..f449ef969 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1304,12 +1304,92 @@ export class BackendClient extends BaseHttpClient { invitation_registration_enabled?: boolean; password_login_enabled?: boolean; space_login_enabled?: boolean; + passkey_login_enabled?: boolean; + passkey_supported?: boolean; }> { return this.get('/api/v1/user/account-info', undefined, { skipWorkspace: true, }); } + // ============ Passkey (WebAuthn) API ============ + public getPasskeyAuthOptions( + email?: string, + origin?: string, + ): Promise<{ options: any; challenge_token: string }> { + return this.post( + '/api/v1/user/passkey/auth/options', + { email, origin }, + { skipWorkspace: true }, + ); + } + + public verifyPasskeyAuth( + challenge_token: string, + credential: any, + ): Promise<{ token: string; user: string }> { + return this.post( + '/api/v1/user/passkey/auth/verify', + { challenge_token, credential }, + { skipWorkspace: true }, + ); + } + + public getPasskeyRegisterOptions( + origin?: string, + ): Promise<{ options: any; challenge_token: string }> { + return this.post( + '/api/v1/user/passkey/register/options', + { origin }, + { skipWorkspace: true }, + ); + } + + public verifyPasskeyRegister( + challenge_token: string, + credential: any, + name?: string, + ): Promise<{ uuid: string; name: string; created_at?: string }> { + return this.post( + '/api/v1/user/passkey/register/verify', + { challenge_token, credential, name }, + { skipWorkspace: true }, + ); + } + + public getPasskeys(): Promise< + Array<{ + uuid: string; + name: string; + aaguid?: string; + transports?: string; + backed_up?: boolean; + created_at?: string; + last_used_at?: string; + }> + > { + return this.get('/api/v1/user/passkeys', undefined, { + skipWorkspace: true, + }); + } + + public renamePasskey( + uuid: string, + name: string, + ): Promise<{ uuid: string; name: string }> { + return this.patch( + `/api/v1/user/passkey/${encodeURIComponent(uuid)}`, + { name }, + { skipWorkspace: true }, + ); + } + + public deletePasskey(uuid: string): Promise { + return this.delete(`/api/v1/user/passkey/${encodeURIComponent(uuid)}`, { + skipWorkspace: true, + }); + } + // ============ Workspace API ============ public getWorkspaceBootstrap(): Promise { return this.get('/api/v1/workspaces/bootstrap', undefined, { diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 48436017f..598569fd7 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -35,7 +35,9 @@ import { AlertCircle, RefreshCw, Layers, + Fingerprint, } from 'lucide-react'; +import { startAuthentication } from '@simplewebauthn/browser'; import langbotIcon from '@/app/assets/langbot-logo.webp'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -63,6 +65,8 @@ export default function Login() { const [spaceLoading, setSpaceLoading] = useState(false); const [showLocalLogin, setShowLocalLogin] = useState(false); const [showSpaceLogin, setShowSpaceLogin] = useState(false); + const [showPasskeyLogin, setShowPasskeyLogin] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [retrying, setRetrying] = useState(false); @@ -90,6 +94,9 @@ export default function Login() { } setShowLocalLogin(res.password_login_enabled !== false); setShowSpaceLogin(res.space_login_enabled !== false); + setShowPasskeyLogin( + res.passkey_login_enabled !== false || Boolean(res.passkey_supported), + ); setLoading(false); // Also check if already logged in @@ -184,6 +191,30 @@ export default function Login() { handleLogin(values.email, values.password); } + async function handlePasskeyLogin() { + setPasskeyLoading(true); + try { + const { options, challenge_token } = + await httpClient.getPasskeyAuthOptions( + undefined, + window.location.origin, + ); + const authResp = await startAuthentication({ optionsJSON: options }); + const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp); + if (await finishLogin(res.token, res.user)) { + toast.success(t('common.passkeyLoginSuccess')); + } + } catch (error: any) { + if (error?.name === 'NotAllowedError') { + // User cancelled the biometric prompt + } else { + toast.error(error?.message || t('common.passkeyLoginFailed')); + } + } finally { + setPasskeyLoading(false); + } + } + function handleLogin(username: string, password: string) { httpClient .authUser(username, password) @@ -324,8 +355,27 @@ export default function Login() { )} + {showPasskeyLogin && ( +
+ +
+ )} + {/* Divider - only show if both login methods are available */} - {showSpaceLogin && showLocalLogin && ( + {(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index bdab194bc..2f00dc058 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -86,6 +86,10 @@ const enUS = { 'Recommended: Use official stable model APIs and cloud services', loginLocal: 'Login with local account', loginWithPassword: 'Login with password', + loginWithPasskey: 'Sign in with Passkey', + passkeyLoginSuccess: 'Passkey verified successfully, signing in...', + passkeyLoginFailed: 'Failed to sign in with Passkey', + passkeyNotSupported: 'Passkey is not supported on this browser or device', spaceLoginTitle: 'Login with LangBot Account', spaceLoginDescription: 'Scan the QR code or visit the link below to authorize', @@ -1339,6 +1343,20 @@ const enUS = { bindSpaceWarning: 'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.', bindSpaceSuccess: 'LangBot Account bound successfully', + passkeySectionTitle: 'Passkeys', + passkeySectionDesc: + 'Sign in securely without passwords using biometrics or security keys', + addPasskey: 'Add Passkey', + passkeyName: 'Key Name', + passkeyNamePlaceholder: 'e.g., MacBook Touch ID, YubiKey', + passkeyCreated: 'Created on {{date}}', + passkeyLastUsed: 'Last used: {{date}}', + noPasskeys: 'No passkeys registered yet', + deletePasskeyConfirm: + 'Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.', + passkeyAddedSuccess: 'Passkey added successfully', + passkeyDeleteSuccess: 'Passkey deleted', + passkeyRenameSuccess: 'Passkey renamed successfully', bindSpaceFailed: 'Failed to bind LangBot Account', bindSpaceInvalidState: 'Invalid bind request. Please try again from account settings.', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index ec0f06fdb..4460f2640 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -87,6 +87,11 @@ const jaJP = { 'おすすめ:公式の安定したモデル API とクラウドサービスを利用', loginLocal: 'ローカルアカウントでログイン', loginWithPassword: 'パスワードでログイン', + loginWithPasskey: 'パスキーでログイン', + passkeyLoginSuccess: 'パスキーの認証に成功しました。ログイン中...', + passkeyLoginFailed: 'パスキーでのログインに失敗しました', + passkeyNotSupported: + 'お使いのブラウザまたはデバイスはパスキーをサポートしていません', spaceLoginTitle: 'LangBot アカウントでログイン', spaceLoginDescription: 'QRコードをスキャンするか、下のリンクにアクセスして認証してください', @@ -1345,6 +1350,20 @@ const jaJP = { bindSpaceWarning: '連携後、ログインメールアドレスは {{localEmail}} から LangBot アカウントのメールアドレスに変更されます。', bindSpaceSuccess: 'LangBot アカウントの連携に成功しました', + passkeySectionTitle: 'パスキー (Passkey)', + passkeySectionDesc: + '生体認証やセキュリティキーを使って、パスワード不要で安全にログインします', + addPasskey: 'パスキーを追加', + passkeyName: 'キー名', + passkeyNamePlaceholder: '例: MacBook Touch ID、YubiKey', + passkeyCreated: '作成日: {{date}}', + passkeyLastUsed: '最終使用: {{date}}', + noPasskeys: '登録されているパスキーはありません', + deletePasskeyConfirm: + 'このパスキーを削除してもよろしいですか?削除後はこのキーでのログインができなくなります。', + passkeyAddedSuccess: 'パスキーが正常に追加されました', + passkeyDeleteSuccess: 'パスキーを削除しました', + passkeyRenameSuccess: 'パスキー名を変更しました', bindSpaceFailed: 'LangBot アカウントの連携に失敗しました', bindSpaceInvalidState: '無効な連携リクエストです。アカウント設定から再度お試しください。', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 8fbfc73b8..1b9d40ec4 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -84,6 +84,10 @@ const zhHans = { spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务', loginLocal: '使用本地账号登录', loginWithPassword: '通过密码登录', + loginWithPasskey: '使用 Passkey 登录', + passkeyLoginSuccess: 'Passkey 验证成功,正在登录...', + passkeyLoginFailed: 'Passkey 登录失败', + passkeyNotSupported: '当前浏览器或设备不支持 Passkey', spaceLoginTitle: '通过 LangBot 账号登录', spaceLoginDescription: '扫描二维码或访问下方链接进行授权', spaceLoginUserCode: '您的验证码', @@ -1274,6 +1278,19 @@ const zhHans = { bindSpaceWarning: '绑定后,您的登录邮箱将从 {{localEmail}} 更改为 LangBot 账号的邮箱。', bindSpaceSuccess: 'LangBot 账号绑定成功', + passkeySectionTitle: '通行密钥 (Passkey)', + passkeySectionDesc: '使用指纹、面容或硬件安全密钥免密安全登录', + addPasskey: '添加通行密钥', + passkeyName: '密钥名称', + passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey', + passkeyCreated: '创建于 {{date}}', + passkeyLastUsed: '上次使用: {{date}}', + noPasskeys: '暂未绑定任何通行密钥', + deletePasskeyConfirm: + '确定要删除此通行密钥吗?删除后将无法使用该密钥登录。', + passkeyAddedSuccess: '通行密钥添加成功', + passkeyDeleteSuccess: '通行密钥已删除', + passkeyRenameSuccess: '通行密钥重命名成功', bindSpaceFailed: '绑定 LangBot 账号失败', bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起', setPasswordHint: '设置密码后可使用邮箱密码登录',