From 2cf87e2cfb65fd3ba61a955fad5c5c249c4f3e16 Mon Sep 17 00:00:00 2001 From: lewis Date: Wed, 17 Dec 2025 23:29:48 +0200 Subject: [PATCH] Better oauth, appview groundwork --- .env.example | 21 +- ...2a40a64a5ce575bd8884b2c5d51f04871de1.json} | 16 +- TODO.md | 366 ++------- frontend/src/App.svelte | 9 + frontend/src/lib/api.ts | 147 ++++ frontend/src/lib/auth.svelte.ts | 152 +++- frontend/src/lib/oauth.ts | 181 +++++ frontend/src/routes/Admin.svelte | 744 ++++++++++++++++++ frontend/src/routes/Dashboard.svelte | 165 +++- frontend/src/routes/Login.svelte | 208 +++-- frontend/src/routes/Notifications.svelte | 269 +++++++ frontend/src/routes/ResetPassword.svelte | 223 ++++++ frontend/src/routes/Sessions.svelte | 240 ++++++ frontend/src/routes/Settings.svelte | 111 ++- src/api/actor/profile.rs | 71 +- src/api/admin/account/info.rs | 28 +- src/api/admin/account/mod.rs | 10 +- src/api/admin/account/profile.rs | 133 ---- src/api/admin/account/search.rs | 114 +++ src/api/admin/mod.rs | 4 +- src/api/feed/actor_likes.rs | 5 +- src/api/feed/author_feed.rs | 5 +- src/api/feed/custom_feed.rs | 16 +- src/api/feed/post_thread.rs | 5 +- src/api/feed/timeline.rs | 24 +- src/api/notification/register_push.rs | 12 +- src/api/proxy.rs | 96 +-- src/api/proxy_client.rs | 3 + src/api/read_after_write.rs | 24 +- src/api/repo/meta.rs | 62 +- src/api/repo/record/read.rs | 130 ++- src/api/server/mod.rs | 6 +- src/api/server/password.rs | 109 ++- src/api/server/session.rs | 132 +++- src/appview/mod.rs | 413 ++++++++++ src/lib.rs | 25 +- src/oauth/endpoints/authorize.rs | 22 +- src/oauth/endpoints/metadata.rs | 37 + src/oauth/templates.rs | 454 +++++------ src/state.rs | 4 + tests/admin_search.rs | 163 ++++ tests/change_password.rs | 197 +++++ tests/common/mod.rs | 27 +- tests/oauth_client_metadata.rs | 75 ++ tests/session_management.rs | 234 ++++++ 45 files changed, 4586 insertions(+), 906 deletions(-) rename .sqlx/{query-3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b.json => query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json} (72%) create mode 100644 frontend/src/lib/oauth.ts create mode 100644 frontend/src/routes/Admin.svelte create mode 100644 frontend/src/routes/ResetPassword.svelte create mode 100644 frontend/src/routes/Sessions.svelte delete mode 100644 src/api/admin/account/profile.rs create mode 100644 src/api/admin/account/search.rs create mode 100644 src/appview/mod.rs create mode 100644 tests/admin_search.rs create mode 100644 tests/change_password.rs create mode 100644 tests/oauth_client_metadata.rs create mode 100644 tests/session_management.rs diff --git a/.env.example b/.env.example index 69be63c..598aa45 100644 --- a/.env.example +++ b/.env.example @@ -48,10 +48,25 @@ AWS_SECRET_ACCESS_KEY=minioadmin # Optional: rotation key for PLC operations (defaults to user's key) # PLC_ROTATION_KEY=did:key:... # ============================================================================= -# Federation +# AppView Federation # ============================================================================= -# Appview URL for proxying app.bsky.* requests -# APPVIEW_URL=https://api.bsky.app +# AppViews are resolved via DID-based discovery. Configure by mapping lexicon +# namespaces to AppView DIDs. The DID document is fetched and the service +# endpoint is extracted automatically. +# +# Format: APPVIEW_DID_= +# Where uses underscores instead of dots (e.g., APP_BSKY for app.bsky) +# +# Default: app.bsky and com.atproto -> did:web:api.bsky.app +# +# Examples: +# APPVIEW_DID_APP_BSKY=did:web:api.bsky.app +# APPVIEW_DID_COM_WHTWND=did:web:whtwnd.com +# APPVIEW_DID_BLUE_ZIO=did:plc:some-custom-appview +# +# Cache TTL for resolved AppView endpoints (default: 300 seconds) +# APPVIEW_CACHE_TTL_SECS=300 +# # Comma-separated list of relay URLs to notify via requestCrawl # CRAWLERS=https://bsky.network,https://relay.upcloud.world # ============================================================================= diff --git a/.sqlx/query-3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b.json b/.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json similarity index 72% rename from .sqlx/query-3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b.json rename to .sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json index eed231c..5690886 100644 --- a/.sqlx/query-3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b.json +++ b/.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n handle, email, email_confirmed,\n preferred_notification_channel as \"preferred_channel: crate::notifications::NotificationChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", + "query": "SELECT\n handle, email, email_confirmed, is_admin,\n preferred_notification_channel as \"preferred_channel: crate::notifications::NotificationChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", "describe": { "columns": [ { @@ -20,6 +20,11 @@ }, { "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, "name": "preferred_channel: crate::notifications::NotificationChannel", "type_info": { "Custom": { @@ -36,17 +41,17 @@ } }, { - "ordinal": 4, + "ordinal": 5, "name": "discord_verified", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "telegram_verified", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 7, "name": "signal_verified", "type_info": "Bool" } @@ -63,8 +68,9 @@ false, false, false, + false, false ] }, - "hash": "3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b" + "hash": "088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1" } diff --git a/TODO.md b/TODO.md index aa92771..3b969ab 100644 --- a/TODO.md +++ b/TODO.md @@ -1,284 +1,82 @@ -# PDS Implementation TODOs -Lewis' corrected big boy todofile -## Server Infrastructure & Proxying -- [x] Health Check - - [x] Implement `GET /health` endpoint (returns "OK"). - - [x] Implement `GET /xrpc/_health` endpoint (returns "OK"). -- [x] Server Description - - [x] Implement `com.atproto.server.describeServer` (returns available user domains). -- [x] XRPC Proxying - - [x] Implement strict forwarding for all `app.bsky.*` and `chat.bsky.*` requests to an appview. - - [x] Forward auth headers correctly. - - [x] Handle appview errors/timeouts gracefully. -## Authentication & Account Management (`com.atproto.server`) -- [x] Account Creation - - [x] Implement `com.atproto.server.createAccount`. - - [x] Validate handle format (reject invalid characters). - - [x] Create DID for new user (PLC directory). - - [x] Initialize user repository (Root commit). - - [x] Return access JWT and DID. - - [x] Create DID for new user (did:web). -- [x] Session Management - - [x] Implement `com.atproto.server.createSession` (Login). - - [x] Implement `com.atproto.server.getSession`. - - [x] Implement `com.atproto.server.refreshSession`. - - [x] Implement `com.atproto.server.deleteSession` (Logout). - - [x] Implement `com.atproto.server.activateAccount`. - - [x] Implement `com.atproto.server.checkAccountStatus`. - - [x] Implement `com.atproto.server.createAppPassword`. - - [x] Implement `com.atproto.server.createInviteCode`. - - [x] Implement `com.atproto.server.createInviteCodes`. - - [x] Implement `com.atproto.server.deactivateAccount`. - - [x] Implement `com.atproto.server.deleteAccount` (user-initiated, requires password + email token). - - [x] Implement `com.atproto.server.getAccountInviteCodes`. - - [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth). - - [x] Implement `com.atproto.server.listAppPasswords`. - - [x] Implement `com.atproto.server.requestAccountDelete`. - - [x] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`. - - [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`. - - [x] Implement `com.atproto.server.reserveSigningKey`. - - [x] Implement `com.atproto.server.revokeAppPassword`. - - [x] Implement `com.atproto.server.updateEmail`. - - [x] Implement `com.atproto.server.confirmEmail`. -## Repository Operations (`com.atproto.repo`) -- [x] Record CRUD - - [x] Implement `com.atproto.repo.createRecord`. - - [x] Generate `rkey` (TID) if not provided. - - [x] Handle MST (Merkle Search Tree) insertion. - - [x] **Trigger Firehose Event**. - - [x] Implement `com.atproto.repo.putRecord`. - - [x] Implement `com.atproto.repo.getRecord`. - - [x] Implement `com.atproto.repo.deleteRecord`. - - [x] Implement `com.atproto.repo.listRecords`. - - [x] Implement `com.atproto.repo.describeRepo`. - - [x] Implement `com.atproto.repo.applyWrites` (Batch writes). - - [x] Implement `com.atproto.repo.importRepo` (Migration). - - [x] Implement `com.atproto.repo.listMissingBlobs`. -- [x] Blob Management - - [x] Implement `com.atproto.repo.uploadBlob`. - - [x] Store blob (S3). - - [x] return `blob` ref (CID + MimeType). -## Sync & Federation (`com.atproto.sync`) -- [x] The Firehose (WebSocket) - - [x] Implement `com.atproto.sync.subscribeRepos`. - - [x] Broadcast real-time commit events. - - [x] Handle cursor replay (backfill). -- [x] Bulk Export - - [x] Implement `com.atproto.sync.getRepo` (Return full CAR file of repo). - - [x] Implement `com.atproto.sync.getBlocks` (Return specific blocks via CIDs). - - [x] Implement `com.atproto.sync.getLatestCommit`. - - [x] Implement `com.atproto.sync.getRecord` (Sync version, distinct from repo.getRecord). - - [x] Implement `com.atproto.sync.getRepoStatus`. - - [x] Implement `com.atproto.sync.listRepos`. - - [x] Implement `com.atproto.sync.notifyOfUpdate`. -- [x] Blob Sync - - [x] Implement `com.atproto.sync.getBlob`. - - [x] Implement `com.atproto.sync.listBlobs`. -- [x] Crawler Interaction - - [x] Implement `com.atproto.sync.requestCrawl` (Notify relays to index us). -- [x] Deprecated Sync Endpoints (for compatibility) - - [x] Implement `com.atproto.sync.getCheckout` (deprecated). - - [x] Implement `com.atproto.sync.getHead` (deprecated). -## Identity (`com.atproto.identity`) -- [x] Resolution - - [x] Implement `com.atproto.identity.resolveHandle` (Can be internal or proxy to PLC). - - [x] Implement `com.atproto.identity.updateHandle`. - - [x] Implement `com.atproto.identity.submitPlcOperation` / `signPlcOperation` / `requestPlcOperationSignature`. - - [x] Implement `com.atproto.identity.getRecommendedDidCredentials`. - - [x] Implement `/.well-known/did.json` (Depends on supporting did:web). -## Admin Management (`com.atproto.admin`) -- [x] Implement `com.atproto.admin.deleteAccount`. -- [x] Implement `com.atproto.admin.disableAccountInvites`. -- [x] Implement `com.atproto.admin.disableInviteCodes`. -- [x] Implement `com.atproto.admin.enableAccountInvites`. -- [x] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`. -- [x] Implement `com.atproto.admin.getInviteCodes`. -- [x] Implement `com.atproto.admin.getSubjectStatus`. -- [x] Implement `com.atproto.admin.sendEmail`. -- [x] Implement `com.atproto.admin.updateAccountEmail`. -- [x] Implement `com.atproto.admin.updateAccountHandle`. -- [x] Implement `com.atproto.admin.updateAccountPassword`. -- [x] Implement `com.atproto.admin.updateSubjectStatus`. -## Moderation (`com.atproto.moderation`) -- [x] Implement `com.atproto.moderation.createReport`. -## Temp Namespace (`com.atproto.temp`) -- [x] Implement `com.atproto.temp.checkSignupQueue` (signup queue status for gated signups). -## Misc HTTP Endpoints -- [x] Implement `/robots.txt` endpoint. -## OAuth 2.1 Support -Full OAuth 2.1 provider for ATProto native app authentication. -- [x] OAuth Provider Core - - [x] Implement `/.well-known/oauth-protected-resource` metadata endpoint. - - [x] Implement `/.well-known/oauth-authorization-server` metadata endpoint. - - [x] Implement `/oauth/authorize` authorization endpoint (with login UI). - - [x] Implement `/oauth/par` Pushed Authorization Request endpoint. - - [x] Implement `/oauth/token` token endpoint (authorization_code + refresh_token grants). - - [x] Implement `/oauth/jwks` JSON Web Key Set endpoint. - - [x] Implement `/oauth/revoke` token revocation endpoint. - - [x] Implement `/oauth/introspect` token introspection endpoint. -- [x] OAuth Database Tables - - [x] Device table for tracking authorized devices. - - [x] Authorization request table. - - [x] Authorized client table. - - [x] Token table for OAuth tokens. - - [x] Used refresh token table (replay protection). - - [x] DPoP JTI tracking table. -- [x] DPoP (Demonstrating Proof-of-Possession) support. -- [x] Client metadata fetching and validation. -- [x] PKCE (S256) enforcement. -- [x] OAuth token verification extractor for protected resources. -- [x] Authorization UI templates (HTML login form). -- [x] Implement `private_key_jwt` signature verification with async JWKS fetching. -- [x] HS256 JWT support (matches reference PDS). -## OAuth Security Notes -Security measures implemented: -- Constant-time comparison for signature verification (prevents timing attacks) -- HMAC-SHA256 for access token signing with configurable secret -- Production secrets require 32+ character minimum -- DPoP JTI replay protection via database -- DPoP nonce validation with HMAC-based timestamps (5 min validity) -- Refresh token rotation with reuse detection (revokes token family on reuse) -- PKCE S256 enforced (plain not allowed) -- Authorization code single-use enforcement -- URL encoding for redirect parameters (prevents injection) -- All database queries use parameterized statements (no SQL injection) -- Deactivated/taken-down accounts blocked from OAuth authorization -- Client ID validation on token exchange (defense-in-depth against cross-client attacks) -- HTML escaping in OAuth templates (XSS prevention) -### Auth Notes -- Dual algorithm support: ES256K (secp256k1 ECDSA) with per-user keys AND HS256 (HMAC) for compatibility with reference PDS. -- Token storage: Storing only token JTIs in session_tokens table (defense in depth against DB breaches). Refresh token family tracking enables detection of token reuse attacks. -- Key encryption: User signing keys encrypted at rest using AES-256-GCM with keys derived via HKDF from KEY_ENCRYPTION_KEY environment variable. -## PDS-Level App Endpoints -These endpoints need to be implemented at the PDS level (not just proxied to appview). -### Actor (`app.bsky.actor`) -- [x] Implement `app.bsky.actor.getPreferences` (user preferences storage). -- [x] Implement `app.bsky.actor.putPreferences` (update user preferences). -- [x] Implement `app.bsky.actor.getProfile` (PDS-level with proxy fallback). -- [x] Implement `app.bsky.actor.getProfiles` (PDS-level with proxy fallback). -### Feed (`app.bsky.feed`) -These are implemented at PDS level to enable local-first reads (read-after-write pattern): -- [x] Implement `app.bsky.feed.getTimeline` (PDS-level with proxy + RAW). -- [x] Implement `app.bsky.feed.getAuthorFeed` (PDS-level with proxy + RAW). -- [x] Implement `app.bsky.feed.getActorLikes` (PDS-level with proxy + RAW). -- [x] Implement `app.bsky.feed.getPostThread` (PDS-level with proxy + RAW + NotFound handling). -- [x] Implement `app.bsky.feed.getFeed` (proxy to feed generator). -### Notification (`app.bsky.notification`) -- [x] Implement `app.bsky.notification.registerPush` (push notification registration, proxied). -## Infrastructure & Core Components -- [x] Sequencer (Event Log) - - [x] Implement a `Sequencer` (backed by `repo_seq` table). - - [x] Implement event formatting (`commit`, `handle`, `identity`, `account`). - - [x] Implement database polling / event emission mechanism. - - [x] Implement cursor-based event replay (`requestSeqRange`). -- [x] Repo Storage & Consistency (in postgres) - - [x] Implement `RepoStorage` for postgres (replaces per-user SQLite). - - [x] Read/Write IPLD blocks to `blocks` table (global deduplication). - - [x] Manage Repo Root in `repos` table. - - [x] Implement Atomic Repo Transactions. - - [x] Ensure `blocks` write, `repo_root` update, `records` index update, and `sequencer` event are committed in a single transaction. - - [x] Implement concurrency control (row-level locking via FOR UPDATE). -- [x] DID Cache - - [x] Implement caching layer for DID resolution (valkey). - - [x] Handle cache invalidation/expiry. - - [x] Graceful fallback to no-cache when valkey unavailable. -- [x] Crawlers Service - - [x] Implement `Crawlers` service (debounce notifications to relays). - - [x] 20-minute notification debounce. - - [x] Circuit breaker for relay failures. -- [x] Notification Service - - [x] Queue-based notification system with database table - - [x] Background worker polling for pending notifications - - [x] Extensible sender trait for multiple channels - - [x] Email sender via OS sendmail/msmtp - - [x] Discord webhook sender - - [x] Telegram bot sender - - [x] Signal CLI sender - - [x] Helper functions for common notification types (welcome, password reset, email verification, etc.) - - [x] Respect user's `preferred_notification_channel` setting for non-email-specific notifications -- [x] Image Processing - - [x] Implement image resize/formatting pipeline (for blob uploads). - - [x] WebP conversion for thumbnails. - - [x] EXIF stripping. - - [x] File size limits (10MB default). -- [x] IPLD & MST - - [x] Implement Merkle Search Tree logic for repo signing. - - [x] Implement CAR (Content Addressable Archive) encoding/decoding. - - [x] Cycle detection in CAR export. -- [x] Rate Limiting - - [x] Per-IP rate limiting on login (10/min). - - [x] Per-IP rate limiting on OAuth token endpoint (30/min). - - [x] Per-IP rate limiting on password reset (5/hour). - - [x] Per-IP rate limiting on account creation (10/hour). - - [x] Per-IP rate limiting on refreshSession (60/min). - - [x] Per-IP rate limiting on OAuth authorize POST (10/min). - - [x] Per-IP rate limiting on OAuth 2FA POST (10/min). - - [x] Per-IP rate limiting on OAuth PAR (30/min). - - [x] Per-IP rate limiting on OAuth revoke/introspect (30/min). - - [x] Per-IP rate limiting on createAppPassword (10/min). - - [x] Per-IP rate limiting on email endpoints (5/hour). - - [x] Distributed rate limiting via valkey (with in-memory fallback). -- [x] Circuit Breakers - - [x] PLC directory circuit breaker (5 failures → open, 60s timeout). - - [x] Relay notification circuit breaker (10 failures → open, 30s timeout). -- [x] Security Hardening - - [x] Email header injection prevention (CRLF sanitization). - - [x] Signal command injection prevention (phone number validation). - - [x] Constant-time signature comparison. - - [x] SSRF protection for outbound requests. - - [x] Timing attack protection (dummy bcrypt on user-not-found prevents account enumeration). -## Lewis' fabulous mini-list of remaining TODOs -- [x] The OAuth authorize POST endpoint has no rate limiting, allowing password brute-forcing. Fix this and audit all oauth and 2fa surface again. -- [x] DID resolution caching (valkey). -- [x] Record schema validation (generic validation framework). -- [x] Fix any remaining TODOs in the code. -## Future: Web Management UI -A single-page web app for account management. The frontend (JS framework) calls existing ATProto XRPC endpoints - no server-side rendering or bespoke HTML form handlers. -### Architecture -- [x] Static SPA served from PDS (or separate static host) -- [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client) -- [x] All operations use standard XRPC endpoints (existing + new PDS-specific ones below) -- [x] No server-side sessions or CSRF - pure API client -### PDS-Specific XRPC Endpoints (new) -Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D -Anyway... endpoints for PDS settings not covered by standard ATProto: -- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels -- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel -- [x] `com.bspds.account.getNotificationHistory` - list past notifications -- [x] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal -- [x] `com.bspds.account.confirmChannelVerification` - confirm with code -- [x] `com.bspds.admin.getServerStats` - user count, storage usage, etc. -### Frontend Views -Uses existing ATProto endpoints where possible: -Authentication -- [x] Login page (uses `com.atproto.server.createSession`) -- [x] Registration page (uses `com.atproto.server.createAccount`) -- [x] Signup verification flow (uses `com.atproto.server.confirmSignup`, `resendVerification`) -- [ ] Password reset flow (uses `com.atproto.server.requestPasswordReset`, `resetPassword`) -User Dashboard -- [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`) -- [ ] Active sessions view (needs new endpoint or extend existing) -- [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`) -- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) -Notification Preferences -- [x] Channel selector (uses `com.bspds.account.*` endpoints above) -- [x] Verification flows for Discord/Telegram/Signal -- [ ] Notification history view -Account Settings -- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`) -- [ ] Password change while logged in (needs new endpoint - change password with current password) -- [x] Handle change (uses `com.atproto.identity.updateHandle`) -- [x] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`) -Data Management -- [x] Repo browser (browse collections, view/create/delete records via `com.atproto.repo.*`) -- [ ] Data export/download (CAR file download via `com.atproto.sync.getRepo`) -Admin Dashboard (privileged users only) -- [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination) -- [ ] User detail/actions (uses `com.atproto.admin.*` endpoints) -- [ ] Invite management (uses `com.atproto.admin.getInviteCodes`, `disableInviteCodes`) -- [ ] Server stats (uses `com.bspds.admin.getServerStats`) -## Future: private data -I will see where the discourse about encrypted/privileged private data is at the current moment, and make an implementation that matches what the bsky team will likely do in their pds whenever they get around to it. -Then when they come out with theirs, I can make adjustments to mine and be ready on day 1. Or 2. -We want records that only authorized parties can see and decrypt. This requires some sort of federation of keys and communication between PDSes? -Gotta figure all of this out as a first step. +# Lewis' Big Boy TODO list + +## Active development + +### OAuth scope authorization UI +Display and manage OAuth scopes during authorization flows. + +- [ ] Parse and display requested scopes from authorization request +- [ ] Human-readable scope descriptions (e.g., "Read your posts" not "app.bsky.feed.read") +- [ ] Group scopes by category (read, write, admin, etc.) +- [ ] Allow users to uncheck optional scopes before authorizing +- [ ] Distinguish required vs optional scopes in UI +- [ ] Remember scope preferences per client (don't ask again for same scopes) +- [ ] Token endpoint respects user's scope selections +- [ ] Protected endpoints check token scopes before allowing operations + +### Frontend +So like... make the thing unique, make it cool. + +- [ ] Frontpage that explains what this thing is +- [ ] Unique "brand" style both unauthed and authed +- [ ] Better documentation on how to sub out the entire frontend for whatever the users want + +### Delegated accounts +Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model. + +- [ ] Account type flag in actors table (personal | delegated) +- [ ] account_delegations table (delegated_did, controller_did, granted_scopes[], granted_at, granted_by, revoked_at) +- [ ] Detect delegated account during authorize flow +- [ ] Redirect to "authenticate as controller" instead of password prompt +- [ ] Validate controller has delegation grant for this account +- [ ] Issue token with intersection of (requested scopes :intersection-emoji: granted scopes) +- [ ] Token includes act_as claim indicating delegation +- [ ] Define standard scope sets (owner, admin, editor, viewer) +- [ ] Create delegated account flow (no password, must add initial controller) +- [ ] Controller management page (add/remove controllers, modify scopes) +- [ ] "Act as" account switcher for users with delegation grants +- [ ] Log all actions with both actor DID and controller DID +- [ ] Audit log view for delegated account owners + +### Passkey support +Modern passwordless authentication using WebAuthn/FIDO2, alongside or instead of passwords. + +- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) +- [ ] Generate WebAuthn registration challenge +- [ ] Verify attestation response and store credential +- [ ] UI for registering new passkey from settings +- [ ] Detect if account has passkeys during OAuth authorize +- [ ] Offer passkey option alongside password +- [ ] Generate authentication challenge and verify assertion +- [ ] Update sign count (replay protection) +- [ ] Allow creating account with passkey instead of password +- [ ] List/rename/remove passkeys in settings + +### Private/encrypted data +Records that only authorized parties can see and decrypt. Requires key federation between PDSes. + +- [ ] Survey current ATProto discourse on private data +- [ ] Document Bluesky team's likely approach +- [ ] Design key management strategy +- [ ] Per-user encryption keys (separate from signing keys) +- [ ] Key derivation for per-record or per-collection encryption +- [ ] Encrypted record storage format +- [ ] Transparent encryption/decryption in repo operations +- [ ] Protocol for sharing decryption keys between PDSes +- [ ] Handle key rotation and revocation + +--- + +## Completed + +Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, did:web, full admin API, moderation reports. + +OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification. + +App endpoints: getPreferences, putPreferences, getProfile, getProfiles, getTimeline, getAuthorFeed, getActorLikes, getPostThread, getFeed, registerPush (all with local-first + proxy fallback). + +Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic transactions, valkey DID cache, debounced crawler notifications with circuit breakers, multi-channel notifications (email/Discord/Telegram/Signal), image processing, distributed rate limiting, security hardening. + +Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel. + +Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2691ebb..10dbd23 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -3,12 +3,15 @@ import { initAuth, getAuthState } from './lib/auth.svelte' import Login from './routes/Login.svelte' import Register from './routes/Register.svelte' + import ResetPassword from './routes/ResetPassword.svelte' import Dashboard from './routes/Dashboard.svelte' import AppPasswords from './routes/AppPasswords.svelte' import InviteCodes from './routes/InviteCodes.svelte' import Settings from './routes/Settings.svelte' + import Sessions from './routes/Sessions.svelte' import Notifications from './routes/Notifications.svelte' import RepoExplorer from './routes/RepoExplorer.svelte' + import Admin from './routes/Admin.svelte' const auth = getAuthState() @@ -22,6 +25,8 @@ return Login case '/register': return Register + case '/reset-password': + return ResetPassword case '/dashboard': return Dashboard case '/app-passwords': @@ -30,10 +35,14 @@ return InviteCodes case '/settings': return Settings + case '/sessions': + return Sessions case '/notifications': return Notifications case '/repo': return RepoExplorer + case '/admin': + return Admin default: return auth.session ? Dashboard : Login } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a252c61..c96d778 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -47,6 +47,7 @@ export interface Session { emailConfirmed?: boolean preferredChannel?: string preferredChannelVerified?: boolean + isAdmin?: boolean accessJwt: string refreshJwt: string } @@ -270,6 +271,152 @@ export const api = { }) }, + async confirmChannelVerification(token: string, channel: string, code: string): Promise<{ success: boolean }> { + return xrpc('com.bspds.account.confirmChannelVerification', { + method: 'POST', + token, + body: { channel, code }, + }) + }, + + async getNotificationHistory(token: string): Promise<{ + notifications: Array<{ + createdAt: string + channel: string + notificationType: string + status: string + subject: string | null + body: string + }> + }> { + return xrpc('com.bspds.account.getNotificationHistory', { token }) + }, + + async getServerStats(token: string): Promise<{ + userCount: number + repoCount: number + recordCount: number + blobStorageBytes: number + }> { + return xrpc('com.bspds.admin.getServerStats', { token }) + }, + + async changePassword(token: string, currentPassword: string, newPassword: string): Promise { + await xrpc('com.bspds.account.changePassword', { + method: 'POST', + token, + body: { currentPassword, newPassword }, + }) + }, + + async listSessions(token: string): Promise<{ + sessions: Array<{ + id: string + createdAt: string + expiresAt: string + isCurrent: boolean + }> + }> { + return xrpc('com.bspds.account.listSessions', { token }) + }, + + async revokeSession(token: string, sessionId: string): Promise { + await xrpc('com.bspds.account.revokeSession', { + method: 'POST', + token, + body: { sessionId }, + }) + }, + + async searchAccounts(token: string, options?: { + handle?: string + cursor?: string + limit?: number + }): Promise<{ + cursor?: string + accounts: Array<{ + did: string + handle: string + email?: string + indexedAt: string + emailConfirmedAt?: string + deactivatedAt?: string + }> + }> { + const params: Record = {} + if (options?.handle) params.handle = options.handle + if (options?.cursor) params.cursor = options.cursor + if (options?.limit) params.limit = String(options.limit) + return xrpc('com.atproto.admin.searchAccounts', { token, params }) + }, + + async getInviteCodes(token: string, options?: { + sort?: 'recent' | 'usage' + cursor?: string + limit?: number + }): Promise<{ + cursor?: string + codes: Array<{ + code: string + available: number + disabled: boolean + forAccount: string + createdBy: string + createdAt: string + uses: Array<{ usedBy: string; usedAt: string }> + }> + }> { + const params: Record = {} + if (options?.sort) params.sort = options.sort + if (options?.cursor) params.cursor = options.cursor + if (options?.limit) params.limit = String(options.limit) + return xrpc('com.atproto.admin.getInviteCodes', { token, params }) + }, + + async disableInviteCodes(token: string, codes?: string[], accounts?: string[]): Promise { + await xrpc('com.atproto.admin.disableInviteCodes', { + method: 'POST', + token, + body: { codes, accounts }, + }) + }, + + async getAccountInfo(token: string, did: string): Promise<{ + did: string + handle: string + email?: string + indexedAt: string + emailConfirmedAt?: string + invitesDisabled?: boolean + deactivatedAt?: string + }> { + return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } }) + }, + + async disableAccountInvites(token: string, account: string): Promise { + await xrpc('com.atproto.admin.disableAccountInvites', { + method: 'POST', + token, + body: { account }, + }) + }, + + async enableAccountInvites(token: string, account: string): Promise { + await xrpc('com.atproto.admin.enableAccountInvites', { + method: 'POST', + token, + body: { account }, + }) + }, + + async adminDeleteAccount(token: string, did: string): Promise { + await xrpc('com.atproto.admin.deleteAccount', { + method: 'POST', + token, + body: { did }, + }) + }, + async describeRepo(token: string, repo: string): Promise<{ handle: string did: string diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index 4e502ac..3b577e1 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -1,17 +1,28 @@ import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api' +import { startOAuthLogin, handleOAuthCallback, checkForOAuthCallback, clearOAuthCallbackParams, refreshOAuthToken } from './oauth' const STORAGE_KEY = 'bspds_session' +const ACCOUNTS_KEY = 'bspds_accounts' + +export interface SavedAccount { + did: string + handle: string + accessJwt: string + refreshJwt: string +} interface AuthState { session: Session | null loading: boolean error: string | null + savedAccounts: SavedAccount[] } let state = $state({ session: null, loading: true, error: null, + savedAccounts: [], }) function saveSession(session: Session | null) { @@ -34,20 +45,93 @@ function loadSession(): Session | null { return null } +function loadSavedAccounts(): SavedAccount[] { + const stored = localStorage.getItem(ACCOUNTS_KEY) + if (stored) { + try { + return JSON.parse(stored) + } catch { + return [] + } + } + return [] +} + +function saveSavedAccounts(accounts: SavedAccount[]) { + localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)) +} + +function addOrUpdateSavedAccount(session: Session) { + const accounts = loadSavedAccounts() + const existing = accounts.findIndex(a => a.did === session.did) + const savedAccount: SavedAccount = { + did: session.did, + handle: session.handle, + accessJwt: session.accessJwt, + refreshJwt: session.refreshJwt, + } + if (existing >= 0) { + accounts[existing] = savedAccount + } else { + accounts.push(savedAccount) + } + saveSavedAccounts(accounts) + state.savedAccounts = accounts +} + +function removeSavedAccount(did: string) { + const accounts = loadSavedAccounts().filter(a => a.did !== did) + saveSavedAccounts(accounts) + state.savedAccounts = accounts +} + export async function initAuth() { state.loading = true state.error = null + state.savedAccounts = loadSavedAccounts() + + const oauthCallback = checkForOAuthCallback() + if (oauthCallback) { + clearOAuthCallbackParams() + try { + const tokens = await handleOAuthCallback(oauthCallback.code, oauthCallback.state) + const sessionInfo = await api.getSession(tokens.access_token) + const session: Session = { + ...sessionInfo, + accessJwt: tokens.access_token, + refreshJwt: tokens.refresh_token || '', + } + state.session = session + saveSession(session) + addOrUpdateSavedAccount(session) + state.loading = false + return + } catch (e) { + state.error = e instanceof Error ? e.message : 'OAuth login failed' + state.loading = false + return + } + } + const stored = loadSession() if (stored) { try { const session = await api.getSession(stored.accessJwt) state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt } + addOrUpdateSavedAccount(state.session) } catch (e) { if (e instanceof ApiError && e.status === 401) { try { - const refreshed = await api.refreshSession(stored.refreshJwt) - state.session = refreshed - saveSession(refreshed) + const tokens = await refreshOAuthToken(stored.refreshJwt) + const sessionInfo = await api.getSession(tokens.access_token) + const session: Session = { + ...sessionInfo, + accessJwt: tokens.access_token, + refreshJwt: tokens.refresh_token || stored.refreshJwt, + } + state.session = session + saveSession(session) + addOrUpdateSavedAccount(session) } catch { saveSession(null) state.session = null @@ -68,6 +152,7 @@ export async function login(identifier: string, password: string): Promise const session = await api.createSession(identifier, password) state.session = session saveSession(session) + addOrUpdateSavedAccount(session) } catch (e) { if (e instanceof ApiError) { state.error = e.message @@ -80,6 +165,18 @@ export async function login(identifier: string, password: string): Promise } } +export async function loginWithOAuth(): Promise { + state.loading = true + state.error = null + try { + await startOAuthLogin() + } catch (e) { + state.loading = false + state.error = e instanceof Error ? e.message : 'Failed to start OAuth login' + throw e + } +} + export async function register(params: CreateAccountParams): Promise { try { const result = await api.createAccount(params) @@ -111,6 +208,7 @@ export async function confirmSignup(did: string, verificationCode: string): Prom } state.session = session saveSession(session) + addOrUpdateSavedAccount(session) } catch (e) { if (e instanceof ApiError) { state.error = e.message @@ -146,6 +244,49 @@ export async function logout(): Promise { saveSession(null) } +export async function switchAccount(did: string): Promise { + const account = state.savedAccounts.find(a => a.did === did) + if (!account) { + throw new Error('Account not found') + } + state.loading = true + state.error = null + try { + const session = await api.getSession(account.accessJwt) + state.session = { ...session, accessJwt: account.accessJwt, refreshJwt: account.refreshJwt } + saveSession(state.session) + addOrUpdateSavedAccount(state.session) + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + try { + const tokens = await refreshOAuthToken(account.refreshJwt) + const sessionInfo = await api.getSession(tokens.access_token) + const session: Session = { + ...sessionInfo, + accessJwt: tokens.access_token, + refreshJwt: tokens.refresh_token || account.refreshJwt, + } + state.session = session + saveSession(session) + addOrUpdateSavedAccount(session) + } catch { + removeSavedAccount(did) + state.error = 'Session expired. Please log in again.' + throw new Error('Session expired') + } + } else { + state.error = 'Failed to switch account' + throw e + } + } finally { + state.loading = false + } +} + +export function forgetAccount(did: string): void { + removeSavedAccount(did) +} + export function getAuthState() { return state } @@ -158,15 +299,18 @@ export function isAuthenticated(): boolean { return state.session !== null } -export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) { +export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null; savedAccounts?: SavedAccount[] }) { state.session = newState.session state.loading = newState.loading state.error = newState.error + state.savedAccounts = newState.savedAccounts ?? [] } export function _testReset() { state.session = null state.loading = true state.error = null + state.savedAccounts = [] localStorage.removeItem(STORAGE_KEY) + localStorage.removeItem(ACCOUNTS_KEY) } diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts new file mode 100644 index 0000000..dd33feb --- /dev/null +++ b/frontend/src/lib/oauth.ts @@ -0,0 +1,181 @@ +const OAUTH_STATE_KEY = 'bspds_oauth_state' +const OAUTH_VERIFIER_KEY = 'bspds_oauth_verifier' + +interface OAuthState { + state: string + codeVerifier: string + returnTo?: string +} + +function generateRandomString(length: number): string { + const array = new Uint8Array(length) + crypto.getRandomValues(array) + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +async function sha256(plain: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(plain) + return crypto.subtle.digest('SHA-256', data) +} + +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +async function generateCodeChallenge(verifier: string): Promise { + const hash = await sha256(verifier) + return base64UrlEncode(hash) +} + +function generateState(): string { + return generateRandomString(32) +} + +function generateCodeVerifier(): string { + return generateRandomString(32) +} + +function saveOAuthState(state: OAuthState): void { + sessionStorage.setItem(OAUTH_STATE_KEY, state.state) + sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier) +} + +function getOAuthState(): OAuthState | null { + const state = sessionStorage.getItem(OAUTH_STATE_KEY) + const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY) + if (!state || !codeVerifier) return null + return { state, codeVerifier } +} + +function clearOAuthState(): void { + sessionStorage.removeItem(OAUTH_STATE_KEY) + sessionStorage.removeItem(OAUTH_VERIFIER_KEY) +} + +export async function startOAuthLogin(): Promise { + const state = generateState() + const codeVerifier = generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + + saveOAuthState({ state, codeVerifier }) + + const clientId = `${window.location.origin}/oauth/client-metadata.json` + const redirectUri = `${window.location.origin}/` + + const parResponse = await fetch('/oauth/par', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'atproto transition:generic', + state: state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }), + }) + + if (!parResponse.ok) { + const error = await parResponse.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(error.error_description || error.error || 'Failed to start OAuth flow') + } + + const { request_uri } = await parResponse.json() + + const authorizeUrl = new URL('/oauth/authorize', window.location.origin) + authorizeUrl.searchParams.set('client_id', clientId) + authorizeUrl.searchParams.set('request_uri', request_uri) + + window.location.href = authorizeUrl.toString() +} + +export interface OAuthTokens { + access_token: string + refresh_token?: string + token_type: string + expires_in?: number + scope?: string + sub: string +} + +export async function handleOAuthCallback(code: string, state: string): Promise { + const savedState = getOAuthState() + if (!savedState) { + throw new Error('No OAuth state found. Please try logging in again.') + } + + if (savedState.state !== state) { + clearOAuthState() + throw new Error('OAuth state mismatch. Please try logging in again.') + } + + const clientId = `${window.location.origin}/oauth/client-metadata.json` + const redirectUri = `${window.location.origin}/` + + const tokenResponse = await fetch('/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: savedState.codeVerifier, + }), + }) + + clearOAuthState() + + if (!tokenResponse.ok) { + const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(error.error_description || error.error || 'Failed to exchange code for tokens') + } + + return tokenResponse.json() +} + +export async function refreshOAuthToken(refreshToken: string): Promise { + const clientId = `${window.location.origin}/oauth/client-metadata.json` + + const tokenResponse = await fetch('/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken, + }), + }) + + if (!tokenResponse.ok) { + const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(error.error_description || error.error || 'Failed to refresh token') + } + + return tokenResponse.json() +} + +export function checkForOAuthCallback(): { code: string; state: string } | null { + const params = new URLSearchParams(window.location.search) + const code = params.get('code') + const state = params.get('state') + + if (code && state) { + return { code, state } + } + + return null +} + +export function clearOAuthCallbackParams(): void { + const url = new URL(window.location.href) + url.search = '' + window.history.replaceState({}, '', url.toString()) +} diff --git a/frontend/src/routes/Admin.svelte b/frontend/src/routes/Admin.svelte new file mode 100644 index 0000000..8a8a5a1 --- /dev/null +++ b/frontend/src/routes/Admin.svelte @@ -0,0 +1,744 @@ + +{#if auth.session?.isAdmin} +
+
+ ← Dashboard +

Admin Panel

+
+ {#if loading} +

Loading...

+ {:else} + {#if error} +
{error}
+ {/if} + {#if stats} +
+

Server Statistics

+
+
+
{formatNumber(stats.userCount)}
+
Users
+
+
+
{formatNumber(stats.repoCount)}
+
Repositories
+
+
+
{formatNumber(stats.recordCount)}
+
Records
+
+
+
{formatBytes(stats.blobStorageBytes)}
+
Blob Storage
+
+
+ +
+ {/if} +
+

User Management

+
+ + +
+ {#if usersError} +
{usersError}
+ {/if} + {#if showUsers} +
+ {#if users.length === 0} +

No users found

+ {:else} + + + + + + + + + + + {#each users as user} + selectUser(user.did)}> + + + + + + {/each} + +
HandleEmailStatusCreated
@{user.handle} + {#if user.deactivatedAt} + Deactivated + {:else if user.emailConfirmedAt} + Verified + {:else} + Unverified + {/if} + {new Date(user.indexedAt).toLocaleDateString()}
+ {#if usersCursor} + + {/if} + {/if} +
+ {/if} +
+
+

Invite Codes

+
+ +
+ {#if invitesError} +
{invitesError}
+ {/if} + {#if showInvites} +
+ {#if invites.length === 0} +

No invite codes found

+ {:else} + + + + + + + + + + + + + {#each invites as invite} + + + + + + + + + {/each} + +
CodeAvailableUsesStatusCreatedActions
{invite.code}{invite.available}{invite.uses.length} + {#if invite.disabled} + Disabled + {:else if invite.available === 0} + Exhausted + {:else} + Active + {/if} + {new Date(invite.createdAt).toLocaleDateString()} + {#if !invite.disabled} + + {:else} + - + {/if} +
+ {#if invitesCursor} + + {/if} + {/if} +
+ {/if} +
+ {/if} +
+ {#if selectedUser} + + {/if} +{:else if auth.loading} +
Loading...
+{/if} + diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index 9634ab6..311d671 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -1,7 +1,9 @@ {#if auth.session}

Dashboard

- +
{:else if auth.loading} @@ -99,7 +180,13 @@ header h1 { margin: 0; } - .logout { + .account-dropdown { + position: relative; + } + .account-trigger { + display: flex; + align-items: center; + gap: 0.5rem; padding: 0.5rem 1rem; background: transparent; border: 1px solid var(--border-color-light); @@ -107,9 +194,66 @@ cursor: pointer; color: var(--text-primary); } - .logout:hover { + .account-trigger:hover:not(:disabled) { background: var(--bg-secondary); } + .account-trigger:disabled { + opacity: 0.6; + cursor: not-allowed; + } + .account-trigger .account-handle { + font-weight: 500; + } + .dropdown-arrow { + font-size: 0.625rem; + color: var(--text-secondary); + } + .dropdown-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.25rem; + min-width: 200px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 100; + overflow: hidden; + } + .dropdown-section { + padding: 0.5rem 0; + } + .dropdown-label { + display: block; + padding: 0.25rem 1rem; + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + } + .dropdown-item { + display: block; + width: 100%; + padding: 0.75rem 1rem; + background: transparent; + border: none; + text-align: left; + cursor: pointer; + color: var(--text-primary); + font-size: 0.875rem; + } + .dropdown-item:hover { + background: var(--bg-secondary); + } + .dropdown-item.logout-item { + color: var(--error-text); + } + .dropdown-divider { + height: 1px; + background: var(--border-color); + margin: 0; + } section { background: var(--bg-secondary); padding: 1.5rem; @@ -153,6 +297,10 @@ background: var(--warning-bg); color: var(--warning-text); } + .badge.admin { + background: var(--accent); + color: white; + } .nav-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); @@ -181,6 +329,13 @@ color: var(--text-secondary); font-size: 0.875rem; } + .nav-card.admin-card { + border-color: var(--accent); + background: linear-gradient(135deg, var(--bg-card) 0%, rgba(77, 166, 255, 0.05) 100%); + } + .nav-card.admin-card:hover { + box-shadow: 0 2px 12px rgba(77, 166, 255, 0.25); + } .loading { text-align: center; padding: 4rem; diff --git a/frontend/src/routes/Login.svelte b/frontend/src/routes/Login.svelte index f9387d8..26f7bbb 100644 --- a/frontend/src/routes/Login.svelte +++ b/frontend/src/routes/Login.svelte @@ -1,41 +1,36 @@
@@ -177,10 +242,23 @@ Verified {:else} Not verified + {/if} {/if}

Your Telegram username without the @ symbol

+ {#if verifyingChannel === 'telegram'} +
+ + + +
+ {/if}
@@ -197,12 +275,31 @@ Verified {:else} Not verified + {/if} {/if}

Your Signal phone number with country code

+ {#if verifyingChannel === 'signal'} +
+ + + +
+ {/if} + {#if verificationError} +
{verificationError}
+ {/if} + {#if verificationSuccess} +
{verificationSuccess}
+ {/if}
+
+

Notification History

+

View recent notifications sent to your account.

+ {#if !showHistory} + + {:else} + + {#if historyError} +
{historyError}
+ {:else if notifications.length === 0} +

No notifications found.

+ {:else} +
+ {#each notifications as notification} +
+
+ {notification.notificationType} + {notification.channel} + {notification.status} +
+ {#if notification.subject} +
{notification.subject}
+ {/if} +
{notification.body}
+
{formatDate(notification.createdAt)}
+
+ {/each} +
+ {/if} + {/if} +
{/if} diff --git a/frontend/src/routes/ResetPassword.svelte b/frontend/src/routes/ResetPassword.svelte new file mode 100644 index 0000000..95e972e --- /dev/null +++ b/frontend/src/routes/ResetPassword.svelte @@ -0,0 +1,223 @@ + +
+ {#if error} +
{error}
+ {/if} + {#if success} +
{success}
+ {/if} + {#if tokenSent} +

Reset Password

+

Enter the code from your email and choose a new password.

+
+
+ + +
+
+ + +
+
+ + +
+ + +
+ {:else} +

Forgot Password

+

Enter your email address and we'll send you a code to reset your password.

+
+
+ + +
+ +
+ {/if} + +
+ diff --git a/frontend/src/routes/Sessions.svelte b/frontend/src/routes/Sessions.svelte new file mode 100644 index 0000000..b071e24 --- /dev/null +++ b/frontend/src/routes/Sessions.svelte @@ -0,0 +1,240 @@ + +
+
+ ← Dashboard +

Active Sessions

+
+ {#if loading} +

Loading sessions...

+ {:else} + {#if error} +
{error}
+ {/if} + {#if sessions.length === 0} +

No active sessions found.

+ {:else} +
+ {#each sessions as session} +
+
+
+ {#if session.isCurrent} + Current Session + {:else} + Session + {/if} +
+
+
+ Created: + {timeAgo(session.createdAt)} +
+
+ Expires: + {formatDate(session.expiresAt)} +
+
+
+
+ +
+
+ {/each} +
+ + {/if} + {/if} +
+ diff --git a/frontend/src/routes/Settings.svelte b/frontend/src/routes/Settings.svelte index 0faf881..a83acdd 100644 --- a/frontend/src/routes/Settings.svelte +++ b/frontend/src/routes/Settings.svelte @@ -14,6 +14,11 @@ let deletePassword = $state('') let deleteToken = $state('') let deleteTokenSent = $state(false) + let exportLoading = $state(false) + let passwordLoading = $state(false) + let currentPassword = $state('') + let newPassword = $state('') + let confirmNewPassword = $state('') $effect(() => { if (!auth.loading && !auth.session) { navigate('/login') @@ -110,6 +115,61 @@ deleteLoading = false } } + async function handleExportRepo() { + if (!auth.session) return + exportLoading = true + message = null + try { + const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, { + headers: { + 'Authorization': `Bearer ${auth.session.accessJwt}` + } + }) + if (!response.ok) { + const err = await response.json().catch(() => ({ message: 'Export failed' })) + throw new Error(err.message || 'Export failed') + } + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${auth.session.handle}-repo.car` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + showMessage('success', 'Repository exported successfully') + } catch (e) { + showMessage('error', e instanceof Error ? e.message : 'Failed to export repository') + } finally { + exportLoading = false + } + } + async function handleChangePassword(e: Event) { + e.preventDefault() + if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return + if (newPassword !== confirmNewPassword) { + showMessage('error', 'Passwords do not match') + return + } + if (newPassword.length < 8) { + showMessage('error', 'Password must be at least 8 characters') + return + } + passwordLoading = true + message = null + try { + await api.changePassword(auth.session.accessJwt, currentPassword, newPassword) + showMessage('success', 'Password changed successfully') + currentPassword = '' + newPassword = '' + confirmNewPassword = '' + } catch (e) { + showMessage('error', e instanceof ApiError ? e.message : 'Failed to change password') + } finally { + passwordLoading = false + } + }
@@ -187,6 +247,55 @@ +
+

Change Password

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+

Export Data

+

Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.

+ +

Delete Account

This action is irreversible. All your data will be permanently deleted.

@@ -275,7 +384,7 @@ margin: 0 0 0.5rem 0; font-size: 1.125rem; } - .current { + .current, .description { color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem; diff --git a/src/api/actor/profile.rs b/src/api/actor/profile.rs index 9c0c398..60ba0c0 100644 --- a/src/api/actor/profile.rs +++ b/src/api/actor/profile.rs @@ -2,7 +2,7 @@ use crate::api::proxy_client::proxy_client; use crate::state::AppState; use axum::{ Json, - extract::{Query, State}, + extract::{Query, RawQuery, State}, http::StatusCode, response::{IntoResponse, Response}, }; @@ -17,11 +17,6 @@ pub struct GetProfileParams { pub actor: String, } -#[derive(Deserialize)] -pub struct GetProfilesParams { - pub actors: String, -} - #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ProfileViewDetailed { @@ -71,14 +66,15 @@ fn munge_profile_with_local(profile: &mut ProfileViewDetailed, local_record: &Va } async fn proxy_to_appview( + state: &AppState, method: &str, params: &HashMap, auth_did: &str, auth_key_bytes: Option<&[u8]>, ) -> Result<(StatusCode, Value), Response> { - let appview_url = match std::env::var("APPVIEW_URL") { - Ok(url) => url, - Err(_) => { + let resolved = match state.appview_registry.get_appview_for_method(method).await { + Some(r) => r, + None => { return Err(( StatusCode::BAD_GATEWAY, Json( @@ -88,14 +84,51 @@ async fn proxy_to_appview( .into_response()); } }; - let target_url = format!("{}/xrpc/{}", appview_url, method); + let target_url = format!("{}/xrpc/{}", resolved.url, method); info!("Proxying GET request to {}", target_url); let client = proxy_client(); - let mut request_builder = client.get(&target_url).query(params); + let request_builder = client.get(&target_url).query(params); + proxy_request(request_builder, auth_did, auth_key_bytes, method, &resolved.did).await +} + +async fn proxy_to_appview_raw( + state: &AppState, + method: &str, + raw_query: Option<&str>, + auth_did: &str, + auth_key_bytes: Option<&[u8]>, +) -> Result<(StatusCode, Value), Response> { + let resolved = match state.appview_registry.get_appview_for_method(method).await { + Some(r) => r, + None => { + return Err(( + StatusCode::BAD_GATEWAY, + Json( + json!({"error": "UpstreamError", "message": "No upstream AppView configured"}), + ), + ) + .into_response()); + } + }; + let target_url = match raw_query { + Some(q) => format!("{}/xrpc/{}?{}", resolved.url, method, q), + None => format!("{}/xrpc/{}", resolved.url, method), + }; + info!("Proxying GET request to {}", target_url); + let client = proxy_client(); + let request_builder = client.get(&target_url); + proxy_request(request_builder, auth_did, auth_key_bytes, method, &resolved.did).await +} + +async fn proxy_request( + mut request_builder: reqwest::RequestBuilder, + auth_did: &str, + auth_key_bytes: Option<&[u8]>, + method: &str, + appview_did: &str, +) -> Result<(StatusCode, Value), Response> { if let Some(key_bytes) = auth_key_bytes { - let appview_did = - std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); - match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) { + match crate::auth::create_service_token(auth_did, appview_did, method, key_bytes) { Ok(service_token) => { request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token)); @@ -167,6 +200,7 @@ pub async fn get_profile( let mut query_params = HashMap::new(); query_params.insert("actor".to_string(), params.actor.clone()); let (status, body) = match proxy_to_appview( + &state, "app.bsky.actor.getProfile", &query_params, auth_did.as_deref().unwrap_or(""), @@ -201,7 +235,7 @@ pub async fn get_profile( pub async fn get_profiles( State(state): State, headers: axum::http::HeaderMap, - Query(params): Query, + RawQuery(raw_query): RawQuery, ) -> Response { let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); let auth_user = if let Some(h) = auth_header { @@ -217,11 +251,10 @@ pub async fn get_profiles( }; let auth_did = auth_user.as_ref().map(|u| u.did.clone()); let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone()); - let mut query_params = HashMap::new(); - query_params.insert("actors".to_string(), params.actors.clone()); - let (status, body) = match proxy_to_appview( + let (status, body) = match proxy_to_appview_raw( + &state, "app.bsky.actor.getProfiles", - &query_params, + raw_query.as_deref(), auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref(), ) diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs index 50db839..b48d7d2 100644 --- a/src/api/admin/account/info.rs +++ b/src/api/admin/account/info.rs @@ -2,7 +2,7 @@ use crate::auth::BearerAuthAdmin; use crate::state::AppState; use axum::{ Json, - extract::{Query, State}, + extract::{Query, RawQuery, State}, http::StatusCode, response::{IntoResponse, Response}, }; @@ -88,17 +88,31 @@ pub async fn get_account_info( } } -#[derive(Deserialize)] -pub struct GetAccountInfosParams { - pub dids: String, +fn parse_repeated_param(query: Option<&str>, key: &str) -> Vec { + query + .map(|q| { + q.split('&') + .filter_map(|pair| { + let mut parts = pair.splitn(2, '='); + let k = parts.next()?; + let v = parts.next()?; + if k == key { + Some(urlencoding::decode(v).ok()?.into_owned()) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default() } pub async fn get_account_infos( State(state): State, _auth: BearerAuthAdmin, - Query(params): Query, + RawQuery(raw_query): RawQuery, ) -> Response { - let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect(); + let dids = parse_repeated_param(raw_query.as_deref(), "dids"); if dids.is_empty() { return ( StatusCode::BAD_REQUEST, @@ -107,7 +121,7 @@ pub async fn get_account_infos( .into_response(); } let mut infos = Vec::new(); - for did in dids { + for did in &dids { if did.is_empty() { continue; } diff --git a/src/api/admin/account/mod.rs b/src/api/admin/account/mod.rs index 511f803..4142316 100644 --- a/src/api/admin/account/mod.rs +++ b/src/api/admin/account/mod.rs @@ -1,19 +1,15 @@ mod delete; mod email; mod info; -mod profile; +mod search; mod update; pub use delete::{DeleteAccountInput, delete_account}; pub use email::{SendEmailInput, SendEmailOutput, send_email}; pub use info::{ - AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, GetAccountInfosParams, - get_account_info, get_account_infos, -}; -pub use profile::{ - CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput, create_profile, - create_record_admin, + AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, get_account_info, get_account_infos, }; +pub use search::{SearchAccountsOutput, SearchAccountsParams, search_accounts}; pub use update::{ UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput, update_account_email, update_account_handle, update_account_password, diff --git a/src/api/admin/account/profile.rs b/src/api/admin/account/profile.rs deleted file mode 100644 index e9bd784..0000000 --- a/src/api/admin/account/profile.rs +++ /dev/null @@ -1,133 +0,0 @@ -use crate::api::repo::record::create_record_internal; -use crate::auth::BearerAuthAdmin; -use crate::state::AppState; -use axum::{ - Json, - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tracing::{error, info}; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateProfileInput { - pub did: String, - pub display_name: Option, - pub description: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateRecordAdminInput { - pub did: String, - pub collection: String, - pub rkey: Option, - pub record: serde_json::Value, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateProfileOutput { - pub uri: String, - pub cid: String, -} - -pub async fn create_profile( - State(state): State, - _auth: BearerAuthAdmin, - Json(input): Json, -) -> Response { - let did = input.did.trim(); - if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); - } - - let mut profile_record = json!({ - "$type": "app.bsky.actor.profile" - }); - - if let Some(display_name) = &input.display_name { - profile_record["displayName"] = json!(display_name); - } - if let Some(description) = &input.description { - profile_record["description"] = json!(description); - } - - match create_record_internal( - &state, - did, - "app.bsky.actor.profile", - "self", - &profile_record, - ) - .await - { - Ok((uri, commit_cid)) => { - info!(did = %did, uri = %uri, "Created profile for user"); - ( - StatusCode::OK, - Json(CreateProfileOutput { - uri, - cid: commit_cid.to_string(), - }), - ) - .into_response() - } - Err(e) => { - error!("Failed to create profile for {}: {}", did, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response() - } - } -} - -pub async fn create_record_admin( - State(state): State, - _auth: BearerAuthAdmin, - Json(input): Json, -) -> Response { - let did = input.did.trim(); - if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); - } - - let rkey = input - .rkey - .unwrap_or_else(|| chrono::Utc::now().format("%Y%m%d%H%M%S%f").to_string()); - - match create_record_internal(&state, did, &input.collection, &rkey, &input.record).await { - Ok((uri, commit_cid)) => { - info!(did = %did, uri = %uri, "Admin created record"); - ( - StatusCode::OK, - Json(CreateProfileOutput { - uri, - cid: commit_cid.to_string(), - }), - ) - .into_response() - } - Err(e) => { - error!("Failed to create record for {}: {}", did, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response() - } - } -} diff --git a/src/api/admin/account/search.rs b/src/api/admin/account/search.rs new file mode 100644 index 0000000..c14d09e --- /dev/null +++ b/src/api/admin/account/search.rs @@ -0,0 +1,114 @@ +use crate::auth::BearerAuthAdmin; +use crate::state::AppState; +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::error; + +#[derive(Deserialize)] +pub struct SearchAccountsParams { + pub handle: Option, + pub cursor: Option, + #[serde(default = "default_limit")] + pub limit: i64, +} + +fn default_limit() -> i64 { + 50 +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountView { + pub did: String, + pub handle: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + pub indexed_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email_confirmed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deactivated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub invites_disabled: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchAccountsOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + pub accounts: Vec, +} + +pub async fn search_accounts( + State(state): State, + _auth: BearerAuthAdmin, + Query(params): Query, +) -> Response { + let limit = params.limit.clamp(1, 100); + let cursor_did = params.cursor.as_deref().unwrap_or(""); + let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h)); + let result = sqlx::query_as::<_, (String, String, Option, chrono::DateTime, bool, Option>)>( + r#" + SELECT did, handle, email, created_at, email_confirmed, deactivated_at + FROM users + WHERE did > $1 AND ($2::text IS NULL OR handle ILIKE $2) + ORDER BY did ASC + LIMIT $3 + "#, + ) + .bind(cursor_did) + .bind(&handle_filter) + .bind(limit + 1) + .fetch_all(&state.db) + .await; + match result { + Ok(rows) => { + let has_more = rows.len() > limit as usize; + let accounts: Vec = rows + .into_iter() + .take(limit as usize) + .map(|(did, handle, email, created_at, email_confirmed, deactivated_at)| AccountView { + did: did.clone(), + handle, + email, + indexed_at: created_at.to_rfc3339(), + email_confirmed_at: if email_confirmed { + Some(created_at.to_rfc3339()) + } else { + None + }, + deactivated_at: deactivated_at.map(|dt| dt.to_rfc3339()), + invites_disabled: None, + }) + .collect(); + let next_cursor = if has_more { + accounts.last().map(|a| a.did.clone()) + } else { + None + }; + ( + StatusCode::OK, + Json(SearchAccountsOutput { + cursor: next_cursor, + accounts, + }), + ) + .into_response() + } + Err(e) => { + error!("DB error in search_accounts: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 268f1ba..89c1c26 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -4,8 +4,8 @@ pub mod server_stats; pub mod status; pub use account::{ - create_profile, create_record_admin, delete_account, get_account_info, get_account_infos, - send_email, update_account_email, update_account_handle, update_account_password, + delete_account, get_account_info, get_account_infos, search_accounts, send_email, + update_account_email, update_account_handle, update_account_password, }; pub use invite::{ disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes, diff --git a/src/api/feed/actor_likes.rs b/src/api/feed/actor_likes.rs index 3dc710c..e234384 100644 --- a/src/api/feed/actor_likes.rs +++ b/src/api/feed/actor_likes.rs @@ -1,6 +1,6 @@ use crate::api::read_after_write::{ FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, extract_repo_rev, - format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview, + format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview_via_registry, }; use crate::state::AppState; use axum::{ @@ -87,7 +87,8 @@ pub async fn get_actor_likes( if let Some(cursor) = ¶ms.cursor { query_params.insert("cursor".to_string(), cursor.clone()); } - let proxy_result = match proxy_to_appview( + let proxy_result = match proxy_to_appview_via_registry( + &state, "app.bsky.feed.getActorLikes", &query_params, auth_did.as_deref().unwrap_or(""), diff --git a/src/api/feed/author_feed.rs b/src/api/feed/author_feed.rs index a282638..2b99a05 100644 --- a/src/api/feed/author_feed.rs +++ b/src/api/feed/author_feed.rs @@ -1,7 +1,7 @@ use crate::api::read_after_write::{ FeedOutput, FeedViewPost, ProfileRecord, RecordDescript, extract_repo_rev, format_local_post, format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed, - proxy_to_appview, + proxy_to_appview_via_registry, }; use crate::state::AppState; use axum::{ @@ -70,7 +70,8 @@ pub async fn get_author_feed( if let Some(include_pins) = params.include_pins { query_params.insert("includePins".to_string(), include_pins.to_string()); } - let proxy_result = match proxy_to_appview( + let proxy_result = match proxy_to_appview_via_registry( + &state, "app.bsky.feed.getAuthorFeed", &query_params, auth_did.as_deref().unwrap_or(""), diff --git a/src/api/feed/custom_feed.rs b/src/api/feed/custom_feed.rs index 40202b8..6a02137 100644 --- a/src/api/feed/custom_feed.rs +++ b/src/api/feed/custom_feed.rs @@ -37,14 +37,14 @@ pub async fn get_feed( if let Err(e) = validate_at_uri(¶ms.feed) { return ApiError::InvalidRequest(format!("Invalid feed URI: {}", e)).into_response(); } - let appview_url = match std::env::var("APPVIEW_URL") { - Ok(url) => url, - Err(_) => { - return ApiError::UpstreamUnavailable("No upstream AppView configured".to_string()) + let resolved = match state.appview_registry.get_appview_for_method("app.bsky.feed.getFeed").await { + Some(r) => r, + None => { + return ApiError::UpstreamUnavailable("No upstream AppView configured for app.bsky.feed.getFeed".to_string()) .into_response(); } }; - if let Err(e) = is_ssrf_safe(&appview_url) { + if let Err(e) = is_ssrf_safe(&resolved.url) { error!("SSRF check failed for appview URL: {}", e); return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)) .into_response(); @@ -56,16 +56,14 @@ pub async fn get_feed( if let Some(cursor) = ¶ms.cursor { query_params.insert("cursor".to_string(), cursor.clone()); } - let target_url = format!("{}/xrpc/app.bsky.feed.getFeed", appview_url); + let target_url = format!("{}/xrpc/app.bsky.feed.getFeed", resolved.url); info!(target = %target_url, feed = %params.feed, "Proxying getFeed request"); let client = proxy_client(); let mut request_builder = client.get(&target_url).query(&query_params); if let Some(key_bytes) = auth_user.key_bytes.as_ref() { - let appview_did = - std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); match crate::auth::create_service_token( &auth_user.did, - &appview_did, + &resolved.did, "app.bsky.feed.getFeed", key_bytes, ) { diff --git a/src/api/feed/post_thread.rs b/src/api/feed/post_thread.rs index a7c85e6..a680d6a 100644 --- a/src/api/feed/post_thread.rs +++ b/src/api/feed/post_thread.rs @@ -1,6 +1,6 @@ use crate::api::read_after_write::{ PostRecord, PostView, RecordDescript, extract_repo_rev, format_local_post, - format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview, + format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview_via_registry, }; use crate::state::AppState; use axum::{ @@ -153,7 +153,8 @@ pub async fn get_post_thread( if let Some(parent_height) = params.parent_height { query_params.insert("parentHeight".to_string(), parent_height.to_string()); } - let proxy_result = match proxy_to_appview( + let proxy_result = match proxy_to_appview_via_registry( + &state, "app.bsky.feed.getPostThread", &query_params, auth_did.as_deref().unwrap_or(""), diff --git a/src/api/feed/timeline.rs b/src/api/feed/timeline.rs index e05af1f..0acc1b0 100644 --- a/src/api/feed/timeline.rs +++ b/src/api/feed/timeline.rs @@ -1,7 +1,7 @@ use crate::api::read_after_write::{ FeedOutput, FeedViewPost, PostView, extract_repo_rev, format_local_post, format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed, - proxy_to_appview, + proxy_to_appview_via_registry, }; use crate::state::AppState; use axum::{ @@ -50,17 +50,14 @@ pub async fn get_timeline( .into_response(); } }; - match std::env::var("APPVIEW_URL") { - Ok(url) if !url.starts_with("http://127.0.0.1") => { - return get_timeline_with_appview( - &state, - ¶ms, - &auth_user.did, - auth_user.key_bytes.as_deref(), - ) - .await; - } - _ => {} + if state.appview_registry.get_appview_for_method("app.bsky.feed.getTimeline").await.is_some() { + return get_timeline_with_appview( + &state, + ¶ms, + &auth_user.did, + auth_user.key_bytes.as_deref(), + ) + .await; } get_timeline_local_only(&state, &auth_user.did).await } @@ -81,7 +78,8 @@ async fn get_timeline_with_appview( if let Some(cursor) = ¶ms.cursor { query_params.insert("cursor".to_string(), cursor.clone()); } - let proxy_result = match proxy_to_appview( + let proxy_result = match proxy_to_appview_via_registry( + state, "app.bsky.feed.getTimeline", &query_params, auth_did, diff --git a/src/api/notification/register_push.rs b/src/api/notification/register_push.rs index ddebb66..1c8b3d4 100644 --- a/src/api/notification/register_push.rs +++ b/src/api/notification/register_push.rs @@ -53,14 +53,14 @@ pub async fn register_push( if input.app_id.is_empty() || input.app_id.len() > 256 { return ApiError::InvalidRequest("Invalid appId".to_string()).into_response(); } - let appview_url = match std::env::var("APPVIEW_URL") { - Ok(url) => url, - Err(_) => { - return ApiError::UpstreamUnavailable("No upstream AppView configured".to_string()) + let resolved = match state.appview_registry.get_appview_for_method("app.bsky.notification.registerPush").await { + Some(r) => r, + None => { + return ApiError::UpstreamUnavailable("No upstream AppView configured for app.bsky.notification.registerPush".to_string()) .into_response(); } }; - if let Err(e) = is_ssrf_safe(&appview_url) { + if let Err(e) = is_ssrf_safe(&resolved.url) { error!("SSRF check failed for appview URL: {}", e); return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)) .into_response(); @@ -102,7 +102,7 @@ pub async fn register_push( return ApiError::InternalError.into_response(); } }; - let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", appview_url); + let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", resolved.url); info!( target = %target_url, service_did = %input.service_did, diff --git a/src/api/proxy.rs b/src/api/proxy.rs index b82aeb6..8b5a798 100644 --- a/src/api/proxy.rs +++ b/src/api/proxy.rs @@ -2,30 +2,18 @@ use crate::api::proxy_client::proxy_client; use crate::state::AppState; use axum::{ body::Bytes, - extract::{Path, Query, State}, + extract::{Path, RawQuery, State}, http::{HeaderMap, Method, StatusCode}, response::{IntoResponse, Response}, }; -use std::collections::HashMap; -use tracing::error; - -fn resolve_service_did(did_with_fragment: &str) -> Option<(String, String)> { - if let Some(without_prefix) = did_with_fragment.strip_prefix("did:web:") { - let host = without_prefix.split('#').next()?; - let url = format!("https://{}", host); - let did_without_fragment = format!("did:web:{}", host); - Some((url, did_without_fragment)) - } else { - None - } -} +use tracing::{error, info, warn}; pub async fn proxy_handler( State(state): State, Path(method): Path, method_verb: Method, headers: HeaderMap, - Query(params): Query>, + RawQuery(query): RawQuery, body: Bytes, ) -> Response { let proxy_header = headers @@ -34,54 +22,70 @@ pub async fn proxy_handler( .map(|s| s.to_string()); let (appview_url, service_aud) = match &proxy_header { Some(did_str) => { - let (url, did_without_fragment) = match resolve_service_did(did_str) { - Some(resolved) => resolved, + let did_without_fragment = did_str.split('#').next().unwrap_or(did_str).to_string(); + match state.appview_registry.resolve_appview_did(&did_without_fragment).await { + Some(resolved) => (resolved.url, Some(resolved.did)), None => { error!(did = %did_str, "Could not resolve service DID"); return (StatusCode::BAD_GATEWAY, "Could not resolve service DID") .into_response(); } - }; - (url, Some(did_without_fragment)) + } } None => { - let url = match std::env::var("APPVIEW_URL") { - Ok(url) => url, - Err(_) => { - return (StatusCode::BAD_GATEWAY, "No upstream AppView configured") + match state.appview_registry.get_appview_for_method(&method).await { + Some(resolved) => (resolved.url, Some(resolved.did)), + None => { + return (StatusCode::BAD_GATEWAY, "No upstream AppView configured for this method") .into_response(); } - }; - let aud = std::env::var("APPVIEW_DID").ok(); - (url, aud) + } } }; - let target_url = format!("{}/xrpc/{}", appview_url, method); + let target_url = match &query { + Some(q) => format!("{}/xrpc/{}?{}", appview_url, method, q), + None => format!("{}/xrpc/{}", appview_url, method), + }; + info!("Proxying {} request to {}", method_verb, target_url); let client = proxy_client(); - let mut request_builder = client.request(method_verb, &target_url).query(¶ms); + let mut request_builder = client.request(method_verb, &target_url); let mut auth_header_val = headers.get("Authorization").cloned(); - if let Some(aud) = &service_aud - && let Some(token) = crate::auth::extract_bearer_token_from_header( + if let Some(aud) = &service_aud { + if let Some(token) = crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) - && let Ok(auth_user) = crate::auth::validate_bearer_token(&state.db, &token).await - && let Some(key_bytes) = auth_user.key_bytes - && let Ok(new_token) = - crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes) - && let Ok(val) = - axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token)) - { - auth_header_val = Some(val); + ) { + match crate::auth::validate_bearer_token(&state.db, &token).await { + Ok(auth_user) => { + if let Some(key_bytes) = auth_user.key_bytes { + match crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes) { + Ok(new_token) => { + if let Ok(val) = axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token)) { + auth_header_val = Some(val); + } + } + Err(e) => { + warn!("Failed to create service token: {:?}", e); + } } + } + } + Err(e) => { + warn!("Token validation failed: {:?}", e); + } + } + } + } if let Some(val) = auth_header_val { request_builder = request_builder.header("Authorization", val); } - for (key, value) in headers.iter() { - if key != "host" && key != "content-length" && key != "authorization" { - request_builder = request_builder.header(key, value); + for header_name in crate::api::proxy_client::HEADERS_TO_FORWARD { + if let Some(val) = headers.get(*header_name) { + request_builder = request_builder.header(*header_name, val); } } - request_builder = request_builder.body(body); + if !body.is_empty() { + request_builder = request_builder.body(body); + } match request_builder.send().await { Ok(resp) => { let status = resp.status(); @@ -95,8 +99,10 @@ pub async fn proxy_handler( } }; let mut response_builder = Response::builder().status(status); - for (key, value) in headers.iter() { - response_builder = response_builder.header(key, value); + for header_name in crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD { + if let Some(val) = headers.get(*header_name) { + response_builder = response_builder.header(*header_name, val); + } } match response_builder.body(axum::body::Body::from(body)) { Ok(r) => r, diff --git a/src/api/proxy_client.rs b/src/api/proxy_client.rs index 280f1ef..6f2965b 100644 --- a/src/api/proxy_client.rs +++ b/src/api/proxy_client.rs @@ -121,12 +121,15 @@ pub const HEADERS_TO_FORWARD: &[&str] = &[ "accept-language", "atproto-accept-labelers", "x-bsky-topics", + "content-type", ]; pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[ "atproto-repo-rev", "atproto-content-labelers", "retry-after", "content-type", + "cache-control", + "etag", ]; pub fn validate_at_uri(uri: &str) -> Result { diff --git a/src/api/read_after_write.rs b/src/api/read_after_write.rs index cfe2376..4970a7d 100644 --- a/src/api/read_after_write.rs +++ b/src/api/read_after_write.rs @@ -238,16 +238,28 @@ impl ProxyResponse { } } -pub async fn proxy_to_appview( +pub async fn proxy_to_appview_via_registry( + state: &AppState, method: &str, params: &HashMap, auth_did: &str, auth_key_bytes: Option<&[u8]>, ) -> Result { - let appview_url = std::env::var("APPVIEW_URL").map_err(|_| { - ApiError::UpstreamUnavailable("No upstream AppView configured".to_string()).into_response() + let resolved = state.appview_registry.get_appview_for_method(method).await.ok_or_else(|| { + ApiError::UpstreamUnavailable(format!("No AppView configured for method: {}", method)).into_response() })?; - if let Err(e) = is_ssrf_safe(&appview_url) { + proxy_to_appview_with_url(method, params, auth_did, auth_key_bytes, &resolved.url, &resolved.did).await +} + +pub async fn proxy_to_appview_with_url( + method: &str, + params: &HashMap, + auth_did: &str, + auth_key_bytes: Option<&[u8]>, + appview_url: &str, + appview_did: &str, +) -> Result { + if let Err(e) = is_ssrf_safe(appview_url) { error!("SSRF check failed for appview URL: {}", e); return Err( ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)).into_response(), @@ -258,9 +270,7 @@ pub async fn proxy_to_appview( let client = proxy_client(); let mut request_builder = client.get(&target_url).query(params); if let Some(key_bytes) = auth_key_bytes { - let appview_did = - std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); - match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) { + match crate::auth::create_service_token(auth_did, appview_did, method, key_bytes) { Ok(service_token) => { request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token)); diff --git a/src/api/repo/meta.rs b/src/api/repo/meta.rs index 45bb2a9..56b11b7 100644 --- a/src/api/repo/meta.rs +++ b/src/api/repo/meta.rs @@ -1,21 +1,75 @@ +use crate::api::proxy_client::proxy_client; use crate::state::AppState; use axum::{ Json, - extract::{Query, State}, + extract::{Query, RawQuery, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use serde_json::json; +use tracing::{error, info}; #[derive(Deserialize)] pub struct DescribeRepoInput { pub repo: String, } +async fn proxy_describe_repo_to_appview(state: &AppState, raw_query: Option<&str>) -> Response { + let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.describeRepo").await { + Some(r) => r, + None => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "NotFound", "message": "Repo not found"})), + ) + .into_response(); + } + }; + let target_url = match raw_query { + Some(q) => format!("{}/xrpc/com.atproto.repo.describeRepo?{}", resolved.url, q), + None => format!("{}/xrpc/com.atproto.repo.describeRepo", resolved.url), + }; + info!("Proxying describeRepo to AppView: {}", target_url); + let client = proxy_client(); + match client.get(&target_url).send().await { + Ok(resp) => { + let status = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + match resp.bytes().await { + Ok(body) => { + let mut builder = Response::builder().status(status); + if let Some(ct) = content_type { + builder = builder.header("content-type", ct); + } + builder + .body(axum::body::Body::from(body)) + .unwrap_or_else(|_| { + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() + }) + } + Err(e) => { + error!("Error reading AppView response: {:?}", e); + (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response() + } + } + } + Err(e) => { + error!("Error proxying to AppView: {:?}", e); + (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response() + } + } +} + pub async fn describe_repo( State(state): State, Query(input): Query, + RawQuery(raw_query): RawQuery, ) -> Response { let user_row = if input.repo.starts_with("did:") { sqlx::query!( @@ -37,11 +91,7 @@ pub async fn describe_repo( let (user_id, handle, did) = match user_row { Ok(Some((id, handle, did))) => (id, handle, did), _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "NotFound", "message": "Repo not found"})), - ) - .into_response(); + return proxy_describe_repo_to_appview(&state, raw_query.as_deref()).await; } }; let collections_query = sqlx::query!( diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs index cf85300..635927f 100644 --- a/src/api/repo/record/read.rs +++ b/src/api/repo/record/read.rs @@ -1,7 +1,8 @@ +use crate::api::proxy_client::proxy_client; use crate::state::AppState; use axum::{ Json, - extract::{Query, State}, + extract::{Query, RawQuery, State}, http::StatusCode, response::{IntoResponse, Response}, }; @@ -11,7 +12,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::HashMap; use std::str::FromStr; -use tracing::error; +use tracing::{error, info}; #[derive(Deserialize)] pub struct GetRecordInput { @@ -21,9 +22,69 @@ pub struct GetRecordInput { pub cid: Option, } +async fn proxy_get_record_to_appview(state: &AppState, raw_query: Option<&str>) -> Response { + let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.getRecord").await { + Some(r) => r, + None => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "NotFound", "message": "Repo not found"})), + ) + .into_response(); + } + }; + let target_url = match raw_query { + Some(q) => format!("{}/xrpc/com.atproto.repo.getRecord?{}", resolved.url, q), + None => format!("{}/xrpc/com.atproto.repo.getRecord", resolved.url), + }; + info!("Proxying getRecord to AppView: {}", target_url); + let client = proxy_client(); + match client.get(&target_url).send().await { + Ok(resp) => { + let status = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + match resp.bytes().await { + Ok(body) => { + let mut builder = Response::builder().status(status); + if let Some(ct) = content_type { + builder = builder.header("content-type", ct); + } + builder + .body(axum::body::Body::from(body)) + .unwrap_or_else(|_| { + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() + }) + } + Err(e) => { + error!("Error reading AppView response: {:?}", e); + ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "UpstreamError"})), + ) + .into_response() + } + } + } + Err(e) => { + error!("Error proxying to AppView: {:?}", e); + ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "UpstreamError"})), + ) + .into_response() + } + } +} + pub async fn get_record( State(state): State, Query(input): Query, + RawQuery(raw_query): RawQuery, ) -> Response { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let user_id_opt = if input.repo.starts_with("did:") { @@ -46,11 +107,7 @@ pub async fn get_record( let user_id: uuid::Uuid = match user_id_opt { Ok(Some(id)) => id, _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "NotFound", "message": "Repo not found"})), - ) - .into_response(); + return proxy_get_record_to_appview(&state, raw_query.as_deref()).await; } }; let record_row = sqlx::query!( @@ -134,9 +191,62 @@ pub struct ListRecordsOutput { pub cursor: Option, pub records: Vec, } + +async fn proxy_list_records_to_appview(state: &AppState, raw_query: Option<&str>) -> Response { + let resolved = match state.appview_registry.get_appview_for_method("com.atproto.repo.listRecords").await { + Some(r) => r, + None => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "NotFound", "message": "Repo not found"})), + ) + .into_response(); + } + }; + let target_url = match raw_query { + Some(q) => format!("{}/xrpc/com.atproto.repo.listRecords?{}", resolved.url, q), + None => format!("{}/xrpc/com.atproto.repo.listRecords", resolved.url), + }; + info!("Proxying listRecords to AppView: {}", target_url); + let client = proxy_client(); + match client.get(&target_url).send().await { + Ok(resp) => { + let status = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + match resp.bytes().await { + Ok(body) => { + let mut builder = Response::builder().status(status); + if let Some(ct) = content_type { + builder = builder.header("content-type", ct); + } + builder + .body(axum::body::Body::from(body)) + .unwrap_or_else(|_| { + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() + }) + } + Err(e) => { + error!("Error reading AppView response: {:?}", e); + (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response() + } + } + } + Err(e) => { + error!("Error proxying to AppView: {:?}", e); + (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response() + } + } +} + pub async fn list_records( State(state): State, Query(input): Query, + RawQuery(raw_query): RawQuery, ) -> Response { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let user_id_opt = if input.repo.starts_with("did:") { @@ -159,11 +269,7 @@ pub async fn list_records( let user_id: uuid::Uuid = match user_id_opt { Ok(Some(id)) => id, _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "NotFound", "message": "Repo not found"})), - ) - .into_response(); + return proxy_list_records_to_appview(&state, raw_query.as_deref()).await; } }; let limit = input.limit.unwrap_or(50).clamp(1, 100); diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 6043762..4be6274 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -16,10 +16,10 @@ pub use app_password::{create_app_password, list_app_passwords, revoke_app_passw pub use email::{confirm_email, request_email_update, update_email}; pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; pub use meta::{describe_server, health, robots_txt}; -pub use password::{request_password_reset, reset_password}; +pub use password::{change_password, request_password_reset, reset_password}; pub use service_auth::get_service_auth; pub use session::{ - confirm_signup, create_session, delete_session, get_session, refresh_session, - resend_verification, + confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session, + resend_verification, revoke_session, }; pub use signing_key::reserve_signing_key; diff --git a/src/api/server/password.rs b/src/api/server/password.rs index 92de55c..dca17e8 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -1,3 +1,4 @@ +use crate::auth::BearerAuth; use crate::state::{AppState, RateLimitKind}; use axum::{ Json, @@ -5,8 +6,9 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; -use bcrypt::{DEFAULT_COST, hash}; +use bcrypt::{DEFAULT_COST, hash, verify}; use chrono::{Duration, Utc}; +use uuid::Uuid; use serde::Deserialize; use serde_json::json; use tracing::{error, info, warn}; @@ -297,3 +299,108 @@ pub async fn reset_password( info!("Password reset completed for user {}", user_id); (StatusCode::OK, Json(json!({}))).into_response() } + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangePasswordInput { + pub current_password: String, + pub new_password: String, +} + +pub async fn change_password( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let current_password = &input.current_password; + let new_password = &input.new_password; + if current_password.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "currentPassword is required"})), + ) + .into_response(); + } + if new_password.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "newPassword is required"})), + ) + .into_response(); + } + if new_password.len() < 8 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Password must be at least 8 characters"})), + ) + .into_response(); + } + let user = sqlx::query_as::<_, (Uuid, String)>( + "SELECT id, password_hash FROM users WHERE did = $1", + ) + .bind(&auth.0.did) + .fetch_optional(&state.db) + .await; + let (user_id, password_hash) = match user { + Ok(Some(row)) => row, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in change_password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let valid = match verify(current_password, &password_hash) { + Ok(v) => v, + Err(e) => { + error!("Password verification error: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + if !valid { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "InvalidPassword", "message": "Current password is incorrect"})), + ) + .into_response(); + } + let new_hash = match hash(new_password, DEFAULT_COST) { + Ok(h) => h, + Err(e) => { + error!("Failed to hash password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") + .bind(&new_hash) + .bind(user_id) + .execute(&state.db) + .await + { + error!("DB error updating password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + info!(did = %auth.0.did, "Password changed successfully"); + (StatusCode::OK, Json(json!({}))).into_response() +} diff --git a/src/api/server/session.rs b/src/api/server/session.rs index e606df8..1f31b31 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -185,7 +185,7 @@ pub async fn get_session( ) -> Response { match sqlx::query!( r#"SELECT - handle, email, email_confirmed, + handle, email, email_confirmed, is_admin, preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1"#, @@ -210,6 +210,7 @@ pub async fn get_session( "emailConfirmed": row.email_confirmed, "preferredChannel": preferred_channel, "preferredChannelVerified": preferred_channel_verified, + "isAdmin": row.is_admin, "active": true, "didDoc": {} })).into_response() @@ -406,7 +407,7 @@ pub async fn refresh_session( } match sqlx::query!( r#"SELECT - handle, email, email_confirmed, + handle, email, email_confirmed, is_admin, preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1"#, @@ -433,6 +434,7 @@ pub async fn refresh_session( "emailConfirmed": u.email_confirmed, "preferredChannel": preferred_channel, "preferredChannelVerified": preferred_channel_verified, + "isAdmin": u.is_admin, "active": true })).into_response() } @@ -702,3 +704,129 @@ pub async fn resend_verification( } Json(json!({"success": true})).into_response() } + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionInfo { + pub id: String, + pub created_at: String, + pub expires_at: String, + pub is_current: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListSessionsOutput { + pub sessions: Vec, +} + +pub async fn list_sessions( + State(state): State, + headers: HeaderMap, + auth: BearerAuth, +) -> Response { + let current_jti = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .and_then(|token| crate::auth::get_jti_from_token(token).ok()); + let result = sqlx::query_as::<_, (i32, String, chrono::DateTime, chrono::DateTime)>( + r#" + SELECT id, access_jti, created_at, refresh_expires_at + FROM session_tokens + WHERE did = $1 AND refresh_expires_at > NOW() + ORDER BY created_at DESC + "#, + ) + .bind(&auth.0.did) + .fetch_all(&state.db) + .await; + match result { + Ok(rows) => { + let sessions: Vec = rows + .into_iter() + .map(|(id, access_jti, created_at, expires_at)| SessionInfo { + id: id.to_string(), + created_at: created_at.to_rfc3339(), + expires_at: expires_at.to_rfc3339(), + is_current: current_jti.as_ref().map_or(false, |j| j == &access_jti), + }) + .collect(); + (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() + } + Err(e) => { + error!("DB error in list_sessions: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevokeSessionInput { + pub session_id: String, +} + +pub async fn revoke_session( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let session_id: i32 = match input.session_id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), + ) + .into_response(); + } + }; + let session = sqlx::query_as::<_, (String,)>( + "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", + ) + .bind(session_id) + .bind(&auth.0.did) + .fetch_optional(&state.db) + .await; + let access_jti = match session { + Ok(Some((jti,))) => jti, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "SessionNotFound", "message": "Session not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in revoke_session: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1") + .bind(session_id) + .execute(&state.db) + .await + { + error!("DB error deleting session: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti); + if let Err(e) = state.cache.delete(&cache_key).await { + warn!("Failed to invalidate session cache: {:?}", e); + } + info!(did = %auth.0.did, session_id = %session_id, "Session revoked"); + (StatusCode::OK, Json(json!({}))).into_response() +} diff --git a/src/appview/mod.rs b/src/appview/mod.rs new file mode 100644 index 0000000..9cb8d0c --- /dev/null +++ b/src/appview/mod.rs @@ -0,0 +1,413 @@ +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use tracing::{debug, error, info, warn}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DidDocument { + pub id: String, + #[serde(default)] + pub service: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DidService { + pub id: String, + #[serde(rename = "type")] + pub service_type: String, + pub service_endpoint: String, +} + +#[derive(Clone)] +struct CachedAppView { + url: String, + did: String, + resolved_at: Instant, +} + +pub struct AppViewRegistry { + namespace_to_did: HashMap, + did_cache: RwLock>, + client: Client, + cache_ttl: Duration, + plc_directory_url: String, +} + +impl Clone for AppViewRegistry { + fn clone(&self) -> Self { + Self { + namespace_to_did: self.namespace_to_did.clone(), + did_cache: RwLock::new(HashMap::new()), + client: self.client.clone(), + cache_ttl: self.cache_ttl, + plc_directory_url: self.plc_directory_url.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct ResolvedAppView { + pub url: String, + pub did: String, +} + +impl AppViewRegistry { + pub fn new() -> Self { + let mut namespace_to_did = HashMap::new(); + + let bsky_did = std::env::var("APPVIEW_DID_BSKY") + .unwrap_or_else(|_| "did:web:api.bsky.app".to_string()); + namespace_to_did.insert("app.bsky".to_string(), bsky_did.clone()); + namespace_to_did.insert("com.atproto".to_string(), bsky_did); + + for (key, value) in std::env::vars() { + if let Some(namespace) = key.strip_prefix("APPVIEW_DID_") { + let namespace = namespace.to_lowercase().replace('_', "."); + if namespace != "bsky" { + namespace_to_did.insert(namespace, value); + } + } + } + + let cache_ttl_secs: u64 = std::env::var("APPVIEW_CACHE_TTL_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(300); + + let plc_directory_url = std::env::var("PLC_DIRECTORY_URL") + .unwrap_or_else(|_| "https://plc.directory".to_string()); + + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .pool_max_idle_per_host(10) + .build() + .unwrap_or_else(|_| Client::new()); + + info!( + "AppView registry initialized with {} namespace mappings", + namespace_to_did.len() + ); + for (ns, did) in &namespace_to_did { + debug!(" {} -> {}", ns, did); + } + + Self { + namespace_to_did, + did_cache: RwLock::new(HashMap::new()), + client, + cache_ttl: Duration::from_secs(cache_ttl_secs), + plc_directory_url, + } + } + + pub fn register_namespace(&mut self, namespace: &str, did: &str) { + info!("Registering AppView: {} -> {}", namespace, did); + self.namespace_to_did + .insert(namespace.to_string(), did.to_string()); + } + + pub async fn get_appview_for_method(&self, method: &str) -> Option { + let namespace = self.extract_namespace(method)?; + self.get_appview_for_namespace(&namespace).await + } + + pub async fn get_appview_for_namespace(&self, namespace: &str) -> Option { + let did = self.get_did_for_namespace(namespace)?; + self.resolve_appview_did(&did).await + } + + pub fn get_did_for_namespace(&self, namespace: &str) -> Option { + if let Some(did) = self.namespace_to_did.get(namespace) { + return Some(did.clone()); + } + + let mut parts: Vec<&str> = namespace.split('.').collect(); + while !parts.is_empty() { + let prefix = parts.join("."); + if let Some(did) = self.namespace_to_did.get(&prefix) { + return Some(did.clone()); + } + parts.pop(); + } + + None + } + + pub async fn resolve_appview_did(&self, did: &str) -> Option { + { + let cache = self.did_cache.read().await; + if let Some(cached) = cache.get(did) { + if cached.resolved_at.elapsed() < self.cache_ttl { + return Some(ResolvedAppView { + url: cached.url.clone(), + did: cached.did.clone(), + }); + } + } + } + + let resolved = self.resolve_did_internal(did).await?; + + { + let mut cache = self.did_cache.write().await; + cache.insert( + did.to_string(), + CachedAppView { + url: resolved.url.clone(), + did: resolved.did.clone(), + resolved_at: Instant::now(), + }, + ); + } + + Some(resolved) + } + + async fn resolve_did_internal(&self, did: &str) -> Option { + let did_doc = if did.starts_with("did:web:") { + self.resolve_did_web(did).await + } else if did.starts_with("did:plc:") { + self.resolve_did_plc(did).await + } else { + warn!("Unsupported DID method: {}", did); + return None; + }; + + let doc = match did_doc { + Ok(doc) => doc, + Err(e) => { + error!("Failed to resolve DID {}: {}", did, e); + return None; + } + }; + + self.extract_appview_endpoint(&doc) + } + + async fn resolve_did_web(&self, did: &str) -> Result { + let host = did + .strip_prefix("did:web:") + .ok_or("Invalid did:web format")?; + + let (host, path) = if host.contains(':') { + let decoded = host.replace("%3A", ":"); + let parts: Vec<&str> = decoded.splitn(2, '/').collect(); + if parts.len() > 1 { + (parts[0].to_string(), format!("/{}", parts[1])) + } else { + (decoded, String::new()) + } + } else { + let parts: Vec<&str> = host.splitn(2, ':').collect(); + if parts.len() > 1 && parts[1].contains('/') { + let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect(); + if path_parts.len() > 1 { + ( + format!("{}:{}", parts[0], path_parts[0]), + format!("/{}", path_parts[1]), + ) + } else { + (host.to_string(), String::new()) + } + } else { + (host.to_string(), String::new()) + } + }; + + let scheme = + if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') + { + "http" + } else { + "https" + }; + + let url = if path.is_empty() { + format!("{}://{}/.well-known/did.json", scheme, host) + } else { + format!("{}://{}{}/did.json", scheme, host, path) + }; + + debug!("Resolving did:web {} via {}", did, url); + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + + resp.json::() + .await + .map_err(|e| format!("Failed to parse DID document: {}", e)) + } + + async fn resolve_did_plc(&self, did: &str) -> Result { + let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); + + debug!("Resolving did:plc {} via {}", did, url); + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err("DID not found".to_string()); + } + + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + + resp.json::() + .await + .map_err(|e| format!("Failed to parse DID document: {}", e)) + } + + fn extract_appview_endpoint(&self, doc: &DidDocument) -> Option { + for service in &doc.service { + if service.service_type == "AtprotoAppView" + || service.id.contains("atproto_appview") + || service.id.ends_with("#bsky_appview") + { + return Some(ResolvedAppView { + url: service.service_endpoint.clone(), + did: doc.id.clone(), + }); + } + } + + for service in &doc.service { + if service.service_type.contains("AppView") || service.id.contains("appview") { + return Some(ResolvedAppView { + url: service.service_endpoint.clone(), + did: doc.id.clone(), + }); + } + } + + if let Some(service) = doc.service.first() { + if service.service_endpoint.starts_with("http") { + warn!( + "No explicit AppView service found for {}, using first service: {}", + doc.id, service.service_endpoint + ); + return Some(ResolvedAppView { + url: service.service_endpoint.clone(), + did: doc.id.clone(), + }); + } + } + + if doc.id.starts_with("did:web:") { + let host = doc.id.strip_prefix("did:web:")?; + let decoded_host = host.replace("%3A", ":"); + let base_host = decoded_host.split('/').next()?; + let scheme = if base_host.starts_with("localhost") + || base_host.starts_with("127.0.0.1") + || base_host.contains(':') + { + "http" + } else { + "https" + }; + warn!( + "No service found for {}, deriving URL from DID: {}://{}", + doc.id, scheme, base_host + ); + return Some(ResolvedAppView { + url: format!("{}://{}", scheme, base_host), + did: doc.id.clone(), + }); + } + + None + } + + fn extract_namespace(&self, method: &str) -> Option { + let parts: Vec<&str> = method.split('.').collect(); + if parts.len() >= 2 { + Some(format!("{}.{}", parts[0], parts[1])) + } else { + None + } + } + + pub fn list_namespaces(&self) -> Vec<(String, String)> { + self.namespace_to_did + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + } + + pub async fn invalidate_cache(&self, did: &str) { + let mut cache = self.did_cache.write().await; + cache.remove(did); + } + + pub async fn invalidate_all_cache(&self) { + let mut cache = self.did_cache.write().await; + cache.clear(); + } +} + +impl Default for AppViewRegistry { + fn default() -> Self { + Self::new() + } +} + +pub async fn get_appview_url_for_method(registry: &AppViewRegistry, method: &str) -> Option { + registry.get_appview_for_method(method).await.map(|r| r.url) +} + +pub async fn get_appview_did_for_method(registry: &AppViewRegistry, method: &str) -> Option { + registry.get_appview_for_method(method).await.map(|r| r.did) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_namespace() { + let registry = AppViewRegistry::new(); + assert_eq!( + registry.extract_namespace("app.bsky.actor.getProfile"), + Some("app.bsky".to_string()) + ); + assert_eq!( + registry.extract_namespace("com.atproto.repo.createRecord"), + Some("com.atproto".to_string()) + ); + assert_eq!( + registry.extract_namespace("com.whtwnd.blog.getPost"), + Some("com.whtwnd".to_string()) + ); + assert_eq!(registry.extract_namespace("invalid"), None); + } + + #[test] + fn test_get_did_for_namespace() { + let mut registry = AppViewRegistry::new(); + registry.register_namespace("com.whtwnd", "did:web:whtwnd.com"); + + assert!(registry.get_did_for_namespace("app.bsky").is_some()); + assert_eq!( + registry.get_did_for_namespace("com.whtwnd"), + Some("did:web:whtwnd.com".to_string()) + ); + assert!(registry.get_did_for_namespace("unknown.namespace").is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index d088f03..f3b988b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod api; +pub mod appview; pub mod auth; pub mod cache; pub mod circuit_breaker; @@ -49,6 +50,14 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.server.getSession", get(api::server::get_session), ) + .route( + "/xrpc/com.bspds.account.listSessions", + get(api::server::list_sessions), + ) + .route( + "/xrpc/com.bspds.account.revokeSession", + post(api::server::revoke_session), + ) .route( "/xrpc/com.atproto.server.deleteSession", post(api::server::delete_session), @@ -161,12 +170,8 @@ pub fn app(state: AppState) -> Router { get(api::admin::get_account_infos), ) .route( - "/xrpc/com.bspds.admin.createProfile", - post(api::admin::create_profile), - ) - .route( - "/xrpc/com.bspds.admin.createRecord", - post(api::admin::create_record_admin), + "/xrpc/com.atproto.admin.searchAccounts", + get(api::admin::search_accounts), ) .route( "/xrpc/com.atproto.server.activateAccount", @@ -192,6 +197,10 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.server.resetPassword", post(api::server::reset_password), ) + .route( + "/xrpc/com.bspds.account.changePassword", + post(api::server::change_password), + ) .route( "/xrpc/com.atproto.server.requestEmailUpdate", post(api::server::request_email_update), @@ -352,6 +361,10 @@ pub fn app(state: AppState) -> Router { get(oauth::endpoints::oauth_authorization_server), ) .route("/oauth/jwks", get(oauth::endpoints::oauth_jwks)) + .route( + "/oauth/client-metadata.json", + get(oauth::endpoints::frontend_client_metadata), + ) .route( "/oauth/par", post(oauth::endpoints::pushed_authorization_request), diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs index 50e13c9..d731f8d 100644 --- a/src/oauth/endpoints/authorize.rs +++ b/src/oauth/endpoints/authorize.rs @@ -1,6 +1,6 @@ use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code}; use crate::oauth::{ - Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates, + Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db, templates, }; use crate::state::{AppState, RateLimitKind}; use axum::{ @@ -196,10 +196,16 @@ pub async fn authorize_get( ) .into_response(); } + let client_cache = ClientMetadataCache::new(3600); + let client_name = client_cache + .get(&request_data.parameters.client_id) + .await + .ok() + .and_then(|m| m.client_name); if wants_json(&headers) { return Json(AuthorizeResponse { client_id: request_data.parameters.client_id.clone(), - client_name: None, + client_name: client_name.clone(), scope: request_data.parameters.scope.clone(), redirect_uri: request_data.parameters.redirect_uri.clone(), state: request_data.parameters.state.clone(), @@ -223,7 +229,7 @@ pub async fn authorize_get( .collect(); return Html(templates::account_selector_page( &request_data.parameters.client_id, - None, + client_name.as_deref(), &request_uri, &device_accounts, )) @@ -231,7 +237,7 @@ pub async fn authorize_get( } Html(templates::login_page( &request_data.parameters.client_id, - None, + client_name.as_deref(), request_data.parameters.scope.as_deref(), &request_uri, None, @@ -352,6 +358,12 @@ pub async fn authorize_post( )) .into_response(); } + let client_cache = ClientMetadataCache::new(3600); + let client_name = client_cache + .get(&request_data.parameters.client_id) + .await + .ok() + .and_then(|m| m.client_name); let show_login_error = |error_msg: &str, json: bool| -> Response { if json { return ( @@ -365,7 +377,7 @@ pub async fn authorize_post( } Html(templates::login_page( &request_data.parameters.client_id, - None, + client_name.as_deref(), request_data.parameters.scope.as_deref(), &form.request_uri, Some(error_msg), diff --git a/src/oauth/endpoints/metadata.rs b/src/oauth/endpoints/metadata.rs index 8256ed1..569791d 100644 --- a/src/oauth/endpoints/metadata.rs +++ b/src/oauth/endpoints/metadata.rs @@ -127,3 +127,40 @@ pub async fn oauth_jwks(State(_state): State) -> Json { }; Json(create_jwk_set(vec![server_key])) } + +#[derive(Debug, Serialize, Deserialize)] +pub struct FrontendClientMetadata { + pub client_id: String, + pub client_name: String, + pub client_uri: String, + pub redirect_uris: Vec, + pub grant_types: Vec, + pub response_types: Vec, + pub scope: String, + pub token_endpoint_auth_method: String, + pub application_type: String, + pub dpop_bound_access_tokens: bool, +} + +pub async fn frontend_client_metadata( + State(_state): State, +) -> Json { + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let base_url = format!("https://{}", pds_hostname); + let client_id = format!("{}/oauth/client-metadata.json", base_url); + Json(FrontendClientMetadata { + client_id, + client_name: "PDS Account Manager".to_string(), + client_uri: base_url.clone(), + redirect_uris: vec![format!("{}/", base_url)], + grant_types: vec![ + "authorization_code".to_string(), + "refresh_token".to_string(), + ], + response_types: vec!["code".to_string()], + scope: "atproto transition:generic".to_string(), + token_endpoint_auth_method: "none".to_string(), + application_type: "web".to_string(), + dpop_bound_access_tokens: false, + }) +} diff --git a/src/oauth/templates.rs b/src/oauth/templates.rs index b82e1e2..5c2cfaa 100644 --- a/src/oauth/templates.rs +++ b/src/oauth/templates.rs @@ -1,47 +1,69 @@ use chrono::{DateTime, Utc}; +fn format_scope_for_display(scope: Option<&str>) -> String { + let scope = scope.unwrap_or(""); + if scope.is_empty() || scope.contains("atproto") || scope.contains("transition:generic") { + return "access your account".to_string(); + } + let parts: Vec<&str> = scope.split_whitespace().collect(); + let friendly: Vec<&str> = parts + .iter() + .filter_map(|s| { + match *s { + "atproto" | "transition:generic" | "transition:chat.bsky" => None, + "read" => Some("read your data"), + "write" => Some("write data"), + other => Some(other), + } + }) + .collect(); + if friendly.is_empty() { + "access your account".to_string() + } else { + friendly.join(", ") + } +} + fn base_styles() -> &'static str { r#" :root { - --primary: #0085ff; - --primary-hover: #0077e6; - --primary-contrast: #ffffff; - --primary-100: #dbeafe; - --primary-400: #60a5fa; - --primary-600-30: rgba(37, 99, 235, 0.3); - --contrast-0: #ffffff; - --contrast-25: #f8f9fa; - --contrast-50: #f1f3f5; - --contrast-100: #e9ecef; - --contrast-200: #dee2e6; - --contrast-300: #ced4da; - --contrast-400: #adb5bd; - --contrast-500: #6b7280; - --contrast-600: #4b5563; - --contrast-700: #374151; - --contrast-800: #1f2937; - --contrast-900: #111827; - --error: #dc2626; - --error-bg: #fef2f2; - --success: #059669; - --success-bg: #ecfdf5; + --bg-primary: #fafafa; + --bg-secondary: #f9f9f9; + --bg-card: #ffffff; + --bg-input: #ffffff; + --text-primary: #333333; + --text-secondary: #666666; + --text-muted: #999999; + --border-color: #dddddd; + --border-color-light: #cccccc; + --accent: #0066cc; + --accent-hover: #0052a3; + --success-bg: #dfd; + --success-border: #8c8; + --success-text: #060; + --error-bg: #fee; + --error-border: #fcc; + --error-text: #c00; } @media (prefers-color-scheme: dark) { :root { - --contrast-0: #111827; - --contrast-25: #1f2937; - --contrast-50: #374151; - --contrast-100: #4b5563; - --contrast-200: #6b7280; - --contrast-300: #9ca3af; - --contrast-400: #d1d5db; - --contrast-500: #e5e7eb; - --contrast-600: #f3f4f6; - --contrast-700: #f9fafb; - --contrast-800: #ffffff; - --contrast-900: #ffffff; - --error-bg: #451a1a; - --success-bg: #064e3b; + --bg-primary: #1a1a1a; + --bg-secondary: #242424; + --bg-card: #2a2a2a; + --bg-input: #333333; + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --text-muted: #707070; + --border-color: #404040; + --border-color-light: #505050; + --accent: #4da6ff; + --accent-hover: #7abbff; + --success-bg: #1a3d1a; + --success-border: #2d5a2d; + --success-text: #7bc67b; + --error-bg: #3d1a1a; + --error-border: #5a2d2d; + --error-text: #ff7b7b; } } * { @@ -50,103 +72,83 @@ fn base_styles() -> &'static str { padding: 0; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - background: var(--contrast-50); - color: var(--contrast-900); + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; line-height: 1.5; } .container { - width: 100%; max-width: 400px; - } - .card { - background: var(--contrast-0); - border: 1px solid var(--contrast-100); - border-radius: 0.75rem; - padding: 1.5rem; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); - } - @media (prefers-color-scheme: dark) { - .card { - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3); - } + margin: 4rem auto; + padding: 2rem; } h1 { - font-size: 1.5rem; + margin: 0 0 0.5rem 0; font-weight: 600; - color: var(--contrast-900); - margin-bottom: 0.5rem; } .subtitle { - color: var(--contrast-500); - font-size: 0.875rem; - margin-bottom: 1.5rem; + color: var(--text-secondary); + margin: 0 0 2rem 0; } .subtitle strong { - color: var(--contrast-700); + color: var(--text-primary); } .client-info { - background: var(--contrast-25); - border-radius: 0.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; } .client-info .client-name { font-weight: 500; - color: var(--contrast-900); + color: var(--text-primary); display: block; margin-bottom: 0.25rem; } .client-info .scope { - color: var(--contrast-500); + color: var(--text-secondary); font-size: 0.875rem; } .error-banner { background: var(--error-bg); - color: var(--error); - border-radius: 0.5rem; - padding: 0.75rem 1rem; + border: 1px solid var(--error-border); + color: var(--error-text); + border-radius: 4px; + padding: 0.75rem; margin-bottom: 1rem; - font-size: 0.875rem; } .form-group { - margin-bottom: 1.25rem; + margin-bottom: 1rem; } label { display: block; font-size: 0.875rem; font-weight: 500; - color: var(--contrast-700); - margin-bottom: 0.375rem; + margin-bottom: 0.25rem; } input[type="text"], input[type="email"], input[type="password"] { width: 100%; - padding: 0.625rem 0.875rem; - border: 2px solid var(--contrast-200); - border-radius: 0.375rem; + padding: 0.75rem; + border: 1px solid var(--border-color-light); + border-radius: 4px; font-size: 1rem; - color: var(--contrast-900); - background: var(--contrast-0); - transition: border-color 0.15s, box-shadow 0.15s; + color: var(--text-primary); + background: var(--bg-input); } input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus { outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px var(--primary-600-30); + border-color: var(--accent); } input[type="text"]::placeholder, input[type="email"]::placeholder, input[type="password"]::placeholder { - color: var(--contrast-400); + color: var(--text-muted); } .checkbox-group { display: flex; @@ -155,14 +157,14 @@ fn base_styles() -> &'static str { margin-bottom: 1.5rem; } .checkbox-group input[type="checkbox"] { - width: 1.125rem; - height: 1.125rem; - accent-color: var(--primary); + width: 1rem; + height: 1rem; + accent-color: var(--accent); } .checkbox-group label { margin-bottom: 0; font-weight: normal; - color: var(--contrast-600); + color: var(--text-secondary); cursor: pointer; } .buttons { @@ -171,45 +173,39 @@ fn base_styles() -> &'static str { } .btn { flex: 1; - padding: 0.625rem 1.25rem; - border-radius: 0.375rem; + padding: 0.75rem; + border-radius: 4px; font-size: 1rem; - font-weight: 500; cursor: pointer; - transition: background-color 0.15s, transform 0.1s; border: none; text-align: center; text-decoration: none; - display: inline-flex; - align-items: center; - justify-content: center; - } - .btn:active { - transform: scale(0.98); } .btn-primary { - background: var(--primary); - color: var(--primary-contrast); + background: var(--accent); + color: white; } .btn-primary:hover { - background: var(--primary-hover); + background: var(--accent-hover); } .btn-primary:disabled { - background: var(--primary-400); + opacity: 0.6; cursor: not-allowed; } .btn-secondary { - background: var(--contrast-200); - color: var(--contrast-800); + background: transparent; + color: var(--accent); + border: 1px solid var(--accent); } .btn-secondary:hover { - background: var(--contrast-300); + background: var(--accent); + color: white; } .footer { text-align: center; margin-top: 1.5rem; font-size: 0.75rem; - color: var(--contrast-400); + color: var(--text-muted); } .accounts { display: flex; @@ -220,79 +216,56 @@ fn base_styles() -> &'static str { .account-item { display: flex; align-items: center; - gap: 0.75rem; + justify-content: space-between; width: 100%; - padding: 0.75rem; - background: var(--contrast-25); - border: 1px solid var(--contrast-100); - border-radius: 0.5rem; + padding: 1rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; cursor: pointer; - transition: background-color 0.15s, border-color 0.15s; + transition: border-color 0.15s, box-shadow 0.15s; text-align: left; } .account-item:hover { - background: var(--contrast-50); - border-color: var(--contrast-200); - } - .avatar { - width: 2.5rem; - height: 2.5rem; - border-radius: 50%; - background: var(--primary); - color: var(--primary-contrast); - display: flex; - align-items: center; - justify-content: center; - font-weight: 600; - font-size: 0.875rem; - flex-shrink: 0; + border-color: var(--accent); + box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15); } .account-info { + display: flex; + flex-direction: column; + gap: 0.25rem; flex: 1; min-width: 0; } .account-info .handle { - display: block; font-weight: 500; - color: var(--contrast-900); + color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .account-info .email { - display: block; - font-size: 0.875rem; - color: var(--contrast-500); + .account-info .did { + font-size: 0.75rem; + color: var(--text-muted); + font-family: monospace; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; } .chevron { - color: var(--contrast-400); + color: var(--text-muted); font-size: 1.25rem; flex-shrink: 0; + margin-left: 0.5rem; } .divider { height: 1px; - background: var(--contrast-100); + background: var(--border-color); margin: 1rem 0; } - .link-button { - background: none; - border: none; - color: var(--primary); - cursor: pointer; - font-size: inherit; - padding: 0; - text-decoration: underline; - } - .link-button:hover { - color: var(--primary-hover); - } .new-account-link { display: block; text-align: center; - color: var(--primary); + color: var(--accent); text-decoration: none; font-size: 0.875rem; } @@ -303,7 +276,7 @@ fn base_styles() -> &'static str { text-align: center; margin-top: 1rem; font-size: 0.875rem; - color: var(--contrast-500); + color: var(--text-secondary); } .icon { font-size: 3rem; @@ -311,9 +284,10 @@ fn base_styles() -> &'static str { } .error-code { background: var(--error-bg); - color: var(--error); + border: 1px solid var(--error-border); + color: var(--error-text); padding: 0.5rem 1rem; - border-radius: 0.375rem; + border-radius: 4px; font-family: monospace; display: inline-block; margin-bottom: 1rem; @@ -323,7 +297,8 @@ fn base_styles() -> &'static str { height: 3rem; border-radius: 50%; background: var(--success-bg); - color: var(--success); + border: 1px solid var(--success-border); + color: var(--success-text); display: flex; align-items: center; justify-content: center; @@ -351,7 +326,7 @@ pub fn login_page( login_hint: Option<&str>, ) -> String { let client_display = client_name.unwrap_or(client_id); - let scope_display = scope.unwrap_or("access your account"); + let scope_display = format_scope_for_display(scope); let error_html = error_message .map(|msg| format!(r#"
{}
"#, html_escape(msg))) .unwrap_or_default(); @@ -368,46 +343,44 @@ pub fn login_page(
-
-

Sign in

-

to continue to {client_display}

-
- {client_display} - wants to {scope_display} -
- {error_html} -
- -
- - -
-
- - -
-
- - -
-
- - -
-
- +

Sign In

+

Sign in to continue to {client_display}

+
+ {client_display} + wants to {scope_display}
+ {error_html} +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+ By signing in, you agree to share your account information with this application. +

"#, styles = base_styles(), client_display = html_escape(client_display), - scope_display = html_escape(scope_display), + scope_display = html_escape(&scope_display), request_uri = html_escape(request_uri), error_html = error_html, login_hint_value = html_escape(login_hint_value), @@ -431,26 +404,21 @@ pub fn account_selector_page( let accounts_html: String = accounts .iter() .map(|account| { - let initials = get_initials(&account.handle); - let email_display = account.email.as_deref().unwrap_or(""); format!( r#"
"#, request_uri = html_escape(request_uri), did = html_escape(&account.did), - initials = html_escape(&initials), handle = html_escape(&account.handle), - email = html_escape(email_display), ) }) .collect(); @@ -466,17 +434,15 @@ pub fn account_selector_page(
-
-

Choose an account

-

to continue to {client_display}

-
- {accounts_html} -
-
- +

Sign In

+

Choose an account to continue to {client_display}

+
+ {accounts_html}
+
+
"#, @@ -493,7 +459,7 @@ pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<& .unwrap_or_default(); let (title, subtitle) = match channel { "email" => ( - "Check your email", + "Check Your Email", "We sent a verification code to your email", ), "Discord" => ( @@ -505,7 +471,7 @@ pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<& "We sent a verification code to your Telegram", ), "Signal" => ("Check Signal", "We sent a verification code to your Signal"), - _ => ("Check your messages", "We sent you a verification code"), + _ => ("Check Your Messages", "We sent you a verification code"), }; format!( r#" @@ -519,26 +485,24 @@ pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<&
-
-

{title}

-

{subtitle}

- {error_html} -
- -
- - -
- -
-

- Code expires in 10 minutes. -

-
+

{title}

+

{subtitle}

+ {error_html} +
+ +
+ + +
+ +
+

+ Code expires in 10 minutes. +

"#, @@ -564,15 +528,12 @@ pub fn error_page(error: &str, error_description: Option<&str>) -> String { -
-
-
⚠️
-

Authorization Failed

-
{error}
-

{description}

-
- -
+
+

Authorization Failed

+
{error}
+

{description}

+
+
@@ -596,13 +557,11 @@ pub fn success_page(client_name: Option<&str>) -> String { -
-
-
-

Authorization Successful

-

{client_display} has been granted access to your account.

-

You can close this window and return to the application.

-
+
+
+

Authorization Successful

+

{client_display} has been granted access to your account.

+

You can close this window and return to the application.

"#, @@ -619,19 +578,6 @@ fn html_escape(s: &str) -> String { .replace('\'', "'") } -fn get_initials(handle: &str) -> String { - let clean = handle.trim_start_matches('@'); - if clean.is_empty() { - return "?".to_string(); - } - clean - .chars() - .next() - .unwrap_or('?') - .to_uppercase() - .to_string() -} - pub fn mask_email(email: &str) -> String { if let Some(at_pos) = email.find('@') { let local = &email[..at_pos]; diff --git a/src/state.rs b/src/state.rs index 27d3a1d..43a3624 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,3 +1,4 @@ +use crate::appview::AppViewRegistry; use crate::cache::{Cache, DistributedRateLimiter, create_cache}; use crate::circuit_breaker::CircuitBreakers; use crate::config::AuthConfig; @@ -19,6 +20,7 @@ pub struct AppState { pub circuit_breakers: Arc, pub cache: Arc, pub distributed_rate_limiter: Arc, + pub appview_registry: Arc, } pub enum RateLimitKind { @@ -85,6 +87,7 @@ impl AppState { let rate_limiters = Arc::new(RateLimiters::new()); let circuit_breakers = Arc::new(CircuitBreakers::new()); let (cache, distributed_rate_limiter) = create_cache().await; + let appview_registry = Arc::new(AppViewRegistry::new()); Self { db, @@ -95,6 +98,7 @@ impl AppState { circuit_breakers, cache, distributed_rate_limiter, + appview_registry, } } diff --git a/tests/admin_search.rs b/tests/admin_search.rs new file mode 100644 index 0000000..b555adf --- /dev/null +++ b/tests/admin_search.rs @@ -0,0 +1,163 @@ +mod common; +mod helpers; +use common::*; +use helpers::*; +use reqwest::StatusCode; +use serde_json::Value; + +#[tokio::test] +async fn test_search_accounts_as_admin() { + let client = client(); + let (admin_jwt, _) = create_admin_account_and_login(&client).await; + let (user_did, _) = setup_new_user("search-target").await; + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.searchAccounts", + base_url().await + )) + .bearer_auth(&admin_jwt) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let accounts = body["accounts"].as_array().expect("accounts should be array"); + assert!(!accounts.is_empty(), "Should return some accounts"); + let found = accounts.iter().any(|a| a["did"].as_str() == Some(&user_did)); + assert!(found, "Should find the created user in results"); +} + +#[tokio::test] +async fn test_search_accounts_with_handle_filter() { + let client = client(); + let (admin_jwt, _) = create_admin_account_and_login(&client).await; + let ts = chrono::Utc::now().timestamp_millis(); + let unique_handle = format!("unique-handle-{}.test", ts); + let create_payload = serde_json::json!({ + "handle": unique_handle, + "email": format!("unique-{}@searchtest.com", ts), + "password": "test-password-123" + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.searchAccounts?handle={}", + base_url().await, + unique_handle + )) + .bearer_auth(&admin_jwt) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let accounts = body["accounts"].as_array().unwrap(); + assert_eq!(accounts.len(), 1, "Should find exactly one account with this handle"); + assert_eq!(accounts[0]["handle"].as_str(), Some(unique_handle.as_str())); +} + +#[tokio::test] +async fn test_search_accounts_pagination() { + let client = client(); + let (admin_jwt, _) = create_admin_account_and_login(&client).await; + for i in 0..3 { + let _ = setup_new_user(&format!("search-page-{}", i)).await; + } + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.searchAccounts?limit=2", + base_url().await + )) + .bearer_auth(&admin_jwt) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let accounts = body["accounts"].as_array().unwrap(); + assert_eq!(accounts.len(), 2, "Should return exactly 2 accounts"); + let cursor = body["cursor"].as_str(); + assert!(cursor.is_some(), "Should have cursor for more results"); + let res2 = client + .get(format!( + "{}/xrpc/com.atproto.admin.searchAccounts?limit=2&cursor={}", + base_url().await, + cursor.unwrap() + )) + .bearer_auth(&admin_jwt) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res2.status(), StatusCode::OK); + let body2: Value = res2.json().await.unwrap(); + let accounts2 = body2["accounts"].as_array().unwrap(); + assert!(!accounts2.is_empty(), "Should return more accounts after cursor"); + let first_page_dids: Vec<&str> = accounts.iter().map(|a| a["did"].as_str().unwrap()).collect(); + let second_page_dids: Vec<&str> = accounts2.iter().map(|a| a["did"].as_str().unwrap()).collect(); + for did in &second_page_dids { + assert!(!first_page_dids.contains(did), "Second page should not repeat first page DIDs"); + } +} + +#[tokio::test] +async fn test_search_accounts_requires_admin() { + let client = client(); + let (_, user_jwt) = setup_new_user("search-nonadmin").await; + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.searchAccounts", + base_url().await + )) + .bearer_auth(&user_jwt) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_search_accounts_requires_auth() { + let client = client(); + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.searchAccounts", + base_url().await + )) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_search_accounts_returns_expected_fields() { + let client = client(); + let (admin_jwt, _) = create_admin_account_and_login(&client).await; + let _ = setup_new_user("search-fields").await; + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.searchAccounts?limit=1", + base_url().await + )) + .bearer_auth(&admin_jwt) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let accounts = body["accounts"].as_array().unwrap(); + assert!(!accounts.is_empty()); + let account = &accounts[0]; + assert!(account["did"].as_str().is_some(), "Should have did"); + assert!(account["handle"].as_str().is_some(), "Should have handle"); + assert!(account["indexedAt"].as_str().is_some(), "Should have indexedAt"); +} diff --git a/tests/change_password.rs b/tests/change_password.rs new file mode 100644 index 0000000..4911105 --- /dev/null +++ b/tests/change_password.rs @@ -0,0 +1,197 @@ +mod common; +mod helpers; +use common::*; +use helpers::*; +use reqwest::StatusCode; +use serde_json::{Value, json}; + +#[tokio::test] +async fn test_change_password_success() { + let client = client(); + let ts = chrono::Utc::now().timestamp_millis(); + let handle = format!("change-pw-{}.test", ts); + let email = format!("change-pw-{}@test.com", ts); + let old_password = "old-password-123"; + let new_password = "new-password-456"; + let create_payload = json!({ + "handle": handle, + "email": email, + "password": old_password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let create_body: Value = create_res.json().await.unwrap(); + let did = create_body["did"].as_str().unwrap(); + let jwt = verify_new_account(&client, did).await; + let change_res = client + .post(format!( + "{}/xrpc/com.bspds.account.changePassword", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ + "currentPassword": old_password, + "newPassword": new_password + })) + .send() + .await + .expect("Failed to change password"); + assert_eq!(change_res.status(), StatusCode::OK); + let login_old = client + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url().await + )) + .json(&json!({ + "identifier": handle, + "password": old_password + })) + .send() + .await + .expect("Failed to try old password"); + assert_eq!(login_old.status(), StatusCode::UNAUTHORIZED, "Old password should not work"); + let login_new = client + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url().await + )) + .json(&json!({ + "identifier": handle, + "password": new_password + })) + .send() + .await + .expect("Failed to try new password"); + assert_eq!(login_new.status(), StatusCode::OK, "New password should work"); +} + +#[tokio::test] +async fn test_change_password_wrong_current() { + let client = client(); + let (_, jwt) = setup_new_user("change-pw-wrong").await; + let res = client + .post(format!( + "{}/xrpc/com.bspds.account.changePassword", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ + "currentPassword": "wrong-password", + "newPassword": "new-password-123" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"].as_str(), Some("InvalidPassword")); +} + +#[tokio::test] +async fn test_change_password_too_short() { + let client = client(); + let ts = chrono::Utc::now().timestamp_millis(); + let handle = format!("change-pw-short-{}.test", ts); + let email = format!("change-pw-short-{}@test.com", ts); + let password = "correct-password"; + let create_payload = json!({ + "handle": handle, + "email": email, + "password": password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let create_body: Value = create_res.json().await.unwrap(); + let did = create_body["did"].as_str().unwrap(); + let jwt = verify_new_account(&client, did).await; + let res = client + .post(format!( + "{}/xrpc/com.bspds.account.changePassword", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ + "currentPassword": password, + "newPassword": "short" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert!(body["message"].as_str().unwrap().contains("8 characters")); +} + +#[tokio::test] +async fn test_change_password_empty_current() { + let client = client(); + let (_, jwt) = setup_new_user("change-pw-empty").await; + let res = client + .post(format!( + "{}/xrpc/com.bspds.account.changePassword", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ + "currentPassword": "", + "newPassword": "new-password-123" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_change_password_empty_new() { + let client = client(); + let (_, jwt) = setup_new_user("change-pw-emptynew").await; + let res = client + .post(format!( + "{}/xrpc/com.bspds.account.changePassword", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ + "currentPassword": "e2e-password-123", + "newPassword": "" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_change_password_requires_auth() { + let client = client(); + let res = client + .post(format!( + "{}/xrpc/com.bspds.account.changePassword", + base_url().await + )) + .json(&json!({ + "currentPassword": "old", + "newPassword": "new-password-123" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8170491..84dcc63 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -137,8 +137,12 @@ async fn setup_with_external_infra() -> String { } let mock_server = MockServer::start().await; setup_mock_appview(&mock_server).await; + let mock_uri = mock_server.uri(); + let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri); + let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A")); + setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await; unsafe { - std::env::set_var("APPVIEW_URL", mock_server.uri()); + std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did); } MOCK_APPVIEW.set(mock_server).ok(); spawn_app(database_url).await @@ -186,8 +190,12 @@ async fn setup_with_testcontainers() -> String { let _ = s3_client.create_bucket().bucket("test-bucket").send().await; let mock_server = MockServer::start().await; setup_mock_appview(&mock_server).await; + let mock_uri = mock_server.uri(); + let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri); + let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A")); + setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await; unsafe { - std::env::set_var("APPVIEW_URL", mock_server.uri()); + std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did); } MOCK_APPVIEW.set(mock_server).ok(); S3_CONTAINER.set(s3_container).ok(); @@ -215,6 +223,21 @@ async fn setup_with_testcontainers() -> String { ); } +async fn setup_mock_did_document(mock_server: &MockServer, did: &str, service_endpoint: &str) { + Mock::given(method("GET")) + .and(path("/.well-known/did.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": did, + "service": [{ + "id": "#atproto_appview", + "type": "AtprotoAppView", + "serviceEndpoint": service_endpoint + }] + }))) + .mount(mock_server) + .await; +} + async fn setup_mock_appview(mock_server: &MockServer) { Mock::given(method("GET")) .and(path("/xrpc/app.bsky.actor.getProfile")) diff --git a/tests/oauth_client_metadata.rs b/tests/oauth_client_metadata.rs new file mode 100644 index 0000000..a59a5af --- /dev/null +++ b/tests/oauth_client_metadata.rs @@ -0,0 +1,75 @@ +mod common; +use common::*; +use reqwest::StatusCode; +use serde_json::Value; + +#[tokio::test] +async fn test_frontend_client_metadata_returns_valid_json() { + let client = client(); + let res = client + .get(format!( + "{}/oauth/client-metadata.json", + base_url().await + )) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Should return valid JSON"); + assert!(body["client_id"].as_str().is_some(), "Should have client_id"); + assert!(body["client_name"].as_str().is_some(), "Should have client_name"); + assert!(body["redirect_uris"].as_array().is_some(), "Should have redirect_uris"); + assert!(body["grant_types"].as_array().is_some(), "Should have grant_types"); + assert!(body["response_types"].as_array().is_some(), "Should have response_types"); + assert!(body["scope"].as_str().is_some(), "Should have scope"); + assert!(body["token_endpoint_auth_method"].as_str().is_some(), "Should have token_endpoint_auth_method"); +} + +#[tokio::test] +async fn test_frontend_client_metadata_correct_values() { + let client = client(); + let res = client + .get(format!( + "{}/oauth/client-metadata.json", + base_url().await + )) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let client_id = body["client_id"].as_str().unwrap(); + assert!(client_id.ends_with("/oauth/client-metadata.json"), "client_id should end with /oauth/client-metadata.json"); + let grant_types = body["grant_types"].as_array().unwrap(); + let grant_strs: Vec<&str> = grant_types.iter().filter_map(|v| v.as_str()).collect(); + assert!(grant_strs.contains(&"authorization_code"), "Should support authorization_code grant"); + assert!(grant_strs.contains(&"refresh_token"), "Should support refresh_token grant"); + let response_types = body["response_types"].as_array().unwrap(); + let response_strs: Vec<&str> = response_types.iter().filter_map(|v| v.as_str()).collect(); + assert!(response_strs.contains(&"code"), "Should support code response type"); + assert_eq!(body["token_endpoint_auth_method"].as_str(), Some("none"), "Should be public client (none auth)"); + assert_eq!(body["application_type"].as_str(), Some("web"), "Should be web application"); + assert_eq!(body["dpop_bound_access_tokens"].as_bool(), Some(false), "Should not require DPoP"); + let scope = body["scope"].as_str().unwrap(); + assert!(scope.contains("atproto"), "Scope should include atproto"); +} + +#[tokio::test] +async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() { + let client = client(); + let res = client + .get(format!( + "{}/oauth/client-metadata.json", + base_url().await + )) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let client_uri = body["client_uri"].as_str().unwrap(); + let redirect_uris = body["redirect_uris"].as_array().unwrap(); + assert!(!redirect_uris.is_empty(), "Should have at least one redirect URI"); + let redirect_uri = redirect_uris[0].as_str().unwrap(); + assert!(redirect_uri.starts_with(client_uri), "Redirect URI should be on same origin as client_uri"); +} diff --git a/tests/session_management.rs b/tests/session_management.rs new file mode 100644 index 0000000..1760d37 --- /dev/null +++ b/tests/session_management.rs @@ -0,0 +1,234 @@ +mod common; +mod helpers; +use common::*; +use helpers::*; +use reqwest::StatusCode; +use serde_json::{Value, json}; + +#[tokio::test] +async fn test_list_sessions_returns_current_session() { + let client = client(); + let (did, jwt) = setup_new_user("list-sessions").await; + let res = client + .get(format!( + "{}/xrpc/com.bspds.account.listSessions", + base_url().await + )) + .bearer_auth(&jwt) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let sessions = body["sessions"].as_array().expect("sessions should be array"); + assert!(!sessions.is_empty(), "Should have at least one session"); + let current = sessions.iter().find(|s| s["isCurrent"].as_bool() == Some(true)); + assert!(current.is_some(), "Should have a current session marked"); + let session = current.unwrap(); + assert!(session["id"].as_str().is_some(), "Session should have id"); + assert!(session["createdAt"].as_str().is_some(), "Session should have createdAt"); + assert!(session["expiresAt"].as_str().is_some(), "Session should have expiresAt"); + let _ = did; +} + +#[tokio::test] +async fn test_list_sessions_multiple_sessions() { + let client = client(); + let ts = chrono::Utc::now().timestamp_millis(); + let handle = format!("multi-list-{}.test", ts); + let email = format!("multi-list-{}@test.com", ts); + let password = "test-password-123"; + let create_payload = json!({ + "handle": handle, + "email": email, + "password": password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let create_body: Value = create_res.json().await.unwrap(); + let did = create_body["did"].as_str().unwrap(); + let jwt1 = verify_new_account(&client, did).await; + let login_payload = json!({ + "identifier": handle, + "password": password + }); + let login_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url().await + )) + .json(&login_payload) + .send() + .await + .expect("Failed to login"); + assert_eq!(login_res.status(), StatusCode::OK); + let login_body: Value = login_res.json().await.unwrap(); + let jwt2 = login_body["accessJwt"].as_str().unwrap(); + let list_res = client + .get(format!( + "{}/xrpc/com.bspds.account.listSessions", + base_url().await + )) + .bearer_auth(jwt2) + .send() + .await + .expect("Failed to list sessions"); + assert_eq!(list_res.status(), StatusCode::OK); + let list_body: Value = list_res.json().await.unwrap(); + let sessions = list_body["sessions"].as_array().unwrap(); + assert!(sessions.len() >= 2, "Should have at least 2 sessions, got {}", sessions.len()); + let _ = jwt1; +} + +#[tokio::test] +async fn test_list_sessions_requires_auth() { + let client = client(); + let res = client + .get(format!( + "{}/xrpc/com.bspds.account.listSessions", + base_url().await + )) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_revoke_session_success() { + let client = client(); + let ts = chrono::Utc::now().timestamp_millis(); + let handle = format!("revoke-sess-{}.test", ts); + let email = format!("revoke-sess-{}@test.com", ts); + let password = "test-password-123"; + let create_payload = json!({ + "handle": handle, + "email": email, + "password": password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let create_body: Value = create_res.json().await.unwrap(); + let did = create_body["did"].as_str().unwrap(); + let jwt1 = verify_new_account(&client, did).await; + let login_payload = json!({ + "identifier": handle, + "password": password + }); + let login_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url().await + )) + .json(&login_payload) + .send() + .await + .expect("Failed to login"); + assert_eq!(login_res.status(), StatusCode::OK); + let login_body: Value = login_res.json().await.unwrap(); + let jwt2 = login_body["accessJwt"].as_str().unwrap(); + let list_res = client + .get(format!( + "{}/xrpc/com.bspds.account.listSessions", + base_url().await + )) + .bearer_auth(jwt2) + .send() + .await + .expect("Failed to list sessions"); + let list_body: Value = list_res.json().await.unwrap(); + let sessions = list_body["sessions"].as_array().unwrap(); + let other_session = sessions.iter().find(|s| s["isCurrent"].as_bool() != Some(true)); + assert!(other_session.is_some(), "Should have another session to revoke"); + let session_id = other_session.unwrap()["id"].as_str().unwrap(); + let revoke_res = client + .post(format!( + "{}/xrpc/com.bspds.account.revokeSession", + base_url().await + )) + .bearer_auth(jwt2) + .json(&json!({"sessionId": session_id})) + .send() + .await + .expect("Failed to revoke session"); + assert_eq!(revoke_res.status(), StatusCode::OK); + let list_after_res = client + .get(format!( + "{}/xrpc/com.bspds.account.listSessions", + base_url().await + )) + .bearer_auth(jwt2) + .send() + .await + .expect("Failed to list sessions after revoke"); + let list_after_body: Value = list_after_res.json().await.unwrap(); + let sessions_after = list_after_body["sessions"].as_array().unwrap(); + let revoked_still_exists = sessions_after.iter().any(|s| s["id"].as_str() == Some(session_id)); + assert!(!revoked_still_exists, "Revoked session should not appear in list"); + let _ = jwt1; +} + +#[tokio::test] +async fn test_revoke_session_invalid_id() { + let client = client(); + let (_, jwt) = setup_new_user("revoke-invalid").await; + let res = client + .post(format!( + "{}/xrpc/com.bspds.account.revokeSession", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({"sessionId": "not-a-number"})) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_revoke_session_not_found() { + let client = client(); + let (_, jwt) = setup_new_user("revoke-notfound").await; + let res = client + .post(format!( + "{}/xrpc/com.bspds.account.revokeSession", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({"sessionId": "999999999"})) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_revoke_session_requires_auth() { + let client = client(); + let res = client + .post(format!( + "{}/xrpc/com.bspds.account.revokeSession", + base_url().await + )) + .json(&json!({"sessionId": "1"})) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +}