Skip to content

feat: exchange a service account's API key without a browser - #20

Draft
alukach wants to merge 3 commits into
mainfrom
feat/api-key-exchange
Draft

alukach wants to merge 3 commits into
mainfrom
feat/api-key-exchange

Conversation

@alukach

@alukach alukach commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Closes #17, as rescoped on 2026-09-25 for the revised ADR-013 (see the last decision below). Part of source-cooperative/source.coop#491. The server side is source-cooperative/data.source.coop#235 (the proxy's key exchange at /.sts) plus source-cooperative/source.coop#580 (issuing keys and answering the proxy's lookup).

What and why

A service account's API key is an opaque sck_ secret (ADR-013 as revised in source-cooperative/data.source.coop#234). The proxy exchanges it at /.sts as it does an ID token, but only from a POST form body. This PR lets the CLI make that exchange for software that runs unattended.

  • Key mode for creds. source-coop creds --api-key-file <path> (or SOURCE_API_KEY_FILE, or SOURCE_API_KEY holding the key itself) exchanges the key at /.sts without a browser and prints the usual output. That output is credential_process JSON by default, so credential_process = source-coop creds --api-key-file … in ~/.aws/config serves AWS SDKs and GDAL 3.12+. Credentials are cached as login's are and replaced before they expire, so a daemon keeps working across expiry. I extended creds rather than adding a subcommand, because creds already is the credential_process mode.
  • Credentials are replaced before SDKs start asking. botocore refreshes credential_process credentials once they have under 15 minutes left, and reruns the process on every credential lookup until it gets ones with more. The Go and JavaScript SDKs do the same at 5 minutes. creds therefore replaces credentials once they have less than 16 minutes left (the extra minute is for clock skew), or less than half the requested session if that is shorter. The half-session limit keeps a 15-minute session from being replaced on every call. This covers a key's exchange and login's refresh-token refresh. On main, creds serves credentials until 60 seconds before expiry, so it runs once per S3 request for the last 15 minutes of every session.
  • Token in the body, never the URL. Every exchange, the Ory ID token's included, moves from the query string to a form-encoded POST body. The proxy refuses a key in a URL, URLs land in access logs, and reqwest puts the URL in its connection errors, so a network failure used to print the ID token to stderr. The deployed proxy already reads form bodies at /.sts (checked against staging below), so nothing changes for login users, and this can merge before the server side lands.
  • Role. RoleArn defaults to arn:aws:iam::000000000000:role/_default. A bare name such as --role-arn ReadOnly is sent as arn:aws:iam::000000000000:role/ReadOnly, and a full ARN is sent as given.
  • Errors. A refusal prints the proxy's Code and Message verbatim and exits 1, for example Error: STS error (InvalidIdentityToken): API key was not accepted (request id a40cee47fef1c4b4). When the message carries no request id (the 500 the proxy returns when it fails closed, or any Ory-token error), the id from the x-request-id header is appended, so every failure has one to quote to support.
  • README. The key mode, the refresh window, the credential_process snippet, GDAL, and the stock-SDK setup that needs no CLI (AWS_WEB_IDENTITY_TOKEN_FILE, AWS_ROLE_ARN, AWS_ENDPOINT_URL_STS, AWS_ENDPOINT_URL_S3, AWS_REGION). It also corrects the stale --role-arn default in the login options table (source-coop-user → _default).

Decisions to flag

  • Names. --api-key-file / SOURCE_API_KEY_FILE and SOURCE_API_KEY follow the CLI's pairing of each flag with a SOURCE_* variable (--proxy-url / SOURCE_PROXY_URL, --role-arn / SOURCE_ROLE_ARN). The _FILE suffix mirrors AWS_WEB_IDENTITY_TOKEN_FILE, and one key file serves both the CLI and a stock SDK. No flag takes the key itself, because a command line is visible to other users through ps and ends up in shell history and ~/.aws/config. When both are set the file wins, since it is the explicit choice a profile makes.
  • The CLI checks the key's shape before sending it: trimmed, then exactly sck_ plus 36 base62 characters whose last six are the CRC-32 of the thirty before them (ADR-013, docs(adr): ADR-013 keys end in a CRC-32 checksum data.source.coop#242), the same rule as the proxy's parse_api_key. A key cut short or mistyped therefore fails locally too, with "check that it was copied whole". Anything else fails locally, so pointing --api-key-file at the wrong file never sends that file's contents anywhere. The error names the source (the path, or SOURCE_API_KEY) and never the value.
  • Cache slot. A key's credentials are cached under <role>+key-<first 16 hex digits of sha256(key)>. They never share a slot with a login session or another key, and the key itself is never written down.
  • In key mode the cache is best effort. A cache that can't be read (a corrupt entry, a locked keychain) counts as a miss, and a failed write is a warning on stderr, so the exchange is the only hard dependency. login mode stays strict, because there the cache holds the refresh token.
  • Every key-mode call takes a per-slot lock, so SDK threads or a batch of jobs started together share one exchange rather than each spending one against the proxy's limit of 100 a minute per IP.
  • Refreshing early never ends a session sooner. Sometimes the early exchange or refresh fails, through a proxy outage, a revoked key or an expired refresh token. If the cached credentials are still valid, creds then serves them with a warning on stderr, as SDKs keep their credentials when an advisory refresh fails. Failing instead would make a one-shot aws s3 cp in a cron job fail during a proxy blip whenever the cache was in its last 16 minutes, not just its last minute. The one-minute buffer remains the floor of the refresh window.
  • login credentials without a refresh token keep the one-minute rule. Only an interactive login can replace them, so refreshing them early would only turn valid credentials into an error sooner.
  • Role expansion applies to the Ory-token exchange too. It lives in sts::assume_role, which both paths share. The proxy's is_default_role treats _default and arn:…:role/_default alike, so login behaves as before, and its cache is still keyed by the role as given, so existing caches stay valid.
  • The #580 dialog suggests a role #235 refuses. feat(accounts): opaque API keys resolved by hash, and a working key UI source.coop#580's issue dialog suggests AWS_ROLE_ARN=arn:aws:iam::<account>:role/FullAccess, but feat(sts): exchange opaque API keys at /.sts by hash lookup data.source.coop#235 serves only _default until Add the ReadOnly Role alongside FullAccess data.source.coop#221 lands, and answers any other role with MalformedPolicyDocument: role not found. This README uses role/_default. I'm flagging it here rather than changing either PR.
  • The issue text predates the rescope. Unattended refresh: exchange an API key without a browser and keep the token file fresh #17 on GitHub asks for a mode that "keeps AWS_WEB_IDENTITY_TOKEN_FILE fresh". Under the revised ADR-013 that file holds the key itself and never changes, so there is nothing to refresh. The rescoped issue asks for credential_process plus an env export for tools that send STS as a GET, which is what this delivers (--format env already existed). The rescoped text wins where the two disagree.

How I tested

  • cargo fmt --check and cargo clippy --all-targets -- -D warnings are clean, as is CI's cargo clippy -- -D warnings. cargo test: 33 passed, 1 ignored (the existing keyring test). All re-run on a39541d, after the checksum commit.
  • New unit tests, using wiremock:
    • An API-key exchange is a form-encoded POST with exactly Action, RoleArn (the bare ReadOnly expanded), WebIdentityToken and DurationSeconds, and no query string.
    • Refresh window: credentials with 10 minutes left are due at the default hour but not for a 15-minute session, and credentials with 30 minutes left are not due at an hour.
    • Credentials with 5 minutes left of a 15-minute session are exchanged again before they expire. If that exchange fails they are served, and once they have expired a failure is an error. Fresh credentials are served without a request, and an unusable cache still yields credentials.
    • The proxy's refusal body, byte for byte, surfaces verbatim. A header request id is appended only when the message lacks one.
    • Key parsing trims \n and \r\n, accepts keys whose checksums were computed independently with Python's zlib, and rejects empty, JWT, short, long, wrong-case, mistyped, non-base62 and checksum-less 47-character input.
    • The file wins over the environment variable, and errors never echo the key.
    • The credential_process JSON has its five fields.
    • Key cache slots differ per key and per role, and never equal login's.
  • To confirm the new tests fail when the logic breaks, I put back the one-minute rule, which failed two of them, and then disabled the fallback, which failed the third.
  • The existing refresh tests now expect a POST body, with the ID token absent from the query string.
  • End to end, I ran a Linux release build of fc37931 in ghcr.io/osgeo/gdal:ubuntu-small-latest. The image is headless with no keyring, so the cache used its file fallback. The build ran against a local stand-in for the proxy that answers /.sts as #235 does and issues credentials for the requested duration, here 300 seconds, which puts the refresh point 150 seconds before expiry:
    • The first creds --api-key-file exchanged the key and printed only the credential_process JSON: stdout parsed as JSON, with 0 bytes on stderr. An immediate second call was served from the cache.
    • 155 seconds later, 145 seconds before expiry, I made the stand-in answer 500. creds exited 0, served the cached credentials, and warned on stderr: Warning: STS error (InternalError): internal error (request id mockreq0002); using cached credentials until they expire. With the stand-in back, the next call exchanged the key again before the credentials expired.
    • The cache file was ReadOnly_key-c8b4449fef56b9ee.json, mode 0600.
    • A refused key printed Error: STS error (InvalidIdentityToken): API key was not accepted (request id mockreq0004), exited 1, and wrote nothing to stdout.
    • GDAL 3.14dev, with credential_process = source-coop creds --api-key-file … in ~/.aws/config, ran the CLI and logged "Successfully obtained credentials from credential_process". It then read /vsis3/my-account/my-product/empty.geojson with a request signed by the exchanged access key and session token.
    • Every /.sts request was an application/x-www-form-urlencoded POST with an empty query string.
  • Live, with a throwaway well-formed key, on 9270c1a (fc37931 changes when credentials are replaced, not the request):
    • data.staging.source.coop runs main's proxy, which knows no keys, so the token took the same STS route an Ory ID token now takes. The form body reached that route, which answered STS error (InvalidIdentityToken): malformed JWT (request id a40d0b771cbed85f).
    • The #235 preview failed closed, as expected while staging doesn't serve the key route: STS error (InternalError): internal error (request id a40d0b783fc2b4a4).
  • Not run:

Docs and ADRs

  • ADR-013 as revised (docs(adr): revise ADR-013 — API keys are opaque secrets resolved by the platform data.source.coop#234) says the CLI "offers the same exchange with the key in the request body and can serve as an AWS credential_process", which is how GDAL gets credentials. This PR implements that, so the ADR still holds. ADR-014 is untouched.
  • docs.source.coop's docs/using-source/data-upload.md documents login with credential_process = source-coop creds. Setup is unchanged, and the page's claim that the AWS CLI and SDKs refresh credentials automatically still holds; they now just stop rerunning creds on every request. User docs for the key workflow belong to Unattended workflow guide docs.source.coop#34, which relies on this PR for GDAL.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

alukach and others added 2 commits September 25, 2026 14:16
`creds` now takes a service account's API key, from `--api-key-file` (or `SOURCE_API_KEY_FILE`) or from `SOURCE_API_KEY`, and exchanges it at the proxy's `/.sts` without a browser. The key is trimmed and must have the issued shape, `sck_` and 43 base64url characters, before it is sent, so the contents of the wrong file never are. Its credentials are cached as `login`'s are, under a slot named for the role and a prefix of the key's SHA-256, and the key is exchanged again once they expire, so `credential_process = source-coop creds --api-key-file …` keeps an SDK, GDAL or a daemon supplied without a person. For a key the cache is best effort: one that can't be read or written costs an exchange, not the call. A per-slot lock lets callers started together share one exchange rather than each spending one against the proxy's rate limit.

Every STS exchange, the Ory ID token's included, now sends `WebIdentityToken` in a form-encoded POST body instead of the query string. URLs land in access logs, reqwest prints the URL in its connection errors, and the proxy refuses an API key sent in a URL. `RoleArn` goes out in the ARN form stock SDKs send: a bare `ReadOnly` becomes `arn:aws:iam::000000000000:role/ReadOnly`, and the default `_default` becomes `arn:aws:iam::000000000000:role/_default`, which the proxy already treats as `_default`.

An STS error still reads `STS error (Code): Message` with the proxy's text verbatim. When the message lacks the proxy's request id, the one in the `x-request-id` header is appended, so every failure carries an id to quote to support.

The README covers the key mode, GDAL 3.12+ through `credential_process`, and the stock-SDK setup that needs no CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
botocore refreshes credential_process credentials with under 15 minutes left and reruns the process on every lookup until it gets ones with more; the Go and JavaScript SDKs do the same with 5 minutes. `creds` served cached credentials until 60 seconds before expiry, so for the last 15 minutes of every session it ran once per S3 request. It now replaces them once they have less than 16 minutes left, the extra minute for clock skew, or less than half the requested session if that is shorter, so a short session isn't replaced on every call. The one-minute buffer stays the floor.

This applies to an API key's exchange and to `login`'s refresh-token refresh, both of which run without a person. `login` credentials with no refresh token keep the one-minute rule, because only an interactive login can replace them.

Replacing credentials early never ends a session sooner: when the exchange or refresh fails and the cached credentials are still valid, `creds` serves them with a warning on stderr, as an SDK keeps its credentials when an advisory refresh fails. Without that, a proxy outage or an expired refresh token would fail a caller up to 15 minutes before its credentials ran out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
Source API keys are now `sck_`, 30 random base62 characters and a six-character base62 CRC-32 of those 30 (ADR-013, source-cooperative/data.source.coop#242). The CLI checks the shape and the checksum before sending a key anywhere, so a key file holding a key that was cut short or mistyped fails at once with "does not hold a Source API key (sck_ and 36 letters and digits); check that it was copied whole", instead of reaching the proxy. The error still never echoes the file's contents.

The CRC-32 is the IEEE one zlib computes, in a few lines rather than a new crate. The tests' keys are still built at run time so scanners don't flag the file, with checksums computed independently in Python.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
alukach added a commit to source-cooperative/source.coop that referenced this pull request Sep 30, 2026
## What I'm changing

A service-account API key now ends in a checksum. It is `sck_`, 30
random base62 characters, and six more that are the CRC-32 of those 30
(IEEE, as zlib computes it) written in base62: a fixed 40 characters
matching `^sck_[0-9A-Za-z]{36}$`, in place of `sck_` and 43 base64url
characters. This is GitHub's own token layout (`ghp_` + 30 random + 6
checksum), and it is what ADR-013 now specifies
(source-cooperative/data.source.coop#242).

The hint a key is listed by becomes that checksum, the key's last six
characters (`sck_…Xy9QeT`), instead of its last four.

## Why

GitHub's [secret-scanning partner
program](https://docs.github.com/en/code-security/tutorials/secret-scanning-partner-program#identify-your-secrets-and-create-regular-expressions)
recommends a unique prefix, high entropy and a 32-bit checksum. With the
checksum, anything that holds a key — a scanner, the data proxy, the
Source CLI, #581's revocation routes — can tell it from a look-alike, a
truncated key or a mistyped one without a lookup, so a mistyped key gets
an error that says so instead of "not accepted". It adds no security:
anyone can compute a CRC-32. Base62 keeps `-` out of the key, so a
double-click selects all of it; about half of the base64url keys
contained one.

The random part is 178 bits, down from 256. The hash needs no salt at
that size and enumeration stays infeasible.

## How

- `src/types/service-account-key.ts` holds the whole format:
`API_KEY_PATTERN`, `API_KEY_ALPHABET`, `apiKeyChecksum` and `isApiKey`.
The CRC-32 is a few lines of TypeScript rather than `zlib.crc32` because
client components import this module and it is bundled for the browser.
- `issueApiKey` draws the 30 characters with `crypto.randomInt(62)`,
which is uniform, and appends their checksum.
- The hint is the checksum. Six base62 characters are a 32-bit
fingerprint where four were 24 bits, and a checksum of 178 random bits
narrows them by 32, which leaves far too many to guess. The schema still
accepts the four-character hints of keys issued before this change, so
their rows keep rendering.
- Tests and the Storybook mock build their keys at run time, so secret
scanners don't flag the files once the pattern is registered.

Keys issued since #570 merged are in the old format and will be refused
as malformed by the proxy. No deployed proxy can exchange a key yet
(source-cooperative/data.source.coop#235 is open), so none of them has
ever worked; their owners issue new ones.

## Stories

-
[ApiKeyList](https://source-coop-ui-git-feat-api-key-checksum-radiantearth.vercel.app/?path=/story/features-service-accounts-apikeylist--default):
each row's hint is the six-character checksum.
-
[IssueApiKeyDialog](https://source-coop-ui-git-feat-api-key-checksum-radiantearth.vercel.app/?path=/story/features-service-accounts-issueapikeydialog--default):
issue a key to see a 40-character key, listed "by its last six
characters".

![The key list with six-character
hints](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/api-key-checksum/api-key-list.png)

![The show-once view with a 40-character
key](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/api-key-checksum/issue-api-key-show-once.png)

## How you can test it

I ran these on the branch as pushed (8d0a99e):

- `npm run type-check` passes.
- `npx jest src/types/service-account-key.test.ts
src/lib/actions/service-account-keys.test.ts
src/components/features/service-accounts
src/app/api/v1/service-account-keys --forceExit`: 4 suites and 25 tests
pass. The new suite pins `apiKeyChecksum` to vectors computed
independently with Python's `zlib.crc32`, including a CRC above 2^31
(which a signed shift would get wrong) and one whose checksum keeps a
leading zero, and it refuses a cut-short key, a mistyped character, a
mistyped checksum, a character outside base62 and the old 47-character
format.
- `npm run lint` reports nothing in the files this PR touches.
- The screenshots are from a static Storybook build of this branch; the
dialog story issues `sck_storyFixtureNotARealKey12345671oUR38`, whose
checksum holds.

## Docs and ADRs

- ADR-013 states the key format and is revised in place for it by
source-cooperative/data.source.coop#242, since nothing implementing it
has shipped.
- source-cooperative/docs.source.coop#37, the automated-access guide,
now says what the proxy's new "API key is malformed; check that it was
copied whole" error means.

## Related

Part of #491 and #561. The same format is checked by
source-cooperative/data.source.coop#235 (proxy),
source-cooperative/source-coop-cli#20 (CLI) and #581 (revocation routes
and GitHub's secret-scanning endpoint), which also documents what to
send GitHub to enroll.

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

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit to source-cooperative/data.source.coop that referenced this pull request Sep 30, 2026
## What I'm changing

ADR-013 now specifies an API key as `sck_`, 30 random base62 characters
and a six-character checksum: a fixed 40 characters matching
`^sck_[0-9A-Za-z]{36}$`, in place of `sck_` and 43 base64url characters
with no checksum. The checksum is the CRC-32 of the 30 random characters
(IEEE, as zlib computes it), written in base62 with the digits
`0-9A-Za-z`, most significant first, padded with `0` to six. This is
GitHub's own token layout.

## Why

GitHub's [secret-scanning partner
program](https://docs.github.com/en/code-security/tutorials/secret-scanning-partner-program#identify-your-secrets-and-create-regular-expressions)
recommends three things for a secret format: a unique prefix, high
entropy, and a 32-bit checksum. The ADR already had the first two. The
checksum lets a scanner, the proxy or the CLI tell a real key from a
look-alike, a truncated key or a mistyped one without asking
source.coop, and so gives a mistyped key a refusal of its own. It adds
no security, since anyone can compute it; the ADR says so. Base62 keeps
`-` out of the key, so a double-click selects all of it: about half of
the base64url keys contained one.

The random part is 178 bits, down from 256; the ADR's "no salt or KDF"
and "enumeration is infeasible" arguments still hold at that size, and
the text now says 178.

The proxy's step 2 gains the checksum check and the distinct refusal,
"API key is malformed; check that it was copied whole", which reveals
nothing because the format is public.

ADR-013 is revised in place, as it was on 2026-09-25, because nothing
implementing it has shipped: source.coop issues keys since
source-cooperative/source.coop#570 merged, but no deployed proxy can
exchange one until #235 lands.

## Implementing PRs

- #235 checks the checksum at `/.sts`
(commits d6745e0 and 30ad22f); #236 and #237 are rebased onto it.
- source-cooperative/source.coop#596 generates keys in the new format
and shows the checksum as a key's hint;
source-cooperative/source.coop#581, stacked on it, validates leaked keys
by checksum. What to send GitHub, and the equivalent steps for other
scanners, are logged on source-cooperative/source.coop#561.
- source-cooperative/source-coop-cli#20 checks a key file's format and
checksum before exchanging it.

## Docs and ADRs

Only ADR-013 states the key format; I checked the other ADRs with `git
grep sck_` and none mention it. ADR-014's amendment of ADR-013 is about
ownership, not format, and still holds.

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

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

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.

Unattended refresh: exchange an API key without a browser and keep the token file fresh

1 participant