Commit Graph
48 Commits
Author SHA1 Message Date
Evan JarrettandClaude Opus 5 e75b2e246b oauth: compare-and-swap session writes so a concurrent refresh cannot delete a live session
Refresh tokens rotate on use, and DoWithSession serializes refreshes per DID with
an in-process mutex. That is the right mechanism and it protects nothing once
there are two instances: both can refresh the same account at the same time, the
slower one presents a refresh token the auth server has already superseded, gets
invalid_grant, and isAuthError deletes the session. The user is signed out
mid-push, and the session another instance had just legitimately refreshed is
destroyed along with it.

oauth_sessions gains a rev that increments on every write. A store that has read
a session writes with a compare-and-swap against the revision it read and gets
ErrSessionRevConflict if anyone wrote first, so a stale writer can no longer
replace rotated tokens with invalidated ones. The persist callback treats that
conflict as an ordinary outcome rather than an error, since leaving the newer
state alone is exactly right.

The delete path is now guarded by the same signal. An auth error on a session
whose revision has moved since we read it means "someone else refreshed this",
not "this session is dead", so it retries once against the newer tokens instead
of deleting. Exactly once: a second failure means staleness was not the problem,
and looping would hold the per-DID lock while getting the same answer.

The guard is deliberately conservative. A store without revisions, no recorded
revision, a failed lookup, a session that is simply gone: all answer "not
advanced" and keep the previous delete-on-error behavior. Wrongly claiming a
concurrent refresh would keep a genuinely dead session alive with no way out but
waiting; wrongly missing one costs a re-login.

The sentinel lives in pkg/auth/oauth rather than next to the SQLite store,
because pkg/appview/db already imports pkg/auth/oauth and the other direction
would be an import cycle. The db package re-exports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:40:12 -05:00
Evan JarrettandClaude Opus 5 500ee2f8d1 auth: classify service-token failures structurally, not by string
Follow-up to 37bab32. That commit stopped deleting OAuth sessions on transient
errors, which fixed spurious sign-outs but overshot on one path: a genuinely dead
session stopped being evicted at all, turning a forced re-login into a permanent
failure loop.

GetOrFetchServiceToken flattened every non-200 from getServiceAuth into
fmt.Errorf("service auth failed with status %d: %s"). IsSessionInvalidError then
had nothing structured to inspect, and its string fallback could not help: it
looks for the OAuth 2.0 code invalid_token, while atproto emits the XRPC name
InvalidToken. The difference is the underscore, not the case, so lowercasing
never bridged it. A revoked session came back 401 InvalidToken and was classified
transient, so /auth/token returned 503 forever and the user was never prompted to
re-authenticate.

The non-200 branch now wraps an *atclient.APIError carrying the status and the
parsed atproto error name, which is what the existing structured checks in
IsSessionInvalidError already know how to read. Transient shapes stay transient:
atprotoErrorName returns "" for a non-JSON body, so 500s with HTML, 502s, and
429s do not evict.

ExpiredToken is deliberately not treated as a dead session. It means "refresh
me", and deleting on it would sign the user out of every UI session over an
ordinary access-token expiry a refresh would have fixed. isAuthError omits it for
the same reason; the two classifiers have to agree about the same condition.

The comment on the string fallback claimed it was a looser spelling of the
structured check. It is not — it handles a different error family. indigo's
RefreshTokens returns OAuth token-endpoint failures as a bare fmt.Errorf carrying
the auth server's snake_case code verbatim ("token refresh failed (HTTP 400):
invalid_grant"), never a typed error, so a string match is the only thing that
can classify a refresh failure, which is the invalid_grant replay case 37bab32
exists to detect. Both comments now say which family they cover.

Two hardening items on the same theme:

use_dpop_nonce no longer counts as an auth error in the appview's isOAuthError.
It is a routine handshake step indigo retries with the server-supplied nonce, and
treating it as fatal signed users out over ordinary nonce rotation. It can still
escape when a server sends that error with no DPoP-Nonce header, leaving indigo
nothing to retry with; a stuck session there is preferable to signing everyone
out in the common case, and the comment says so rather than claiming it cannot
happen.

Detached session deletes are bounded by SessionDeleteTimeout. They run on
context.WithoutCancel so a canceled request cannot leave the cleanup half-done,
which also stripped the only deadline they had — a wedged database write blocked
the goroutine with no way to shed it. Matches the bound already on the detached
persist callback. The unparseable-token-endpoint warning is now deduped per
endpoint rather than once per process, since that path fails open by returning
the client unwrapped, silently reinstating the refresh burn.

The refreshDetachTimeout comment now notes the cap is per-POST: the DPoP-nonce
retry means one refresh can issue two, holding the per-DID lock for up to twice
the stated value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:19:59 -05:00
Evan JarrettandClaude Fable 5 37bab324d7 fix OAuth refresh-token burn on client cancellation causing sign-outs
When a Docker client canceled a slow /auth/token request mid-refresh, the
token-refresh POST was aborted client-side but completed on the PDS, which
rotated the refresh token. The rotated token was never received or persisted,
so the next refresh replayed the consumed token, got invalid_grant, and the
session (OAuth + UI) was deleted, signing the user out everywhere.

- Detach refresh POSTs from the inbound request context via a per-session
  RoundTripper (WithoutCancel + 30s cap); once a refresh starts it completes
- Persist session updates (rotated tokens, DPoP nonces) on a detached context
- Gate session deletion on IsSessionInvalidError: cancellation, timeouts, and
  transport errors no longer delete sessions; genuine invalid_grant still does
- Add phase timing to /auth/token and per-DID lock wait warnings to attribute
  the ~14s pre-refresh stalls that push requests past Docker's deadline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:38:45 -05:00
Evan Jarrett 902fba4553 convert alert to modal. go fix the codebase 2026-05-16 11:59:47 -05:00
Evan Jarrett 7c064ba8b0 fix error code checking to not just check the raw string response in the case that '401' shows up in the sha256 2026-02-27 19:51:39 -06:00
Evan Jarrett 6b87539ef8 update scanner, fix tests, fix dockerfile, move keys to db instead of flat files for appview 2026-02-16 21:04:40 -06:00
Evan Jarrett f35bf2bcde fix oauth scope mismatch 2026-01-05 20:26:41 -06:00
Evan Jarrett aa4b32bbd6 basic implementation of quotas 2026-01-04 20:09:41 -06:00
Evan Jarrett 347db5c391 begin support for helm-charts 2026-01-02 13:09:04 -06:00
Evan Jarrett c1f2ae0f7a fix scope mismatch? 2025-12-26 17:41:38 -06:00
Evan Jarrett 012a14c4ee try fix permission scope again 2025-12-26 17:13:19 -06:00
Evan Jarrett 4cda163099 add back individual scopes 2025-12-26 17:05:51 -06:00
Evan Jarrett 41bcee4a59 try new permission sets 2025-12-26 16:51:49 -06:00
Evan Jarrett d11356cd18 more improvements on repo page rendering. allow for repo avatar image uploads (requires new scopes) 2025-12-21 21:51:44 -06:00
Evan Jarrett 79d1126726 better handling for io.atcr.repo.page 2025-12-20 21:50:09 -06:00
Evan Jarrett 4063544cdf cleanup view around attestations. credential helper self upgrades. better oauth support 2025-12-18 09:33:31 -06:00
Evan Jarrett 66037c332e locks locks locks locks 2025-11-24 22:49:17 -06:00
Evan Jarrett fb7ddd0d53 try and create a cache for layer pushing again 2025-11-24 13:25:24 -06:00
Evan Jarrett 3bdc0da90b try and lock session get/update 2025-11-09 15:04:44 -06:00
Evan Jarrett 628f8b7c62 try and trace oauth failures 2025-11-09 13:07:35 -06:00
Evan Jarrett 15d3684cf6 try and fix bad oauth cache 2025-11-08 20:47:57 -06:00
Evan Jarrett 9af56daa34 make navbar a component 2025-10-29 17:25:39 -05:00
Evan Jarrett 6793ba6a50 use confidential oauth in production 2025-10-29 12:06:47 -05:00
Evan Jarrett e62ebdaa53 clean up duplicate functions 2025-10-28 09:43:43 -05:00
Evan Jarrett 4cfe6f221d create identity resolver to reduce duplicate lookups 2025-10-26 23:08:03 -05:00
Evan Jarrett 0b22082f89 lower cached plc hits to 8 hours 2025-10-26 22:50:42 -05:00
Evan Jarrett 9eb69e2ea7 more linting fixes 2025-10-24 01:05:19 -05:00
Evan Jarrett 16f354b7b9 fix some backfill and db queries 2025-10-21 20:52:51 -05:00
Evan Jarrett 0404ea025b try and wildcard scope 2025-10-21 14:03:20 -05:00
Evan Jarrett 2708af614a use transition:generic for now :( 2025-10-21 13:27:03 -05:00
Evan Jarrett 1f72d90726 fix issue with mismatched scopes locally 2025-10-21 10:49:06 -05:00
Evan Jarrett 08fb8abb41 invalidate sessions when scopes change 2025-10-21 09:54:23 -05:00
Evan Jarrett 965e73881b try and support multi-arch manifest types. add more unit tests. add scope for oras blobs for future proofing 2025-10-19 22:26:47 -05:00
Evan Jarrett 7228b532ba bug fixes, code cleanup, tests. trying to get multipart uploads working for the 12th time 2025-10-19 20:55:18 -05:00
Evan Jarrett 1658a53cad docker push works, hold endpoints require auth 2025-10-18 20:11:36 -05:00
Evan Jarrett 48414be75d lots of unit testing for xrpc endpoints. start pointing appview to the new endpoints. remove legacy api endpoints 2025-10-17 15:41:20 -05:00
Evan Jarrett 50d5eea4a5 add oci manifest scope 2025-10-17 09:56:26 -05:00
Evan Jarrett 34ace405fd more oauth fixes for hold service 2025-10-10 15:01:47 -05:00
Evan Jarrett c77761a52d implement stars UI 2025-10-08 13:24:39 -05:00
Evan Jarrett 5bcedac3c1 update docker credential helper to remove configure and store devices, not api keys. improve ui, fetch profile image 2025-10-07 19:52:26 -05:00
Evan Jarrett 2d16bbfee3 cleanup more auth 2025-10-07 10:58:11 -05:00
Evan Jarrett 5b18538a8b refactor oauth to use indigo 2025-10-06 22:24:19 -05:00
Evan Jarrett 678a11d1b7 backfill via com.atproto.sync.listReposByCollection 2025-10-06 21:24:46 -05:00
Evan Jarrett 22d0faa3a3 refactor oauth code to reduce complexity 2025-10-05 16:03:03 -05:00
Evan Jarrett 13fbfe3684 oauth working 2025-10-04 21:19:49 -05:00
Evan Jarrett 31e235a2a1 cleanup oauth 2025-10-04 13:50:28 -05:00
Evan Jarrett a200e7b23b fix docker push command 2025-10-03 15:55:45 -05:00
Evan Jarrett 85d0bd2463 initial commit 2025-10-02 11:03:59 -05:00