Skip to content

feat(api): accept Ory access tokens from the CLI's login - #654

Merged
alukach merged 6 commits into
mainfrom
feat/api-ory-access-tokens
Oct 13, 2026
Merged

alukach merged 6 commits into
mainfrom
feat/api-ory-access-tokens

Conversation

@alukach

@alukach alukach commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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:

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 feat(api): product list, create, view, edit and delete #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 docs(adr): amend ADR-005 — the API also accepts Ory access tokens 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

🤖 Generated with Claude Code

https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF

The API now takes an Ory access token as a bearer, alongside the assertions the data proxy signs. `source-coop login` asks Ory for an access token whose audience is the site's origin, and `getApiSession` routes a bearer by its unverified `iss`: Ory's go to a new verifier, the proxy's to the existing one, and neither falls back to the other or to the cookie. The Ory verifier checks the token against Ory's published keys (the CLI's client uses the `jwt` access-token strategy), with the issuer, RS256, expiry and the request's origin as audience, so an ID token, which Ory issues to the client ID, never passes. The subject is looked up only as a person's Ory identity.

Both verifiers now build the session in one place, `sessionForAccount`, so a token acts with the same rights whichever way it arrived. `ORY_OAUTH2_ISSUER` names Ory's issuer when it isn't the SDK URL.

Part of #588.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
source-coop-ui Ready Ready Preview Oct 13, 2026 8:30pm UTC
source-cooperative Ready Ready Preview Oct 13, 2026 8:30pm UTC

Request Review

@claude

claude Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 24s —— View job


✅ No blocking issues — safe to merge.

I read the verifier, the routing in getApiSession and the shared sessionForAccount, and found nothing that blocks.

  • Verification: jwtVerify pins RS256, the issuer, the audience (the request origin) and expiry. An ID token's audience is the client ID, so it can't pass.
  • Routing: the unverified iss is used only to choose a verifier. A token whose iss isn't Ory's goes to the proxy verifier, which still refuses non-read methods. An Ory-issued token that fails verification returns null and never reaches the proxy verifier or the cookie.
  • Subject lookup: fetchByOryId means a service account can't be reached through an Ory token. Disabled accounts are rejected in sessionForAccount.

Non-blocking notes:

  • Revocation: a jwt-strategy access token can't be revoked before it expires. That is inherent to the design. The token lifetime configured on the Ory client is the exposure window, so keep it short.
  • Errors: if the JWKS fetch fails (a network error rather than a bad token), jwtVerify throws and the call is logged as a verification failure. The caller gets a 401, which is acceptable.

Simplify (ponytail)

  • src/lib/api/ory-access-token.ts:35: _setOryJwks and the module-level jwks cache duplicate the pattern in oidc.ts. Share one small cachedJwks(url) helper if a third verifier ever appears. Leave it for now.
  • src/lib/api/ory-access-token.ts:38: oryIssuers() returns two strings, and oryIssuers()[0] is used to build the JWKS URL. Fine as is. Keep it.

Docs

The description covers all three: the ADR-005 amendment is linked (data.source.coop#254), the llms.txt line was checked and still holds, and no docs.source.coop page describes calling the API with a token. Nothing is missing.
· branch feat/api-ory-access-tokens


💰 Estimated review cost: $0.11 · 0m23s · 4 turns

@alukach alukach changed the title … feat(api): accept Ory access tokens from the CLI's login Oct 9, 2026
alukach added a commit that referenced this pull request Oct 13, 2026
…661)

Part of #588.

## What

The API stops accepting the data proxy's signed assertions for any
write. `getApiSession` returns no session for a bearer on anything but
`GET` or `HEAD`, and doesn't fall back to the cookie.

The proxy can sign an assertion for any subject (ADR-005), so as things
stand its signing key can create, edit and delete products, data
connections, memberships and accounts as anyone. Nothing needs that. The
proxy sends only reads:

- `GET` lookups of products, permissions, data connections and product
lists, as the caller (`data.source.coop` `src/source_api/cache.rs`).
- `POST /service-account-keys/exchanges`, as itself. This route already
verifies the assertion itself and doesn't use `getApiSession`, so it's
unaffected.
- `POST /accounts/{account_id}/trusts/exchanges`, as the account named
in `RoleArn`. This is a lookup sent as a `POST`. It now verifies the
assertion with `authenticateWithOidcToken` directly, so it keeps working
and accepts the same callers as before: the account itself or an admin.
A browser cookie no longer reaches it, which nothing used.

An org-wide search found no other bearer caller of a mutating `/api/v1`
route. `metadata-catalog-pipeline` and ops-source's crawler only `GET`.

## With #654

#654 routes a bearer to the Ory or proxy verifier by its `iss`. When it
merges after this, the method check has to move into the proxy branch,
so the CLI's Ory access tokens can still write. Whichever PR merges
second carries that change.

## Docs

- ADR-005 says the API trusts the proxy to assert any `sub`. Once this
merges, it should also say those assertions only read. That belongs in a
follow-up PR to data.source.coop alongside data.source.coop#254.
- No OpenAPI or `llms.txt` change: no route's documented behaviour
changes for any real caller.

## Testing

- `npx jest`: 88 suites, 905 tests pass.
- `npm run type-check` passes.
- `npm run lint`: no new warnings.
- New: `src/lib/api/get-api-session.test.ts` checks that a bearer still
reads on `GET` and `HEAD`, and gets no session (and is never verified)
on `POST`, `PUT`, `PATCH` and `DELETE`. The trust exchange tests now
mock the verifier the route calls.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…okens

# Conflicts:
#	src/lib/api/get-api-session.test.ts
Every deployment's NEXT_PUBLIC_ORY_SDK_URL is its Ory project's issuer, and
the one case the override was for, a local API, can't get a token anyway:
no CLI client allows a localhost audience.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r directly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach
alukach marked this pull request as ready for review October 13, 2026 20:34
@alukach
alukach merged commit 8b40dc4 into main Oct 13, 2026
10 checks passed
@alukach
alukach deleted the feat/api-ory-access-tokens branch October 13, 2026 20:34

This branch was successfully deployed

2 active deployments
Preview – source-cooperative — 46a3ba18 Deployed Oct 13, 2026 by vercel[bot]
Preview – source-coop-ui — 46a3ba18 Deployed Oct 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant