Skip to content
107 changes: 107 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# ANRCODE_CHANGE {"issue":310,"branch":"anr-token-based-auth","date":"2026-07-29"}
name: test

on:
Expand Down Expand Up @@ -566,6 +567,112 @@ jobs:
bin="$(find packages/opencode/dist -type f -name 'opencode' | head -1)"
OPENCODE_BIN="$bin" bun run .github/scripts/validate-opencode-config.ts

# Gates anr-token-auth-smoke: probes credential resolution directly (no CLI
# build) so a known external failure mode — the configured Cognito Identity
# Pool having been deleted/rotated server-side — shows as a skipped smoke
# job instead of a false regression. Any other failure (including "no
# secret configured") still runs the smoke job so it surfaces full
# diagnostics; this only suppresses the one specific, non-code condition.
anr-token-auth-preflight:
name: anr-token-auth-preflight
runs-on: ubuntu-latest
outputs:
run_smoke: ${{ steps.probe.outputs.run_smoke }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Bun
uses: ./.github/actions/setup-bun

- name: Probe token-auth credential resolution
id: probe
run: |
if [ -z "${{ secrets.ANR_REFRESH_TOKEN }}" ] && [ -z "${{ secrets.ANR_ID_TOKEN }}" ]; then
echo "run_smoke=false" >> "$GITHUB_OUTPUT"
echo "::notice::Neither ANR_REFRESH_TOKEN nor ANR_ID_TOKEN secret is set — skipping token-auth smoke."
exit 0
fi

result="$(bun run packages/opencode/script/anr-token-auth-preflight.ts .opencode/.env.commercial 2>preflight-stderr.log | tail -1)"

case "$result" in
AUTH_OK)
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
;;
SKIP_STALE_IDENTITY_POOL)
echo "run_smoke=false" >> "$GITHUB_OUTPUT"
echo "::notice::Configured Cognito Identity Pool was not found (deleted/rotated server-side) — skipping smoke until infra is restored."
cat preflight-stderr.log
;;
*)
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
cat preflight-stderr.log
;;
esac
env:
OPENCODE_ANR_REFRESH_TOKEN: ${{ secrets.ANR_REFRESH_TOKEN }}
OPENCODE_ANR_ID_TOKEN: ${{ secrets.ANR_ID_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.ANR_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ANR_AWS_SECRET_ACCESS_KEY }}
AWS_SESSION_TOKEN: ${{ secrets.ANR_AWS_SESSION_TOKEN }}

# ANR token-auth smoke: verifies non-interactive token mode can initialize
# and reach ANR backend services without opening a browser.
# Requires a GitHub Actions secret: ANR_REFRESH_TOKEN (long-lived, recommended)
# or ANR_ID_TOKEN (short-lived, needs manual rotation).
# Gated by anr-token-auth-preflight; promote to required gate once secrets
# and backend infra (Cognito Identity Pool) are stable.
anr-token-auth-smoke:
name: anr-token-auth-smoke
needs: anr-token-auth-preflight
if: needs.anr-token-auth-preflight.outputs.run_smoke == 'true'
continue-on-error: true
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"

- name: Setup Bun
uses: ./.github/actions/setup-bun

- name: Build single-platform CLI
run: bun run ./packages/opencode/script/build.ts --single --skip-embed-web-ui
env:
OPENCODE_VERSION: "0.0.0-smoke"

- name: Run ANR token-auth smoke (agent list)
run: |
bin="$(find packages/opencode/dist -type f -name 'opencode' | head -1)"
echo "Binary: $bin"
# Pin the env flavor: the repo ships multiple .opencode/.env.* files and
# auto-selection order is filesystem-dependent. The stored refresh token
# is issued by the commercial app client, so the config must match.
"$bin" agent list --env-file .opencode/.env.commercial
env:
OPENCODE_FLAVOR: anr
OPENCODE_ANR_AUTH_MODE: token
# Refresh token is preferred: it is long-lived and a fresh ID token is
# minted at startup (refresh-first bootstrap). ID token is a fallback.
OPENCODE_ANR_REFRESH_TOKEN: ${{ secrets.ANR_REFRESH_TOKEN }}
OPENCODE_ANR_ID_TOKEN: ${{ secrets.ANR_ID_TOKEN }}
# Optional: provide static AWS creds to bypass federation exchange
AWS_ACCESS_KEY_ID: ${{ secrets.ANR_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ANR_AWS_SECRET_ACCESS_KEY }}
AWS_SESSION_TOKEN: ${{ secrets.ANR_AWS_SESSION_TOKEN }}

# Upgrade/migration smoke: install the previous released version, create state,
# then run the build-under-test against that same state to catch migration
# regressions (session/config on-disk format changes).
Expand Down
140 changes: 140 additions & 0 deletions docs/anr-token-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<!-- ANRCODE_CHANGE {"issue":310,"branch":"anr-token-based-auth","date":"2026-07-29"} -->

# ANR Token-Based Authentication Mode

Non-interactive, browserless auth for CI/CD pipelines. Set `OPENCODE_ANR_AUTH_MODE=token` to skip the OIDC browser flow.

**Recommended CI setup:** store a single long-lived Cognito **refresh token** in GitHub Actions secrets. At startup opencode exchanges it for a fresh ID token (refresh-first bootstrap), then federates that into AWS credentials — no manual token rotation, no browser.

## Environment Variable Contract

| Variable | Required | Description |
|---|---|---|
| `OPENCODE_ANR_AUTH_MODE` | No (default: `interactive`) | `interactive` or `token` |
| `OPENCODE_ANR_REFRESH_TOKEN` | Recommended for CI\* | Long-lived Cognito refresh token; a fresh ID token is minted at startup and on schedule |
| `OPENCODE_ANR_ID_TOKEN` | Only if no refresh token\* | Cognito OIDC ID token (JWT), short-lived (~1 h) |
| `AWS_ACCESS_KEY_ID` | No | If set with SECRET+TOKEN, skips federation exchange |
| `AWS_SECRET_ACCESS_KEY` | No | See above |
| `AWS_SESSION_TOKEN` | No | See above |
| `AWS_REGION` | No | Overrides config region when using static creds |
| `OPENCODE_ANR_SKIP_AUTH` | No | **Config-validation only** — not for real auth |

\* Token mode needs at least one of: `OPENCODE_ANR_REFRESH_TOKEN`, `OPENCODE_ANR_ID_TOKEN`, or the full static AWS credential triple.

## Credential Resolution

Token mode resolves credentials in this order:

1. **Static AWS creds** — if `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` are all set, use them directly. No Cognito call is made.
2. **Refresh-first bootstrap** — if `OPENCODE_ANR_REFRESH_TOKEN` is set, exchange it at Cognito's token endpoint for a fresh ID token, then federate that via the Cognito Identity Pool. If the refresh fails and `OPENCODE_ANR_ID_TOKEN` is also set, fall back to step 3; otherwise fail fast.
3. **Federation exchange** — exchange `OPENCODE_ANR_ID_TOKEN` via the Cognito Identity Pool configured in your `.env` file.

## Typical CI Usage

### Recommended: long-lived refresh token (autonomous)

```yaml
- name: Run opencode
env:
OPENCODE_FLAVOR: anr
OPENCODE_ANR_AUTH_MODE: token
OPENCODE_ANR_REFRESH_TOKEN: ${{ secrets.ANR_REFRESH_TOKEN }}
run: opencode agent list
```

### With a short-lived ID token (manual rotation)

```yaml
- name: Run opencode
env:
OPENCODE_FLAVOR: anr
OPENCODE_ANR_AUTH_MODE: token
OPENCODE_ANR_ID_TOKEN: ${{ secrets.ANR_ID_TOKEN }}
run: opencode agent list
```

### With pre-issued AWS credentials (skip federation)

```yaml
- name: Run opencode
env:
OPENCODE_FLAVOR: anr
OPENCODE_ANR_AUTH_MODE: token
OPENCODE_ANR_ID_TOKEN: ${{ secrets.ANR_ID_TOKEN }} # still used for telemetry context
AWS_ACCESS_KEY_ID: ${{ secrets.ANR_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ANR_AWS_SECRET_ACCESS_KEY }}
AWS_SESSION_TOKEN: ${{ secrets.ANR_AWS_SESSION_TOKEN }}
run: opencode agent list
```

## Provisioning the Refresh Token (one-time bootstrap)

1. Run opencode interactively once (`OPENCODE_ANR_AUTH_MODE` unset) and complete the browser OIDC login — ideally as a dedicated CI service account in the Cognito User Pool, not a personal account.
2. Capture the refresh token issued by the login and store it as the `ANR_REFRESH_TOKEN` GitHub Actions secret.
3. Confirm two settings on the Cognito **app client** with whoever administers the user pool:
- **Refresh token validity** covers your desired CI credential lifetime (Cognito default is 30 days; configurable up to 10 years).
- **Refresh token rotation is disabled.** If Cognito rotates the refresh token on each use, the statically stored secret is invalidated after the first CI run.

After that, every CI run self-serves fresh credentials for the life of the refresh token. The refresh call is a plain POST to Cognito's `/oauth2/token` endpoint using the public app client — no client secret is involved.

## Token Refresh Behaviour in Token Mode

| Scenario | Behaviour |
|---|---|
| `OPENCODE_ANR_REFRESH_TOKEN` set | Fresh ID token minted at startup; silent refresh on schedule thereafter — no browser |
| `OPENCODE_ANR_REFRESH_TOKEN` set, refresh fails at startup | Falls back to `OPENCODE_ANR_ID_TOKEN` if set; otherwise fails fast |
| `OPENCODE_ANR_REFRESH_TOKEN` set, scheduled refresh fails mid-run | Logs error, keeps existing creds until STS expiry. No browser fallback. |
| No `OPENCODE_ANR_REFRESH_TOKEN` | One-time warning logged. Creds remain valid until AWS STS expiry. No interactive fallback. |

Interactive mode (default) is unchanged: silent refresh attempted first, browser opened on failure.

## Fail-Fast Error Messages

Missing or invalid configuration exits immediately with a clear, actionable message:

```
[ANR] Token auth mode is missing required environment variable(s):
- OPENCODE_ANR_ID_TOKEN is not set
- OPENCODE_ANR_REFRESH_TOKEN is not set

To fix (one of):
• Set OPENCODE_ANR_REFRESH_TOKEN to a long-lived Cognito refresh token
(recommended for CI — a fresh ID token is minted automatically).
• Set OPENCODE_ANR_ID_TOKEN to a valid, unexpired Cognito ID token.
• Provide AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_SESSION_TOKEN
to bypass federation entirely.
```

Error messages **never** include secret values.

## Differences: `OPENCODE_ANR_SKIP_AUTH` vs `OPENCODE_ANR_AUTH_MODE=token`

| | `OPENCODE_ANR_SKIP_AUTH=1` | `OPENCODE_ANR_AUTH_MODE=token` |
|---|---|---|
| Purpose | Config-lint / validation only | Real production CI auth |
| Authentication | Skipped entirely | Performed (token or federation) |
| AWS credentials | Not obtained | Obtained and set in env |
| Telemetry | Not initialized | Initialized |
| Quota check | Skipped | Performed |
| Use in | `validate-opencode-config.ts` | Any CI job needing full ANR |

**Do not** use `OPENCODE_ANR_SKIP_AUTH` for jobs that need to reach backend services — it bypasses all auth and will result in missing credentials.

## Secret Rotation and Expiry

- **Refresh tokens** are the recommended CI secret: long-lived (configurable on the Cognito app client, up to 10 years), revocable, and exchanged automatically for short-lived ID tokens. Rotate per your org's policy.
- Cognito **ID tokens** are short-lived (typically 1 hour). Only use `ANR_ID_TOKEN` directly if you regenerate it before each CI run.
- AWS **STS session tokens** (`AWS_SESSION_TOKEN`) have their own expiry. If pre-issued, ensure they are valid for the duration of the job.
- Store all tokens exclusively in GitHub Actions secrets (or equivalent). Never commit them to `.env` files.

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `missing required environment variable(s)` | No secret wired in workflow | Add `OPENCODE_ANR_REFRESH_TOKEN: ${{ secrets.ANR_REFRESH_TOKEN }}` to env |
| `refresh token exchange failed` | Expired/revoked refresh token, wrong app client, or rotation enabled | Re-provision the refresh token; verify `CLIENT_ID` matches the issuing app client; disable rotation on the app client |
| `federation exchange failed` | Expired or invalid ID token | Regenerate token; check identity pool ID and region in config |
| `does not appear to be a valid JWT` | Wrong secret mapped | Verify `ANR_ID_TOKEN` secret contains a valid Cognito ID token (three-part JWT) |
| `Unknown OPENCODE_ANR_AUTH_MODE value` | Typo in env var | Valid values: `interactive`, `token` |
| Credentials expire mid-job | No refresh token + long job | Add `OPENCODE_ANR_REFRESH_TOKEN` secret or break job into shorter steps |
| Second CI run fails after first succeeds | Refresh token rotation enabled on app client | Disable rotation, or update the stored secret with the rotated token |
3 changes: 1 addition & 2 deletions packages/anr-core/src/config/env-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,7 @@ export async function loadANRConfig(envPath?: string, quiet = false): Promise<AN
*/
async function loadEnvFile(path: string): Promise<void> {
try {
const file = Bun.file(path)
const text = await file.text()
const text = readFileSync(path, "utf-8")

const lines = text.split("\n")
for (const line of lines) {
Expand Down
11 changes: 10 additions & 1 deletion packages/anr-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ export {

// Authentication
export { authenticateWithOIDC, refreshOIDCTokens, type OIDCTokens } from "./integrations/oidc-auth"
export { exchangeTokenForAWSCredentials } from "./integrations/aws-federation"
export { exchangeTokenForAWSCredentials, type AWSCredentials } from "./integrations/aws-federation"
export {
parseANRAuthMode,
validateTokenModeEnv,
resolveTokenModeCredentials,
type ANRAuthMode,
type TokenAuthResult,
type TokenModeValidationOk,
type TokenModeValidationError,
} from "./integrations/token-auth"

// Telemetry & Observability
export {
Expand Down
Loading
Loading