Skip to content

feat(login): OAuth device-flow login + token refresh; submit via token route - #4

Merged
ZacxDev merged 2 commits into
mainfrom
zach/cli-device-login
Jun 19, 2026
Merged

feat(login): OAuth device-flow login + token refresh; submit via token route#4
ZacxDev merged 2 commits into
mainfrom
zach/cli-device-login

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Adds a browser-based OAuth device-authorization grant to the CLI so civitai login (no flags) authenticates via the browser and stores auto-refreshing tokens that work for app submit and whoami. Implements the server contract from civitai/civitai PR #2644.

What changed

  • civitai login (no --token): device init (POST /api/auth/oauth/device) → print the verification URL + user code (best-effort browser-open of verification_uri_complete; --no-browser to skip) → poll POST /api/auth/oauth/device-token every intervals, respecting slow_down (interval bump) and stopping at expires_in → store tokens. expired_token/access_denied surface a clear terminal error. civitai login --token <key> stays unchanged (personal API key, no refresh).
  • Token storage (internal/config): stores access_token, refresh_token, absolute token_expiry, scope, and an auth_kind marker (oauth vs token), alongside the legacy single-token personal key. Backward compatible, written 0600. CIVITAI_TOKEN env is always treated as a personal key.
  • Refresh (internal/auth.Source, an api.TokenSource): before a request it refreshes an expired OAuth access token (POST /api/auth/oauth/token), persists the rotated refresh token if the server returns one, and retries once on a 401. A personal key is served as-is with no refresh. whoami + app submit both use it, so they refresh transparently.
  • app submit uploads by default to the v1 token route /api/v1/blocks/submit-version (CIVITAI_SUBMIT_PATH still overrides). Stale "not yet automated" copy + README updated.

Client id civitai-cli is a public (PKCE/device) client — no secret. Scope 33554433 = UserRead|AppBlocksSubmit, hardcoded as CLI constants.

Tests (go build ./... && go test ./... green)

  • Device happy path against httptest: init → two authorization_pending polls → 200 success → tokens persisted (config contents + 0600 asserted).
  • slow_down increases the interval; expired_token/access_denied terminal errors; overall timeout at expires_in.
  • Refresh: expired access token triggers a refresh, persists the rotated refresh token, retried request succeeds; a 401 mid-request triggers one refresh+retry; personal key never refreshes.
  • app submit posts to /api/v1/blocks/submit-version with the stored Bearer + bundleBase64 body.
  • Personal-key (--token) path still works and does not refresh.

Self-audit

Tokens persisted 0600, never logged/printed (the test asserts the device_code secret doesn't leak); refresh rotation handled (old refresh token retained when not rotated); the personal-key path is unchanged; submit defaults to the v1 token route.

Not verified

Real end-to-end against prod — needs PR #2644 merged and the civitai-cli OAuth client row provisioned server-side.

🤖 Generated with Claude Code

ZacxDev and others added 2 commits June 19, 2026 07:58
…n route

Add a browser-based OAuth device-authorization grant to `civitai login` so
`civitai login` (no flags) authenticates via the browser and stores
auto-refreshing tokens that work for `app submit` and `whoami`. Implements the
civitai server contract from PR #2644.

- `civitai login` (no --token): device init -> print verification URL + user
  code (best-effort browser open of verification_uri_complete; --no-browser to
  skip) -> poll /api/auth/oauth/device-token every `interval`s (respects
  slow_down, stops at expires_in) -> store tokens. `--token <key>` still stores
  a personal API key unchanged.
- config: stores access/refresh tokens, absolute access-token expiry, scope,
  and an auth_kind marker (oauth vs token) alongside the legacy single-`token`
  personal key (backward compatible, 0600). CIVITAI_TOKEN env is always treated
  as a personal key.
- internal/auth.Source bridges config <-> api.TokenSource: refreshes an expired
  OAuth access token before a request (and once on a 401), persists the rotated
  refresh token, retries once. A personal key is served as-is with no refresh.
- app submit now uploads by default to the v1 token route
  /api/v1/blocks/submit-version (CIVITAI_SUBMIT_PATH still overrides); stale
  "not yet automated" copy updated.

Client id `civitai-cli` is a public (PKCE/device) client, no secret; scope
33554433 = UserRead|AppBlocksSubmit.

Tests: device happy path (init -> 2 pending -> success, tokens persisted 0600),
slow_down interval bump, expired_token/access_denied terminal errors, overall
timeout, refresh rotation + persistence, 401-refresh-retry, personal-key no-
refresh, and app submit posts to /api/v1/blocks/submit-version with the Bearer +
bundleBase64 body. `go build ./... && go test ./...` green. Tokens/device-code
are never logged.

Not verified: real end-to-end against prod (needs PR #2644 merged + the
civitai-cli OAuth client row provisioned).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… fallback path, expires_in guard

The token/refresh route returns the @node-oauth/oauth2-server shape where
`scope` is a JSON ARRAY of strings (e.g. ["33554433"]), while the device-token
(login) route returns a plain string. `TokenResponse.Scope` was a plain
`string`, so json.Unmarshal of the refresh body failed the entire struct and
Refresh() returned "unexpected refresh response" — after the 1h access-token
TTL every authenticated command failed and never self-healed.

- C1: new `Scope` string type with `UnmarshalJSON` accepting BOTH a JSON string
  and a JSON array of strings (array normalized to a space-joined string per
  OAuth convention); applied to `TokenResponse.Scope`. Both server shapes now
  parse cleanly. Call sites convert via `.String()`.
- M1: real-contract tests — replay the actual array-shaped refresh body
  (`"scope":["33554433"]`) and the string-shaped device-token body; the array
  test fails without C1 and passes with it. Plus source-level round-trip +
  expires_in-guard tests.
- M2: cap the RFC 8628 +5s-per-slow_down backoff at 60s (maxPollInterval) so a
  repeated/misbehaving slow_down can't stretch the poll unboundedly.
- L1: fix the verification_uri fallback `/oauth/device` -> `/login/oauth/device`.
- L4: floor-guard refresh `expires_in` — reject a non-positive value instead of
  persisting a token that expires ~now (refresh-on-every-call).
- M3 (not fixed, documented): concurrent-process refresh race vs server
  revoke-on-refresh needs a cross-process file lock; left as a code-comment
  follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ZacxDev
ZacxDev merged commit e39c9c9 into main Jun 19, 2026
1 check passed
@ZacxDev
ZacxDev deleted the zach/cli-device-login branch June 19, 2026 13:34
ZacxDev added a commit that referenced this pull request Jun 19, 2026
…#5)

Refresh() POSTed a JSON body with Content-Type: application/json to
/api/auth/oauth/token, but that endpoint is the @node-oauth/oauth2-server token
handler, which REQUIRES application/x-www-form-urlencoded and rejects JSON with
"content must be application/x-www-form-urlencoded". The custom device-flow
endpoints (/device, /device-token) accept JSON, so LOGIN worked — but every
refresh failed, so OAuth auth silently died after the 1h access-token TTL
(observed live: `whoami` → "device login failed: ... must be
application/x-www-form-urlencoded").

Fix: add postForm() (x-www-form-urlencoded) and send the refresh grant through
it. Verified live: an expired session refreshes cleanly against prod.

The bug slipped #4's tests because TestRefreshRotatesToken's mock decoded the
request body as JSON (accepting the wrong content-type). Hardened it to assert
Content-Type: application/x-www-form-urlencoded + ParseForm — it now FAILS on a
JSON refresh and PASSES on the form-encoded one.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ZacxDev added a commit that referenced this pull request Aug 3, 2026
…s a to-do list

The app-analytics handoff has been sitting on this branch, unmerged, describing three open
PRs and a re-gate in flight. All of that shipped — 8 PRs across both repos, plus follow-ups
#1, #2 and #4 — so as written the doc's most prominent content is a set of live-sounding
directives for work that is done. A stranded doc that is also stale is worse than no doc.

Changes:

- A RESOLVED banner up top with the full merged-PR table, replacing "nothing merged".

- Neutralised the "Do not merge until that re-gate reports" imperative, and recorded that
  the rebase it anticipated WAS needed, for a different reason: #3566 landed later and
  edited the same `detail: {}` object, turning #3561 CONFLICTING after its gate had passed.

- Struck follow-ups #1, #2 and #4 with what actually happened, including the two places
  this doc was WRONG:
    * #1's entry missed a second proc with the identical live defect —
      `getMyForgejoCloneInfo`, which `civitai app pull` drives. It was found by an audit,
      not by the list, which is worth knowing about ranked follow-up lists in general: the
      list is not a survey.
    * #2's suggested fix (reuse `humaniseScopeEndpoint`) would have shipped a bug. Measured
      against the real function it returns '(no workflow id)' for `workflow:submit` and ''
      for `user-settings:write`, because it is the per-ROW labeller and an aggregate bucket
      has no `detail`.

- Recorded #1's scope decision with the prod evidence that later confirmed it: 331 live
  tokens unblocked, 30 of which lack bit 26 — so copying the nearest precedent
  (AppBlocksDevTunnel) would have left those 30 still 403ing. Plus the measurement trap:
  `(mask & Full) = Full` is also true when `mask == Full` and reports 145 false hits; the
  strict-superset form needs `AND mask <> Full`.

- New "Still open — start here" section: the CI `component`-tier gap (three PRs shipped
  browser tests that have never run on a canonical browser), the stale-node_modules trap
  that silently removes ~1,126 tests, the unverified `addCollaborator` downgrade lead, and
  a note that `installs: 0` should be assumed broken until a positive control exists.

- "What actually caught the bugs": across 12 adversarial audit rounds every fix round found
  a defect in the previous fix, and the mechanical gate caught none of them — the suite and
  typecheck were green at every tip.

Doc-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant