# Workload identity: keys without stored secrets

A workload sends an OIDC token from its platform and gets a short-lived RouterPlus inference
key back. You store no RouterPlus key in CI or in the cluster.

Any issuer that publishes OIDC discovery and a key set over https works. Examples are GitHub
Actions, Google Cloud, AWS and Kubernetes service account tokens.

## Trusts

Set up one trust for each workload with the
[management API](api-management.md#workload-identities). A trust has these fields:

| Field | Meaning |
|---|---|
| `issuer` | The token's `iss`, an https URL. For GitHub Actions: `https://token.actions.githubusercontent.com`. |
| `audience` | A value that the token's `aud` must hold. You choose the value, and you request tokens for it. |
| `subject_pattern` | A pattern that the token's `sub` must match. `*` matches any run of characters. `?` matches one character. The pattern matches the whole `sub`. |
| `required_claims` | Claims the token must carry, as `{"claim": "value"}`. RouterPlus compares values as text. Use a dotted path for a nested claim, for example `kubernetes.io.namespace`. |
| `key_ttl_seconds` | How long the key works: 300 to 86400 seconds. Default 3600. |
| workspace and principal | Where the key's requests and spend go. |

Each trust has an `identity_id`. A disabled trust accepts no tokens.

## Exchange endpoint

`POST https://demo-api.routerplus.com/api/v1/auth/token` (also `POST /v1/auth/token`)

Request body, JSON:

```json
{"token": "<OIDC JWT>", "identity_id": "<trust id, optional>"}
```

The endpoint also accepts the RFC 8693 form (`application/x-www-form-urlencoded`):
`grant_type=urn:ietf:params:oauth:grant-type:token-exchange`, `subject_token=<JWT>`,
`subject_token_type=urn:ietf:params:oauth:token-type:jwt` (or `...:id_token`).

Without `identity_id`, RouterPlus tries the enabled trusts for the token's issuer, oldest
first. The first trust that matches makes the key.

Response `200`:

```json
{
  "access_token": "tm_vk_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}
```

Send `access_token` as a Bearer token on inference calls. It stops working after `expires_in`
seconds. To get a new key, exchange a new token.

## What RouterPlus checks

- The signature, with the issuer's published keys. Accepted algorithms: RS256, RS384, RS512, PS256, ES256, ES384. RouterPlus refuses `none`, HS256, HS384 and HS512.
- `exp` (required), `nbf` and `iat`, with 60 seconds of clock skew.
- `aud` holds the trust's audience. `aud` can be a string or a list.
- `sub` matches the trust's pattern.
- Each required claim equals the token's claim.
- The token was not exchanged before. A token (its `iss` and `jti`) works one time.

## Errors

| Status | Body |
|---|---|
| `400` | `{"error": "invalid_request", "error_description": "..."}`: no token, a bad `identity_id`, or a bad `grant_type` or `subject_token_type`. |
| `401` | `{"error": "invalid_token", "error_description": "Token rejected: <reason>."}` |
| `405` | `{"error": "invalid_request", ...}`: the method is not POST. |
| `503` | `{"error": "temporarily_unavailable", ...}`: retry. |

The `401` reasons:

| Reason | Meaning |
|---|---|
| `malformed` | The token is not a valid JWT, is longer than 16,384 characters, or has no `exp` or https `iss`. |
| `unsupported_alg` | The algorithm is not on the accepted list. |
| `unknown_identity` | No enabled trust exists for this issuer, or for this `identity_id`. |
| `issuer_unavailable` | RouterPlus could not read the issuer's discovery document or key set. |
| `signature` | The signature does not verify with the issuer's keys. |
| `expired`, `not_yet_valid`, `issued_in_future` | A time check failed. |
| `not_trusted` | No trust matched the audience, subject and required claims. |
| `replayed` | This token was already exchanged. |

## GitHub Actions example

1. Set up a trust with issuer `https://token.actions.githubusercontent.com` and audience
   `routerplus`.
2. Set a subject pattern, for example `repo:acme/app:ref:refs/heads/main`.
3. Add `repository_owner: acme` as a required claim.
4. Give the job the `id-token: write` permission.

```yaml
permissions:
  id-token: write
  contents: read

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - name: Get a RouterPlus key
        run: |
          OIDC=$(curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=routerplus" | jq -r .value)
          KEY=$(curl -sS https://demo-api.routerplus.com/api/v1/auth/token \
            -H "Content-Type: application/json" \
            -d "{\"token\":\"$OIDC\",\"identity_id\":\"YOUR_IDENTITY_ID\"}" | jq -r .access_token)
          echo "::add-mask::$KEY"
          echo "TM_API_KEY=$KEY" >> "$GITHUB_ENV"
      - name: Call a model
        run: |
          curl -sS https://demo-api.routerplus.com/v1/chat/completions \
            -H "Authorization: Bearer $TM_API_KEY" \
            -H "Content-Type: application/json" \
            -d '{"model":"MODEL_ID","messages":[{"role":"user","content":"Hello"}]}'
```

GitHub Actions subjects have one of these forms:

- `repo:<owner>/<repo>:ref:refs/heads/<branch>`
- `repo:<owner>/<repo>:environment:<name>`
- `repo:<owner>/<repo>:pull_request`
