Skip to content

feat: share one Ory login session between S3 credentials and the source.coop API - #23

Merged
alukach merged 1 commit into
mainfrom
feat/login-session
Oct 9, 2026
Merged

alukach merged 1 commit into
mainfrom
feat/login-session

Conversation

@alukach

@alukach alukach commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Part of #21. This is the auth groundwork for the gh-style commands: one login now works for both S3 and the source.coop API.

login asks Ory for an access token whose audience is the source.coop site (--audience / SOURCE_API_AUDIENCE, default https://source.coop, or https://staging.source.coop in staging builds). It caches the whole Ory session (refresh, ID and access tokens) once, under @session in the keyring or source-coop/session.json, rather than a refresh token per role. creds mints for any role, including one never logged into, by exchanging the session's ID token at /.sts. The new source-coop auth token prints the access token, refreshed if it has expired.

The refresh token lives only in the session. It rotates on use, and a replayed one can revoke the whole token family, so every role and every API call refreshes through one function under one lock, and one refresh renews both tokens. A role's cache keeps only its STS settings. Caches written by 0.3.0 still refresh with their own token until the next login, so nobody has to log in again on upgrade. 0.3.0 reading a cache written by this version serves the credentials until they expire, then says to log in.

Deploying: the CLI's Ory clients must allow the site as an audience and use the jwt access-token strategy before this is released, or login is refused. See the runbook below, steps 1, 2 and 6.

Testing

  • cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo test (28 passed) all pass.
  • The session tests cover a fresh token used as is, an expired access or ID token refreshing and rotating, the rotated token being saved before use, no access token without an audience, and an expired session with no refresh token saying to log in.
  • End to end, I ran the binary against a local stand-in for Ory, /.sts and the API, with an expired session cached:
    • One refresh rotated the refresh token.
    • auth token and API calls sent the new access token.
    • creds minted for a role with nothing cached, using the ID token that same refresh brought.
  • Not run against real Ory: the client configuration in runbook step 1 doesn't exist yet.

Deployment runbook (all four PRs)

Staging goes first at every step, and production only after staging checks out. Nothing here is hard to undo; each phase lists its rollback.

0. Get the branches onto GitHub

Done when you're reading this: open-prs.sh opened these four drafts, linked them to each other, and put the source.coop PR's number into ADR-005's note.

1. Configure the CLI's Ory OAuth2 clients (staging, then production)

The CLI's clients are a79c9537-be78-454a-9ea1-b96a1be811cc (staging) and 197e20e7-d52d-4d1d-9e54-4b73a342034b (production). Change only these clients, not the project and not the web app's client.

  1. Back up the client before changing it:
    ory get oauth2-client <client-id> --project <project> --format json > cli-client-before.json
  2. Patch it rather than updating it. ory update oauth2-client replaces the whole client and drops any field you leave out. Check the flags with ory patch oauth2-client --help for your CLI version:
    • Add the site to its allowed audiences: https://staging.source.coop on the staging client, https://source.coop on the production client.
    • Set access_token_strategy to jwt.
    • Confirm offline_access is among its scopes and refresh_token among its grant types. 0.3.0's refresh already relies on both, so they should be there.
  3. Check that the current release still works: run source-coop login and source-coop creds with the installed 0.3.0. It never asks for an audience, and its ID tokens are unaffected by the access-token strategy.

Rollback: restore cli-client-before.json with ory update oauth2-client <client-id> --file cli-client-before.json. A full replace is what you want here.

2. Read a real token's issuer (staging)

The API matches iss exactly, with or without a trailing slash, so check what Ory actually puts there before deploying the API change.

  1. Build the CLI branch for staging: git checkout feat/login-session && cargo build --features staging.
  2. Log in and print the token's claims:
    ./target/debug/source-coop login
    ./target/debug/source-coop auth token | python3 -c 'import sys,json,base64;p=sys.stdin.read().split(".")[1];print(json.dumps(json.loads(base64.urlsafe_b64decode(p+"="*-len(p)%4)),indent=2))'
  3. Check that:
    • The token has three dot-separated parts. If it doesn't, it's opaque: the jwt strategy from step 1 isn't in effect.
    • aud contains https://staging.source.coop. If it doesn't, the consent step didn't grant the audience.
    • iss equals staging's NEXT_PUBLIC_ORY_SDK_URL, with or without a trailing slash. If it doesn't, set ORY_OAUTH2_ISSUER to the token's iss in Vercel's staging environment.

3. Deploy the API change to staging

  1. Merge the source.coop PR (feat/api-ory-access-tokens). Merging to main deploys staging.
  2. Merge the data.source.coop ADR PR. It's documentation only.
  3. Check it with the staging build from step 2:
    ./target/debug/source-coop api whoami --api-url https://staging.source.coop   # your account, not a 401
    ./target/debug/source-coop creds | jq .Expiration                             # S3 still works
    aws s3 ls s3://<your-account>/ --profile <staging profile>
  4. Check that an ID token is refused:
    curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer <an ID token>" https://staging.source.coop/api/v1/whoami
    This should not resolve to your account.

Rollback: revert the merge. Ory-token calls then get 401, and nothing else changes: the proxy, the web app and S3 credentials don't use this path.

4. Check the product commands on staging

These need source.coop#651 deployed to staging, and #651 needs data.source.coop#253 deployed first (#651's own deploy-order note).

  1. git checkout feat/product-commands && cargo build --features staging
  2. Use a test account you own:
    B=./target/debug/source-coop; export SOURCE_API_URL=https://staging.source.coop
    $B product list <account>                      # table on a terminal
    $B product list <account> | cat                # tab-separated
    $B product create <account>/cli-smoke-test     # prompts; try a bad ID to see it asked again
    $B product edit <account>/cli-smoke-test       # field picker; `e` opens your editor
    $B product view <account>/cli-smoke-test --web
    $B product delete <account>/cli-smoke-test     # asks about data, then for the name
  3. Check that an upgrade needs no new login: with a credentials cache written by 0.3.0, run the new build's creds after the cached credentials expire. It should refresh without a browser.

5. Production

  1. Patch the production CLI client (step 1), then read a production token's claims with a default build (cargo build, no features), as in step 2. Set ORY_OAUTH2_ISSUER in Vercel's production environment if iss differs.
  2. Merge source.coop's release-please PR. Publishing the release deploys production; it carries this change and #651.
  3. Repeat step 3's checks against https://source.coop.

6. Release the CLI

  1. Settle the order with source-coop-cli#20 (API-key exchange), since both rewrite run_creds and the cache. Whichever merges second rebases. When feat/login-session rebases on feat: exchange a service account's API key without a browser #20, minting from the session should take feat: exchange a service account's API key without a browser #20's early-refresh rule (16 minutes left, or half a shorter session).
  2. Merge feat/login-session, then feat/product-commands. GitHub retargets the second to main once the first's branch is deleted.
  3. release-please opens a release PR; feat: commits make it 0.4.0. Merging it tags the release, and cargo-dist builds the installers and publishes the Homebrew formula.
  4. Install the release (brew upgrade source-coop or the installer script) and run source-coop login, source-coop auth token, source-coop api whoami and source-coop product list.
  5. Follow up on docs.source.coop: the CLI page can now cover product create and source-coop api (not part of these PRs).

Rollback: the previous release still works against everything above. Users on the new release who hit login trouble can pass --audience '', which logs in as 0.3.0 did, without API commands.

Notes

  • Preview deployments: a preview's origin isn't an allowed audience, so the CLI's API commands can't sign in against one. Test those on staging, or add the preview's origin to the staging client's audiences and run the CLI with --audience <preview origin> --api-url <preview origin>.
  • Older releases: a 0.3.0 install isn't affected by any of this. It never calls the API, and its cached refresh tokens keep working.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF

`login` now asks Ory for an access token meant for the source.coop API (`--audience`, default the site URL) and caches the whole session — refresh, ID and access tokens — once, rather than a refresh token per role. `creds` mints for any role by exchanging the session's ID token at `/.sts`, and `source-coop auth token` prints the access token, so the same login works for S3 and for the API.

The refresh token lives only in the session, since it rotates on use and a replayed one can revoke the whole family: everything refreshes through `session::token`, under one lock, and one refresh renews both the ID and the access token. A role's cache keeps only the STS settings `login` was given. Caches from earlier versions, with a refresh token of the role's own, still refresh that way until the next `login`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF
@alukach alukach changed the title … feat: share one Ory login session between S3 credentials and the source.coop API Oct 9, 2026
@alukach
alukach added this pull request to stack #25 October 9, 2026 19:20
@alukach
alukach marked this pull request as ready for review October 9, 2026 19:37
@alukach
alukach merged commit d55e4b7 into main Oct 9, 2026
9 checks passed
@alukach
alukach deleted the feat/login-session branch October 9, 2026 19:38
alukach added a commit to source-cooperative/source.coop that referenced this pull request Oct 13, 2026
Part of #588. The CLI half is source-cooperative/source-coop-cli#23.

The API now takes an Ory access token as a bearer, alongside the
assertions the data proxy signs, so the CLI can call `/api/v1` as the
person who logged in. `getApiSession` routes a bearer by its unverified
`iss`: Ory's tokens go to the new `authenticateWithOryAccessToken`, and
the proxy's go to the existing verifier. Neither falls back to the other
or to the session cookie.

The Ory verifier checks the token against Ory's published JWKS, with the
issuer (with or without a trailing slash), RS256, expiry, a 30-second
tolerance, and the request's origin as audience. An ID token, which Ory
issues to the client ID, never passes, and which clients may mint a
token for the API is decided in Ory by the audiences each client is
allowed. The subject is looked up only as a person's Ory identity. Both
verifiers build the session in one place, `sessionForAccount`. The
issuer is `NEXT_PUBLIC_ORY_SDK_URL`, which is each Ory project's issuer
domain.

**Deploying:**

The CLI's Ory clients need the `jwt` access-token strategy and this
site's origin as an allowed audience. Until a client has both, the API
answers that environment's Ory tokens with 401. Nothing else breaks:
only newer CLI builds send Ory tokens, so merging first is safe.

| | Staging client (`a79c9537…`) | Production client (`197e20e7…`) |
|---|---|---|
| Allowed audience | `https://staging.source.coop` ✅ |
`https://source.coop` ✅ |
| `access_token_strategy: jwt` | ✅ set 2026-10-13 | ✅ set 2026-10-13 |

**Changing a client** (done for both; kept for reference and rollback):
`ory patch oauth2-client` doesn't exist, and `ory update oauth2-client`
replaces the whole client. So export the client, change one field, and
send the whole thing back:

```bash
P=d567800d-b705-49d1-8a28-6f92f3a4c053 C=197e20e7-d52d-4d1d-9e54-4b73a342034b
ory get oauth2-client $C --project $P --format json > prod-cli-client.before.json
jq '.access_token_strategy = "jwt"' prod-cli-client.before.json > prod-cli-client.jwt.json
ory update oauth2-client $C --project $P --file prod-cli-client.jwt.json
ory get oauth2-client $C --project $P --format json | jq .access_token_strategy   # "jwt"
```

Rollback: `ory update oauth2-client $C --project $P --file
prod-cli-client.before.json`.

- **No Vercel changes.** Each deployment's `NEXT_PUBLIC_ORY_SDK_URL` is
already the issuer in its tokens: `https://auth.staging.source.coop` on
staging and previews (checked against a real staging token),
`https://auth.source.coop` on production. A local API can't accept CLI
tokens at all, since no CLI client allows a localhost audience.
- Merging deploys staging. Check with a staging CLI build: `source-coop
login`, then `source-coop api whoami --api-url
https://staging.source.coop` should return your account, and `creds`
with `aws s3 ls` should still work.
- The next release deploys production. Repeat the checks against
`https://source.coop`. The release ships everything on `main`: if #651
is in it, data.source.coop#253 must be deployed first.
- Rollback is a revert: only Ory-token calls are affected.

**Docs:**

- **ADR-005:** amended in source-cooperative/data.source.coop#254. It
said the API authenticates proxy-signed tokens; it now records the
second signer.
- **OpenAPI:** the `bearerAuth` description names `source-coop login`
and `source-coop auth token`.
- **`public/llms.txt`:** checked. Its line ("a token from the CLI's
login or a service account") still holds.
- **docs.source.coop:** checked. No page describes calling the API with
a token yet.

## Testing

- `npm run type-check` passes.
- `npx jest src/app/api src/lib`: 535 passed across 53 suites, including
new suites for the Ory verifier and for `getApiSession`'s routing.
- `next lint` on the changed files reports nothing.
- Against real Ory (staging, 2026-10-13): after the CLI client was
switched to `jwt`, a `source-coop login` token was an RS256 JWT with
`iss` `https://auth.staging.source.coop` and `aud`
`["https://staging.source.coop"]`, and empty `ext`. That matches what
the verifier checks. The API call itself (`source-coop api whoami`
against staging) waits for this to deploy, since a preview's origin
isn't an allowed audience.

## Related

- CLI login session, which sends these tokens:
source-cooperative/source-coop-cli#23
- CLI product commands: source-cooperative/source-coop-cli#24
- ADR-005 amendment: source-cooperative/data.source.coop#254
- Deployment runbook: in source-cooperative/source-coop-cli#23

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF

---------

Co-authored-by: Claude Opus 5.5 <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