Skip to content

Enable in-app app-password management over OAuth (transient elevated password session) #223

Description

@holkexyz

Context

certified-app is OAuth-only: every /api/xrpc/* call is proxied through the user's OAuth session (src/lib/auth/fetch.ts). But atproto's app-password endpoints (com.atproto.server.listAppPasswords / createAppPassword / revokeAppPassword) categorically reject OAuth credentials ("OAuth credentials are not supported for this endpoint", HTTP 403). They require a full password-based createSession session.

Result: Settings → App passwords (src/components/settings/app-passwords-section.tsx) 403s for every user on load (list) and on create — it has never worked. This matters because the group import flow (src/components/settings/import-as-group-section.tsx) needs an app password, and a passwordless email-OTP / OAuth user currently has no in-app way to produce one.

Verified against the live PDS (certified.one, did:web:certified.one, a standard atproto PDS): createAppPassword/listAppPasswords exist (401 unauth), createSession requires a password (no passwordless variant), and it accepts an authFactorToken field — i.e. the standard email-2FA branch (AuthFactorTokenRequired) is supported. Whether a given account has email 2FA enabled can't be probed remotely (a wrong password errors before the factor check), so the flow must handle the 2FA branch unconditionally — then the default is irrelevant.

Outcome: make the App passwords section fully functional (list / create / revoke) by collecting the account password once, opening a short-lived server-side elevated session, and performing the operations through it — never exposing PDS tokens to the browser and never re-prompting per action. Add a "Create one" shortcut at the group-import password field.

Password-session calls use Bearer JWT auth (no DPoP) — DPoP is OAuth-only — so the new route is plain fetch with Authorization: Bearer <jwt>, much simpler than the OAuth proxy.

Approach

A short-lived elevated session: the user unlocks the section once with their password (+ emailed code if 2FA is on); the server runs createSession, stores the resulting accessJwt/refreshJwt/pdsUrl in Redis under a ~10-min TTL keyed by the caller's DID, and all subsequent list/create/revoke calls use that stored session server-side. "Lock" (or TTL expiry) calls deleteSession and clears Redis.

Backend

New helper src/lib/auth/app-password-session.ts:

  • establish(did, password, authFactorToken?): resolvePdsUrl(did) (src/lib/atproto/did.ts) → POST {pds}/xrpc/com.atproto.server.createSession with { identifier: did, password, authFactorToken? }. Map results:
    • success → store { accessJwt, refreshJwt, pdsUrl } in Redis key apppw:elev:{did}, TTL ~600s (use getRedis() from src/lib/auth/stores.ts); return { status: "ok" }.
    • error AuthFactorTokenRequired → return { status: "twoFactorRequired" } (PDS emails the code automatically); client re-submits with authFactorToken.
    • error AuthenticationRequired (wrong password OR no password set — indistinguishable) → { status: "invalid" }.
  • getElevated(did) → stored tokens or null.
  • end(did) → best-effort POST {pds}/xrpc/com.atproto.server.deleteSession with Authorization: Bearer <refreshJwt> (deleteSession auths with the refresh token), then Redis del.

New routes under src/app/api/account/app-passwords/ (App Router), all following the established order rate-limit → CSRF → getSessionDid() auth → parse → validate → execute (reuse checkCsrf, makeLimiter/enforceRateLimit keyed by DID, parseJsonBody, extractRouteError, logSafe — see src/app/api/groups/register/route.ts for the template):

  • session/route.tsPOST establish/unlock { password, authFactorToken? } (tighter limiter, e.g. makeLimiter("apppw-unlock", 10, 600) to bound guessing); DELETE = lock.
  • route.tsGET list (elevated session → listAppPasswords), POST create { name } → returns the one-time secret.
  • revoke/route.tsPOST { name }.

List/create/revoke load getElevated(did); if absent → 401 { error: "locked" } so the UI re-prompts. Each does a plain fetch {pds}/xrpc/com.atproto.server.<op> with Authorization: Bearer <accessJwt>.

Security: never log password/tokens (logSafe); short TTL; deleteSession on lock; DID-keyed authorization (only act on the caller's own account); CSRF on all mutating routes.

Lib layer

Rewrite src/lib/atproto/app-passwords.ts to target the new endpoints instead of /api/xrpc/*: unlockAppPasswords(password, authFactorToken?), lockAppPasswords(), listAppPasswords(), createAppPassword(name), revokeAppPassword(name). Keep the existing AppPasswordInfo / CreatedAppPassword types and continue using authFetch (CSRF + 401 handling). unlock returns a discriminated result (ok | twoFactorRequired | invalid).

UI

src/components/settings/app-passwords-section.tsx — add a locked gate:

  • Locked (default): short explanation + Unlock button (can't list without the session).
  • Unlock modal (AppDialog + AppDialogHeader/AppDialogBody, per the modal hard-rule; consider FormDialog for the footer): step 1 password Input type="password"; if the route returns twoFactorRequired, reveal step 2 Input type="text" autoComplete="one-time-code" for the emailed code; resubmit with authFactorToken. On invalid, inline error pointing to the Password section to set a password first (the funnel mirrors src/components/account/password-section.tsx). disableBackdropClose while submitting.
  • Unlocked: render today's list + create form + one-time reveal + revoke (largely the existing JSX), wired to the new lib calls; add a Lock action. Handle a 401 locked (TTL expiry) by dropping back to the locked state.

src/components/settings/import-as-group-section.tsx — add a "Don't have an app password? Create one" affordance above the password Input that opens the same unlock→create flow; on reveal offer "Use this password" to fill the import field (autofill optional).

Reuse primitives only (Button, Input, Banner, ErrorMessage, LoadingSpinner); follow design hard-rules (var(--radius), tokens only, AppDialog for the modal, canonical breakpoints, dark-mode safe).

Files

Create:

  • src/lib/auth/app-password-session.ts — elevated-session helper (establish/get/end).
  • src/app/api/account/app-passwords/session/route.ts — unlock (POST) / lock (DELETE).
  • src/app/api/account/app-passwords/route.ts — list (GET) / create (POST).
  • src/app/api/account/app-passwords/revoke/route.ts — revoke (POST).

Modify:

  • src/lib/atproto/app-passwords.ts — point at the new routes; add unlock/lock.
  • src/components/settings/app-passwords-section.tsx — locked gate + unlock modal + unlocked manage.
  • src/components/settings/import-as-group-section.tsx — "Create one" shortcut.

Reuse (no change): getSessionDid (auth/session.ts), resolvePdsUrl (atproto/did.ts), checkCsrf (auth/csrf.ts), makeLimiter/enforceRateLimit (auth/rate-limit.ts), parseJsonBody/extractRouteError (utils/api.ts), logSafe (utils/log-safe.ts), getRedis (auth/stores.ts), AppDialog/FormDialog + UI primitives.

Verification

  • Unit/route tests (Vitest, mirror src/app/api/indexer/__tests__/route.test.ts): unlock success path; twoFactorRequired passthrough; invalid mapping; list/create/revoke 401 when locked; CSRF/rate-limit/auth gates; assert password/tokens never appear in logs.
  • End-to-end on the dev server (127.0.0.1:3000, signed in): open Settings → App passwords → Unlock with the real account password. Confirm: list renders, create reveals a secret once, revoke removes it, Lock clears it, and the console no longer shows the 403 ("OAuth credentials are not supported"). If the account has 2FA on, confirm the email-code step appears and completes.
  • 2FA branch: if the live account has 2FA off, exercise the twoFactorRequired UI via a forced/mocked route response so the email-code step is covered regardless.
  • Gates: npx tsc --noEmit, npm run lint, and the CLAUDE.md UI grep checks (radii, breakpoints, modal backdrops) stay clean.

Risks / notes

  • The only unknown — whether 2FA is on by default for ePDS accounts — is neutralized by handling AuthFactorTokenRequired unconditionally.
  • Elevated session stores a full-privilege password session server-side; mitigated by short TTL, deleteSession on lock, DID-scoped access, and Redis-only storage (never to the browser). Matches how OAuth sessions are already stored in Redis.
  • "Wrong password" vs "no password set" are indistinguishable from createSession; the unlock error copy covers both and links to set a password.

Background

Discovered while debugging the Settings page 403s. Related indexer issues filed this session: hypercerts-org/magic-indexer#238, #239, #240.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions