auth: sign in to a Fjord Account under SSO/OIDC (#218) #219

Merged
stephen merged 3 commits from feat/218-fjord-oidc-login into main 2026-07-21 06:09:50 +00:00
Owner

Fixes #218. fj auth login --fjord bailed with a StrategyMismatch error on any deployment whose sign-in strategy is oidc (the production shape from GET /api/auth/status), so every Fjord-Account command was unusable there and a Forgejo PAT was the only working path.

This drives the sign-in off GET /api/auth/status and adds the OIDC flow Paragon actually exposes: OAuth 2.0 Authorization Code + PKCE (S256) over a loopback redirect. Paragon's discovery advertises authorization_code + refresh_token with S256 and no device-authorization endpoint, so loopback + PKCE is the supported shape, not device code. fj signs in as the fj-cli public client through the platform's /oauth/* front door (the same one the iOS app uses). The access token the token endpoint returns is a Paragon session bearer that /api/v1/me/forge-instances already accepts, so it flows through the existing gateway unchanged; the refresh token enables silent renewal.

What changed

  • New src/fjord/oidc.rs: /api/auth/status probe, PKCE, authorize-URL builder, one-shot loopback listener, code + refresh token exchange, and keychain storage.
  • fj auth login --fjord branches on the probed strategy. The email + password flow is unchanged and remains the fallback for credentials deployments; a probe failure still falls through to it.
  • fj auth refresh renews an OIDC session silently from its stored refresh token; fj instances refreshes and retries once on a 401 (SessionExpired).
  • Access token stays in the existing Fjord bearer keychain slot; the refresh token lands in a new disjoint slot. Both are cleared on fj auth logout. fj auth status shows the sign-in method.
  • getrandom + ring promoted to direct deps for the PKCE verifier + S256 challenge. Both were already in the tree via reqwest's rustls stack, so the lockfile gains no new crates.

Deployment prerequisite

Paragon must register the fj-cli public OIDC client with allowed_grant_types = ["authorization_code", "refresh_token"] and these exact loopback redirect URIs (Paragon matches redirect URIs exactly, with no RFC 8252 loopback-port exemption):

http://127.0.0.1:47017/callback
http://127.0.0.1:47018/callback
http://127.0.0.1:47019/callback
http://127.0.0.1:47020/callback

fj binds the first free port in that list. The client id and (for staging) an alternate can be overridden with FJ_OIDC_CLIENT_ID. Until the client is registered, the PAT path keeps working, so this is not launch-blocking.

Checks

  • cargo fmt --all -- --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test --all (657 pass; new unit + wiremock tests mock the status/token/refresh endpoints)
  • cargo audit (known unmaintained-only ignores, unchanged)
Fixes #218. `fj auth login --fjord` bailed with a StrategyMismatch error on any deployment whose sign-in strategy is `oidc` (the production shape from `GET /api/auth/status`), so every Fjord-Account command was unusable there and a Forgejo PAT was the only working path. This drives the sign-in off `GET /api/auth/status` and adds the OIDC flow Paragon actually exposes: OAuth 2.0 Authorization Code + PKCE (S256) over a loopback redirect. Paragon's discovery advertises `authorization_code` + `refresh_token` with `S256` and no device-authorization endpoint, so loopback + PKCE is the supported shape, not device code. `fj` signs in as the `fj-cli` public client through the platform's `/oauth/*` front door (the same one the iOS app uses). The access token the token endpoint returns is a Paragon session bearer that `/api/v1/me/forge-instances` already accepts, so it flows through the existing gateway unchanged; the refresh token enables silent renewal. ## What changed - New `src/fjord/oidc.rs`: `/api/auth/status` probe, PKCE, authorize-URL builder, one-shot loopback listener, code + refresh token exchange, and keychain storage. - `fj auth login --fjord` branches on the probed strategy. The email + password flow is unchanged and remains the fallback for `credentials` deployments; a probe failure still falls through to it. - `fj auth refresh` renews an OIDC session silently from its stored refresh token; `fj instances` refreshes and retries once on a 401 (`SessionExpired`). - Access token stays in the existing Fjord bearer keychain slot; the refresh token lands in a new disjoint slot. Both are cleared on `fj auth logout`. `fj auth status` shows the sign-in method. - `getrandom` + `ring` promoted to direct deps for the PKCE verifier + S256 challenge. Both were already in the tree via reqwest's rustls stack, so the lockfile gains no new crates. ## Deployment prerequisite Paragon must register the `fj-cli` **public** OIDC client with `allowed_grant_types = ["authorization_code", "refresh_token"]` and these exact loopback redirect URIs (Paragon matches redirect URIs exactly, with no RFC 8252 loopback-port exemption): ``` http://127.0.0.1:47017/callback http://127.0.0.1:47018/callback http://127.0.0.1:47019/callback http://127.0.0.1:47020/callback ``` `fj` binds the first free port in that list. The client id and (for staging) an alternate can be overridden with `FJ_OIDC_CLIENT_ID`. Until the client is registered, the PAT path keeps working, so this is not launch-blocking. ## Checks - `cargo fmt --all -- --check` - `cargo clippy --all-targets --all-features -- -D warnings` - `cargo test --all` (657 pass; new unit + wiremock tests mock the status/token/refresh endpoints) - `cargo audit` (known unmaintained-only ignores, unchanged)
auth: sign in to a Fjord Account under SSO/OIDC (fj#218)
Some checks failed
ci / check (pull_request) Failing after 13m45s
ci / coverage (pull_request) Has been cancelled
ci / live-e2e (pull_request) Has been cancelled
8c7b6a82bf
`fj auth login --fjord` bailed with a StrategyMismatch error on any
deployment whose sign-in strategy is `oidc` (the production shape), so
every Fjord-Account command (`fj instances`, anything through the
gateway) was unusable there and a Forgejo PAT was the only working path.

Drive the flow off `GET /api/auth/status` and add the OIDC path Paragon
actually exposes: OAuth 2.0 Authorization Code + PKCE (S256) over a
loopback redirect (Paragon advertises no device-authorization grant).
`fj` is the `fj-cli` public client and signs in through the platform's
`/oauth/*` front door, the same one the iOS app uses. The access token
the token endpoint returns is a Paragon session bearer that
`/api/v1/me/forge-instances` already accepts, so it flows through the
existing gateway unchanged; the refresh token enables silent renewal.

- New `fjord::oidc`: status probe, PKCE, authorize URL, loopback
  listener, code/refresh token exchange, keychain storage.
- `auth login --fjord` branches on the probed strategy; the email +
  password flow is unchanged and still the fallback.
- `auth refresh` renews an OIDC session silently from its refresh token;
  `fj instances` refreshes and retries once on a 401.
- Access token in the existing Fjord bearer slot, refresh token in a new
  disjoint keychain slot; both cleared on logout.
- `getrandom` + `ring` promoted to direct deps (already in the tree via
  rustls, so no new crates) for the PKCE verifier + S256 challenge.

Paragon must register the `fj-cli` public client with the loopback
redirect URIs (see the PR). Tests mock the status/token/refresh
endpoints. Not launch-blocking: the PAT path still works.
docs: changelog entry for Fjord Account SSO/OIDC sign-in (fj#218)
All checks were successful
ci / check (pull_request) Successful in 11m9s
ci / coverage (pull_request) Successful in 2m8s
ci / live-e2e (pull_request) Successful in 2m8s
f7f8c65363
stephen force-pushed feat/218-fjord-oidc-login from f7f8c65363
All checks were successful
ci / check (pull_request) Successful in 11m9s
ci / coverage (pull_request) Successful in 2m8s
ci / live-e2e (pull_request) Successful in 2m8s
to 315b9490e0
All checks were successful
Forseti review / forseti review (advisory) (pull_request_target) Successful in 1m46s
ci / check (pull_request) Successful in 10m12s
ci / coverage (pull_request) Successful in 1m36s
ci / live-e2e (pull_request) Successful in 1m53s
2026-07-21 01:34:30 +00:00
Compare

Superseded by Forseti review for c97b2825fdec.

Forseti review

1 blocking finding(s) from the lead reviewer (advisory: not gating merges yet).

Findings

  • P1 src/fjord/oidc.rs:459 — The refresh token key is scoped only by platform URL, but store_tokens intentionally keeps any already-stored refresh token when the new token response omits refresh_token. login_fjord_oidc tells the user that no silent refresh is available in that case, yet a previous refresh token for the same platform can remain in the keychain. When the current access token later gets a 401, refresh_session loads that stored refresh token and exchanges it, which can silently restore a prior Fjord Account session rather than the account that just signed in.

  • PR: rasterstate/fj#219

  • Head SHA: 315b9490e09c

  • Review job: sha256:c4f59ccfadd8e32ba4ad626374bcbea639de0e08695969203606fc21ce4ea094

  • Provider pair: openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001

  • Blocking findings: 1

  • Inline findings: 1

  • Token source: GITHUB_TOKEN

  • Runner: ae4eb40225f3

  • Run: https://rasterhub.com/rasterstate/fj/actions/runs/366

<!-- forseti:review {"version":2,"repo":"rasterstate/fj","pr":219,"head_sha":"315b9490e09c4fdfd5929364e09f98517791f98d","provider_pair":"openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001","policy_version":"stub-policy-v1","prompt_version":"prompt-v2","context_fingerprint":"fnv64:d304ddc3a6734598","review_job_key":"sha256:c4f59ccfadd8e32ba4ad626374bcbea639de0e08695969203606fc21ce4ea094","base_sha":"c5f57b7a270773b9687baaeab89a39e6653ea9af","role":"summary","status":"superseded"} --> > Superseded by Forseti review for `c97b2825fdec`. ## Forseti review 1 blocking finding(s) from the lead reviewer (advisory: not gating merges yet). ### Findings - **P1** `src/fjord/oidc.rs:459` — The refresh token key is scoped only by platform URL, but `store_tokens` intentionally keeps any already-stored refresh token when the new token response omits `refresh_token`. `login_fjord_oidc` tells the user that no silent refresh is available in that case, yet a previous refresh token for the same platform can remain in the keychain. When the current access token later gets a 401, `refresh_session` loads that stored refresh token and exchanges it, which can silently restore a prior Fjord Account session rather than the account that just signed in. - PR: `rasterstate/fj#219` - Head SHA: `315b9490e09c` - Review job: `sha256:c4f59ccfadd8e32ba4ad626374bcbea639de0e08695969203606fc21ce4ea094` - Provider pair: `openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001` - Blocking findings: `1` - Inline findings: `1` - Token source: `GITHUB_TOKEN` - Runner: `ae4eb40225f3` - Run: https://rasterhub.com/rasterstate/fj/actions/runs/366
auth: bind the OIDC refresh token to the signed-in account
All checks were successful
Forseti review / forseti review (advisory) (pull_request_target) Successful in 1m30s
ci / check (pull_request) Successful in 10m1s
ci / live-e2e (pull_request) Successful in 1m54s
ci / coverage (pull_request) Successful in 2m2s
c97b2825fd
The refresh token was keyed only by platform URL, and store_tokens keeps
an already-stored token when a token response omits refresh_token. So a
fresh sign-in (or a credentials login layered over a prior OIDC login)
whose response carries no refresh token would leave a *different*
account's refresh token under that key. A later 401 then loaded it and
exchanged it, silently restoring the previous Fjord Account session
instead of the one that just signed in.

Persist the refresh token as a record bound to the account identity that
obtained it (the access token's user_id, falling back to email), and
gate every use on that identity matching the current bearer:

- store_tokens writes the identity-bound record; on a refresh-less
  issuance it keeps an existing token only when it belongs to the same
  account and drops one issued to a different account.
- refresh_session loads the current bearer, derives its identity, and
  only exchanges a refresh token bound to that same identity.
- fj auth status / fj auth refresh / instances auto-refresh all route
  their presence checks through valid_refresh_token(platform, bearer)
  so a cross-account token reads as absent.

Unit tests cover the identity derivation and that a refresh token is
usable only by the account that obtained it.

Forseti review

1 blocking finding(s) from the lead reviewer (advisory: not gating merges yet).

Findings

  • P1 src/fjord/oidc.rs:578refresh_session loads the currently stored refresh token and immediately exchanges it without any per-platform/process lock. The new automatic retry path can call this from multiple fj instances processes at the same time after both see a 401. Since the code documents that Paragon rotates refresh tokens and detects replay, two concurrent refreshes can both read the same old token; the first rotates and persists it, while the second replays the old token and can fail or invalidate the refreshed session. That turns a normal expired access token into a broken Fjord Account session until the user signs in again.

  • PR: rasterstate/fj#219

  • Head SHA: c97b2825fdec

  • Review job: sha256:65cc865e4157694dbe012a70af97ecb30f37343fd7140756aa409c11d14028f6

  • Provider pair: openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001

  • Blocking findings: 1

  • Inline findings: 1

  • Token source: GITHUB_TOKEN

  • Runner: 2bc6c173e4db

  • Run: https://rasterhub.com/rasterstate/fj/actions/runs/368

<!-- forseti:review {"version":2,"repo":"rasterstate/fj","pr":219,"head_sha":"c97b2825fdec12baeba8b78de66227bae3a9c0b4","provider_pair":"openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001","policy_version":"stub-policy-v1","prompt_version":"prompt-v2","context_fingerprint":"fnv64:70455b6d6d23b5be","review_job_key":"sha256:65cc865e4157694dbe012a70af97ecb30f37343fd7140756aa409c11d14028f6","base_sha":"c5f57b7a270773b9687baaeab89a39e6653ea9af","role":"summary","status":"current"} --> ## Forseti review 1 blocking finding(s) from the lead reviewer (advisory: not gating merges yet). ### Findings - **P1** `src/fjord/oidc.rs:578` — `refresh_session` loads the currently stored refresh token and immediately exchanges it without any per-platform/process lock. The new automatic retry path can call this from multiple `fj instances` processes at the same time after both see a 401. Since the code documents that Paragon rotates refresh tokens and detects replay, two concurrent refreshes can both read the same old token; the first rotates and persists it, while the second replays the old token and can fail or invalidate the refreshed session. That turns a normal expired access token into a broken Fjord Account session until the user signs in again. - PR: `rasterstate/fj#219` - Head SHA: `c97b2825fdec` - Review job: `sha256:65cc865e4157694dbe012a70af97ecb30f37343fd7140756aa409c11d14028f6` - Provider pair: `openai:gpt-5.5+anthropic:claude-haiku-4-5-20251001` - Blocking findings: `1` - Inline findings: `1` - Token source: `GITHUB_TOKEN` - Runner: `2bc6c173e4db` - Run: https://rasterhub.com/rasterstate/fj/actions/runs/368
Sign in to join this conversation.
No description provided.