{
  "topic": "auth.tokens",
  "path": [
    "auth",
    "tokens"
  ],
  "title": "auth.tokens — /oauth/token grants and JWT claim contract",
  "synopsis": "auth.tokens — exchange credentials for a JWT at `POST /api/oauth/token`. Covers every supported grant and the canonical JWT claim contract that all cyoda tokens (M2M, OBO, federated OIDC, trusted-key) conform to.",
  "body": "# auth.tokens\n\n## NAME\n\nauth.tokens — exchange credentials for a JWT at `POST /api/oauth/token`. Covers every supported grant and the canonical JWT claim contract that all cyoda tokens (M2M, OBO, federated OIDC, trusted-key) conform to.\n\n## GOAL\n\nYou have a way to prove identity (an M2M `client_id`/`secret`, or a JWT minted by a federated IdP, or a JWT you signed offline) and you want a cyoda-issued (or cyoda-validated) JWT to present on subsequent API calls.\n\nThis is the single home for the JWT claim contract. `auth.oidc` and `auth.trusted-keys` link here for claim shape.\n\n## PREREQUISITES\n\n**Admin (cyoda operator) sets up:**\n\n- `CYODA_IAM_MODE=jwt`\n- `CYODA_JWT_SIGNING_KEY` (PEM RSA private key; tokens cyoda issues are signed with this)\n- `CYODA_JWT_ISSUER` (default `cyoda`; populates the `iss` claim)\n- `CYODA_JWT_AUDIENCE` (default empty = no `aud` check on inbound tokens)\n- `CYODA_JWT_EXPIRY_SECONDS` (default `3600`)\n- `CYODA_JWT_BOOTSTRAP_AUDIENCE` (default `client`; controls which key signs M2M tokens)\n\nSee `config.auth` for the full env-var reference.\n\n**Client (you) needs:**\n\n- For `client_credentials`: a registered M2M `client_id`/`secret` (see `auth.clients`).\n- For token-exchange (OBO): an already-valid subject JWT plus an M2M `client_id`/`secret` to act as the actor.\n\n## REQUEST FLOW\n\n### client_credentials — most common\n\nMint an M2M JWT with your client credentials:\n\n```bash\ncurl -X POST https://cyoda.example.com/api/oauth/token \\\n  -u \"${CLIENT_ID}:${CLIENT_SECRET}\" \\\n  -H \"Content-Type: application/x-www-form-urlencoded\" \\\n  -d \"grant_type=client_credentials\"\n```\n\nResponse (`200 OK`):\n\n```json\n{\n  \"access_token\": \"eyJhbGciOiJSUzI1NiIs…\",\n  \"token_type\":   \"Bearer\",\n  \"expires_in\":   3600\n}\n```\n\nUse the `access_token` as `Authorization: Bearer …` on every subsequent API call. Mint again when it nears `exp`; cyoda does not issue refresh tokens.\n\n### token-exchange (OBO)\n\nYou are an M2M actor (e.g. a backend service) and you want to call cyoda **on behalf of a user** whose token you already hold. The OBO grant re-signs the subject token so cyoda sees the user as the principal and your service as the actor (RFC 8693).\n\n```bash\ncurl -X POST https://cyoda.example.com/api/oauth/token \\\n  -u \"${CLIENT_ID}:${CLIENT_SECRET}\" \\\n  -H \"Content-Type: application/x-www-form-urlencoded\" \\\n  -d \"grant_type=urn:ietf:params:oauth:grant-type:token-exchange\" \\\n  -d \"subject_token=${USER_TOKEN}\" \\\n  -d \"subject_token_type=urn:ietf:params:oauth:token-type:jwt\"\n```\n\nResponse shape matches `client_credentials` plus a `issued_token_type` field:\n\n```json\n{\n  \"access_token\":      \"eyJhbGciOiJSUzI1NiIs…\",\n  \"token_type\":        \"Bearer\",\n  \"expires_in\":        3600,\n  \"issued_token_type\": \"urn:ietf:params:oauth:token-type:jwt\"\n}\n```\n\nKey constraints:\n\n- The subject token's `caas_org_id` must match the M2M client's tenant. Tenant mismatch → `403 access_denied`.\n- The issued OBO token carries `sub` = the subject's `sub`, `user_roles` from the subject token, and an `act` claim `{\"sub\": \"<m2m client_id>\"}` identifying the actor.\n- Subject token must already be valid (signature, not expired).\n\n## TOKEN\n\n**Cyoda-minted tokens** (issued via `client_credentials` or token-exchange/OBO at `/oauth/token`, or via the bootstrap key in `auth.trusted-keys`) carry the following claim shape. **Federated OIDC tokens** (`auth.oidc`) are *not* re-minted; they carry the upstream IdP's claim shape, and tenant + user identity are bound server-side from the registered provider's `OwnerLegalEntityID` — claims like `caas_org_id`, `caas_user_id`, `tid` on a federated token are explicitly ignored to prevent attacker-controlled tenant routing.\n\nClaim shape for cyoda-minted tokens:\n\n- `sub` (string) — Principal. `client_id` for M2M, user ID for OBO and federated tokens.\n- `iss` (string) — Issuer. Cyoda-minted tokens use `CYODA_JWT_ISSUER`. Federated tokens use the upstream IdP's issuer.\n- `aud` (string or string array) — Audience. Checked against `CYODA_JWT_AUDIENCE` if set; against `expectedAudiences` for federated providers.\n- `exp` (int unix) — Expiry.\n- `iat` (int unix) — Issued-at.\n- `jti` (string UUID) — Unique token ID.\n- `caas_org_id` (string UUID) — Tenant scope. Every API call is constrained to this tenant.\n- `caas_user_id` (string) — User identifier. For M2M tokens this duplicates `sub` (= `client_id`).\n- `user_roles` (string array) — Roles granted (e.g. `ROLE_ADMIN`, `ROLE_M2M`). Federated OIDC tokens carry roles from the provider's configured `rolesClaim` (default `roles`; per-provider override available — see `auth.oidc`).\n- `caas_tier` (string) — Tier label. cyoda-go: always `\"unlimited\"`; Cloud distinguishes paid tiers.\n- `act` (object) — **OBO only.** `{\"sub\": \"<m2m client_id>\"}` identifying the M2M actor that exchanged the user token. Absent on `client_credentials` tokens.\n\nCyoda issues tokens signed with `CYODA_JWT_SIGNING_KEY` (RS256). The `kid` header points at the active keypair in the keystore (`/oauth/keys/*`). Federated OIDC tokens are validated against the registered provider's JWKS — never signed by cyoda. Trusted-key tokens are signed by you (offline) with the matching private key for a registered public key.\n\n## ERRORS\n\n- `errors.UNAUTHORIZED` (`401`) — `Authorization` header missing, token expired, signature invalid, issuer untrusted, or `kid` not in any registered keystore / OIDC JWKS / trusted-key registry.\n- `errors.FORBIDDEN` (`403`) — token valid but caller lacks the required role for the operation.\n- `errors.BAD_REQUEST` (`400`) — malformed `grant_type`, missing form fields, invalid `subject_token` shape.\n- The `/oauth/token` endpoint returns OAuth-shaped errors (`{\"error\": \"...\", \"error_description\": \"...\"}`) per RFC 6749 rather than the generic cyoda error envelope — `invalid_client`, `invalid_grant`, `access_denied`, `server_error`.\n\n## SEE ALSO\n\n- `auth.clients` — provision the M2M client used by `client_credentials` and OBO\n- `auth.oidc` — federate an external IdP whose JWTs cyoda will accept directly\n- `auth.trusted-keys` — register a public key so JWTs you sign offline are accepted\n- `config.auth` — `CYODA_JWT_*`, `CYODA_BOOTSTRAP_*`\n- `openapi` — `cyoda help openapi tags` and look for the `IAM` tag\n",
  "sections": [
    {
      "name": "NAME",
      "body": "auth.tokens — exchange credentials for a JWT at `POST /api/oauth/token`. Covers every supported grant and the canonical JWT claim contract that all cyoda tokens (M2M, OBO, federated OIDC, trusted-key) conform to."
    },
    {
      "name": "GOAL",
      "body": "You have a way to prove identity (an M2M `client_id`/`secret`, or a JWT minted by a federated IdP, or a JWT you signed offline) and you want a cyoda-issued (or cyoda-validated) JWT to present on subsequent API calls.\n\nThis is the single home for the JWT claim contract. `auth.oidc` and `auth.trusted-keys` link here for claim shape."
    },
    {
      "name": "PREREQUISITES",
      "body": "**Admin (cyoda operator) sets up:**\n\n- `CYODA_IAM_MODE=jwt`\n- `CYODA_JWT_SIGNING_KEY` (PEM RSA private key; tokens cyoda issues are signed with this)\n- `CYODA_JWT_ISSUER` (default `cyoda`; populates the `iss` claim)\n- `CYODA_JWT_AUDIENCE` (default empty = no `aud` check on inbound tokens)\n- `CYODA_JWT_EXPIRY_SECONDS` (default `3600`)\n- `CYODA_JWT_BOOTSTRAP_AUDIENCE` (default `client`; controls which key signs M2M tokens)\n\nSee `config.auth` for the full env-var reference.\n\n**Client (you) needs:**\n\n- For `client_credentials`: a registered M2M `client_id`/`secret` (see `auth.clients`).\n- For token-exchange (OBO): an already-valid subject JWT plus an M2M `client_id`/`secret` to act as the actor."
    },
    {
      "name": "REQUEST FLOW",
      "body": "### client_credentials — most common\n\nMint an M2M JWT with your client credentials:\n\n```bash\ncurl -X POST https://cyoda.example.com/api/oauth/token \\\n  -u \"${CLIENT_ID}:${CLIENT_SECRET}\" \\\n  -H \"Content-Type: application/x-www-form-urlencoded\" \\\n  -d \"grant_type=client_credentials\"\n```\n\nResponse (`200 OK`):\n\n```json\n{\n  \"access_token\": \"eyJhbGciOiJSUzI1NiIs…\",\n  \"token_type\":   \"Bearer\",\n  \"expires_in\":   3600\n}\n```\n\nUse the `access_token` as `Authorization: Bearer …` on every subsequent API call. Mint again when it nears `exp`; cyoda does not issue refresh tokens.\n\n### token-exchange (OBO)\n\nYou are an M2M actor (e.g. a backend service) and you want to call cyoda **on behalf of a user** whose token you already hold. The OBO grant re-signs the subject token so cyoda sees the user as the principal and your service as the actor (RFC 8693).\n\n```bash\ncurl -X POST https://cyoda.example.com/api/oauth/token \\\n  -u \"${CLIENT_ID}:${CLIENT_SECRET}\" \\\n  -H \"Content-Type: application/x-www-form-urlencoded\" \\\n  -d \"grant_type=urn:ietf:params:oauth:grant-type:token-exchange\" \\\n  -d \"subject_token=${USER_TOKEN}\" \\\n  -d \"subject_token_type=urn:ietf:params:oauth:token-type:jwt\"\n```\n\nResponse shape matches `client_credentials` plus a `issued_token_type` field:\n\n```json\n{\n  \"access_token\":      \"eyJhbGciOiJSUzI1NiIs…\",\n  \"token_type\":        \"Bearer\",\n  \"expires_in\":        3600,\n  \"issued_token_type\": \"urn:ietf:params:oauth:token-type:jwt\"\n}\n```\n\nKey constraints:\n\n- The subject token's `caas_org_id` must match the M2M client's tenant. Tenant mismatch → `403 access_denied`.\n- The issued OBO token carries `sub` = the subject's `sub`, `user_roles` from the subject token, and an `act` claim `{\"sub\": \"<m2m client_id>\"}` identifying the actor.\n- Subject token must already be valid (signature, not expired)."
    },
    {
      "name": "TOKEN",
      "body": "**Cyoda-minted tokens** (issued via `client_credentials` or token-exchange/OBO at `/oauth/token`, or via the bootstrap key in `auth.trusted-keys`) carry the following claim shape. **Federated OIDC tokens** (`auth.oidc`) are *not* re-minted; they carry the upstream IdP's claim shape, and tenant + user identity are bound server-side from the registered provider's `OwnerLegalEntityID` — claims like `caas_org_id`, `caas_user_id`, `tid` on a federated token are explicitly ignored to prevent attacker-controlled tenant routing.\n\nClaim shape for cyoda-minted tokens:\n\n- `sub` (string) — Principal. `client_id` for M2M, user ID for OBO and federated tokens.\n- `iss` (string) — Issuer. Cyoda-minted tokens use `CYODA_JWT_ISSUER`. Federated tokens use the upstream IdP's issuer.\n- `aud` (string or string array) — Audience. Checked against `CYODA_JWT_AUDIENCE` if set; against `expectedAudiences` for federated providers.\n- `exp` (int unix) — Expiry.\n- `iat` (int unix) — Issued-at.\n- `jti` (string UUID) — Unique token ID.\n- `caas_org_id` (string UUID) — Tenant scope. Every API call is constrained to this tenant.\n- `caas_user_id` (string) — User identifier. For M2M tokens this duplicates `sub` (= `client_id`).\n- `user_roles` (string array) — Roles granted (e.g. `ROLE_ADMIN`, `ROLE_M2M`). Federated OIDC tokens carry roles from the provider's configured `rolesClaim` (default `roles`; per-provider override available — see `auth.oidc`).\n- `caas_tier` (string) — Tier label. cyoda-go: always `\"unlimited\"`; Cloud distinguishes paid tiers.\n- `act` (object) — **OBO only.** `{\"sub\": \"<m2m client_id>\"}` identifying the M2M actor that exchanged the user token. Absent on `client_credentials` tokens.\n\nCyoda issues tokens signed with `CYODA_JWT_SIGNING_KEY` (RS256). The `kid` header points at the active keypair in the keystore (`/oauth/keys/*`). Federated OIDC tokens are validated against the registered provider's JWKS — never signed by cyoda. Trusted-key tokens are signed by you (offline) with the matching private key for a registered public key."
    },
    {
      "name": "ERRORS",
      "body": "- `errors.UNAUTHORIZED` (`401`) — `Authorization` header missing, token expired, signature invalid, issuer untrusted, or `kid` not in any registered keystore / OIDC JWKS / trusted-key registry.\n- `errors.FORBIDDEN` (`403`) — token valid but caller lacks the required role for the operation.\n- `errors.BAD_REQUEST` (`400`) — malformed `grant_type`, missing form fields, invalid `subject_token` shape.\n- The `/oauth/token` endpoint returns OAuth-shaped errors (`{\"error\": \"...\", \"error_description\": \"...\"}`) per RFC 6749 rather than the generic cyoda error envelope — `invalid_client`, `invalid_grant`, `access_denied`, `server_error`."
    },
    {
      "name": "SEE ALSO",
      "body": "- `auth.clients` — provision the M2M client used by `client_credentials` and OBO\n- `auth.oidc` — federate an external IdP whose JWTs cyoda will accept directly\n- `auth.trusted-keys` — register a public key so JWTs you sign offline are accepted\n- `config.auth` — `CYODA_JWT_*`, `CYODA_BOOTSTRAP_*`\n- `openapi` — `cyoda help openapi tags` and look for the `IAM` tag"
    }
  ],
  "see_also": [
    "auth",
    "auth.clients",
    "auth.oidc",
    "auth.trusted-keys",
    "config.auth",
    "errors.UNAUTHORIZED",
    "errors.FORBIDDEN"
  ],
  "stability": "evolving",
  "actions": []
}
