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.ts — POST establish/unlock { password, authFactorToken? } (tighter limiter, e.g. makeLimiter("apppw-unlock", 10, 600) to bound guessing); DELETE = lock.
route.ts — GET list (elevated session → listAppPasswords), POST create { name } → returns the one-time secret.
revoke/route.ts — POST { 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.
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-basedcreateSessionsession.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/listAppPasswordsexist (401 unauth),createSessionrequires apassword(no passwordless variant), and it accepts anauthFactorTokenfield — 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
fetchwithAuthorization: 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 resultingaccessJwt/refreshJwt/pdsUrlin 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) callsdeleteSessionand 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.createSessionwith{ identifier: did, password, authFactorToken? }. Map results:{ accessJwt, refreshJwt, pdsUrl }in Redis keyapppw:elev:{did}, TTL ~600s (usegetRedis()fromsrc/lib/auth/stores.ts); return{ status: "ok" }.AuthFactorTokenRequired→ return{ status: "twoFactorRequired" }(PDS emails the code automatically); client re-submits withauthFactorToken.AuthenticationRequired(wrong password OR no password set — indistinguishable) →{ status: "invalid" }.getElevated(did)→ stored tokens or null.end(did)→ best-effortPOST {pds}/xrpc/com.atproto.server.deleteSessionwithAuthorization: Bearer <refreshJwt>(deleteSession auths with the refresh token), then Redisdel.New routes under
src/app/api/account/app-passwords/(App Router), all following the established order rate-limit → CSRF →getSessionDid()auth → parse → validate → execute (reusecheckCsrf,makeLimiter/enforceRateLimitkeyed by DID,parseJsonBody,extractRouteError,logSafe— seesrc/app/api/groups/register/route.tsfor the template):session/route.ts—POSTestablish/unlock{ password, authFactorToken? }(tighter limiter, e.g.makeLimiter("apppw-unlock", 10, 600)to bound guessing);DELETE= lock.route.ts—GETlist (elevated session →listAppPasswords),POSTcreate{ name }→ returns the one-time secret.revoke/route.ts—POST{ name }.List/create/revoke load
getElevated(did); if absent →401 { error: "locked" }so the UI re-prompts. Each does a plainfetch {pds}/xrpc/com.atproto.server.<op>withAuthorization: Bearer <accessJwt>.Security: never log password/tokens (
logSafe); short TTL;deleteSessionon 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.tsto target the new endpoints instead of/api/xrpc/*:unlockAppPasswords(password, authFactorToken?),lockAppPasswords(),listAppPasswords(),createAppPassword(name),revokeAppPassword(name). Keep the existingAppPasswordInfo/CreatedAppPasswordtypes and continue usingauthFetch(CSRF + 401 handling).unlockreturns a discriminated result (ok|twoFactorRequired|invalid).UI
src/components/settings/app-passwords-section.tsx— add alockedgate:AppDialog+AppDialogHeader/AppDialogBody, per the modal hard-rule; considerFormDialogfor the footer): step 1 passwordInput type="password"; if the route returnstwoFactorRequired, reveal step 2Input type="text" autoComplete="one-time-code"for the emailed code; resubmit withauthFactorToken. Oninvalid, inline error pointing to the Password section to set a password first (the funnel mirrorssrc/components/account/password-section.tsx).disableBackdropClosewhile submitting.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 passwordInputthat 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,AppDialogfor 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
src/app/api/indexer/__tests__/route.test.ts): unlock success path;twoFactorRequiredpassthrough;invalidmapping; list/create/revoke 401 when locked; CSRF/rate-limit/auth gates; assert password/tokens never appear in logs.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.twoFactorRequiredUI via a forced/mocked route response so the email-code step is covered regardless.npx tsc --noEmit,npm run lint, and the CLAUDE.md UI grep checks (radii, breakpoints, modal backdrops) stay clean.Risks / notes
AuthFactorTokenRequiredunconditionally.deleteSessionon lock, DID-scoped access, and Redis-only storage (never to the browser). Matches how OAuth sessions are already stored in Redis.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.