{
  "topic": "auth.trusted-keys",
  "path": [
    "auth",
    "trusted-keys"
  ],
  "title": "auth.trusted-keys — register public keys for offline JWT signing",
  "synopsis": "auth.trusted-keys — register a public key with cyoda so JWTs you sign offline with the matching private key are accepted, without an IdP or `/oauth/token` round-trip.",
  "body": "# auth.trusted-keys\n\n## NAME\n\nauth.trusted-keys — register a public key with cyoda so JWTs you sign offline with the matching private key are accepted, without an IdP or `/oauth/token` round-trip.\n\n## GOAL\n\nYou have a workload that can sign JWTs (a CI job, a controller, an air-gapped service) and you don't want to depend on cyoda being reachable for token minting or an external IdP for discovery. Register the public key once; thereafter your workload signs JWTs locally and cyoda validates them by `kid` lookup against the registry.\n\nUse this path when token minting must work even when cyoda's `/oauth/token` is unreachable, or when you want zero IdP infrastructure.\n\n**Feature flag.** The 5 trusted-key endpoints under `/oauth/keys/trusted/*` are **off by default**. The operator must set `CYODA_IAM_TRUSTED_KEY_REGISTRATION_ENABLED=true` to enable them; otherwise every endpoint returns `404 FEATURE_DISABLED`. This is intentional — trusted keys move the trust boundary, and that posture should be explicit.\n\n## PREREQUISITES\n\n**Admin (cyoda operator) sets up:**\n\n- `CYODA_IAM_MODE=jwt`.\n- `CYODA_IAM_TRUSTED_KEY_REGISTRATION_ENABLED=true` (gate; see callout above).\n- `CYODA_IAM_TRUSTED_KEY_MAX_PER_TENANT` (default `10`) — per-tenant cap on currently-valid trusted keys. Counts active + within-validity entries only.\n- `CYODA_IAM_TRUSTED_KEY_MAX_VALIDITY_DAYS` (default `365`) — default validity for trusted keys when not specified at registration.\n\n**Client (you) needs:**\n\n- A keypair you generated yourself. cyoda-go v0.8.0 supports `kty: \"RSA\"` only. Cloud also supports `kty: \"EC\"` and `kty: \"OKP\"`; cyoda-go parity is tracked for a future release.\n- A `ROLE_ADMIN` cyoda token to register / delete / lifecycle the entry.\n\n## REQUEST FLOW\n\n### Register a public key\n\n```bash\n# Generate a keypair locally\nopenssl genrsa -out signing.pem 2048\nopenssl rsa -in signing.pem -pubout -out signing.pub\n# Convert the public key to JWK shape — your tooling of choice;\n# the API expects the JWK members at the top level.\n\ncurl -X POST https://cyoda.example.com/api/oauth/keys/trusted \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"keyId\": \"my-signing-key-2026-06\",\n        \"kty\":   \"RSA\",\n        \"n\":     \"<base64url-modulus>\",\n        \"e\":     \"AQAB\"\n      }'\n```\n\nResponse (`200 OK`) echoes the registered key shape plus lifecycle metadata.\n\nPick a stable, descriptive `keyId`. It becomes the JWT `kid` header you must set when signing.\n\n### List trusted keys\n\n```bash\ncurl -X GET https://cyoda.example.com/api/oauth/keys/trusted \\\n  -H \"Authorization: Bearer ${TOKEN}\"\n```\n\nReturns the tenant's keys with status (active / invalidated) and validity window.\n\n### Invalidate / reactivate\n\n```bash\n# Stop accepting tokens signed with this key, without removing the entry.\ncurl -X POST https://cyoda.example.com/api/oauth/keys/trusted/${KEY_ID}/invalidate \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n\n# Re-enable.\ncurl -X POST https://cyoda.example.com/api/oauth/keys/trusted/${KEY_ID}/reactivate \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n```\n\n### Delete\n\n```bash\ncurl -X DELETE https://cyoda.example.com/api/oauth/keys/trusted/${KEY_ID} \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n```\n\n### Sign and present a token\n\nYour workload signs a JWT with the matching private key, setting `kid` to the registered `keyId`:\n\n```text\nHeader:  { \"alg\": \"RS256\", \"typ\": \"JWT\", \"kid\": \"my-signing-key-2026-06\" }\nPayload: { universal cyoda claim contract — see auth.tokens }\n```\n\nPresent it like any other cyoda token:\n\n```bash\ncurl -X GET https://cyoda.example.com/api/clients \\\n  -H \"Authorization: Bearer ${SIGNED_JWT}\"\n```\n\ncyoda looks up `kid` in the trusted-key registry, validates the RS256 signature against the registered public key, then enforces the universal claim checks (`iss`, `aud`, `exp`, …).\n\n## TOKEN\n\nJWTs you sign with a trusted-key private key must conform to the universal cyoda claim contract documented in `auth.tokens`. In particular:\n\n- `iss` must match `CYODA_JWT_ISSUER` (typically the value you set on the cyoda deployment) — trusted-key tokens are *not* issuer-bound to your IdP because there is no IdP.\n- `aud` is checked against `CYODA_JWT_AUDIENCE` if set.\n- `caas_org_id` must match the tenant that registered the key.\n\nCyoda does not mint trusted-key JWTs — you sign them. This page covers the registration + lifecycle of the public key; the claim contract is in `auth.tokens`.\n\n## ERRORS\n\n- `errors.FEATURE_DISABLED` (`404`) — trusted-key endpoints called with `CYODA_IAM_TRUSTED_KEY_REGISTRATION_ENABLED=false`.\n- `errors.TRUSTED_KEY_NOT_FOUND` (`404`) — referenced `keyId` not in the registry (also returned for cross-tenant access — the existence of another tenant's key is never confirmed).\n- `errors.TRUSTED_KEY_CAP_REACHED` (`400`) — per-tenant cap reached; delete or invalidate an old key first.\n- `errors.KEY_OWNED_BY_DIFFERENT_TENANT` (`409`) — registration request specifies a `keyId` that already belongs to another tenant. Pick a fresh `keyId`.\n- `errors.UNSUPPORTED_KEY_TYPE` (`400`) — `kty` is not `\"RSA\"` (the only type cyoda-go v0.8.0 accepts).\n- `errors.UNAUTHORIZED` (`401`) — caller lacks a valid bearer for the management call; or, at validation time, the signing key is unknown / invalidated / expired.\n\n## SEE ALSO\n\n- `auth.tokens` — universal JWT claim contract\n- `config.auth` — `CYODA_IAM_TRUSTED_KEY_*` env vars\n- `openapi` — `cyoda help openapi tags` and look for the `IAM` tag's `/oauth/keys/trusted/*` operations\n",
  "sections": [
    {
      "name": "NAME",
      "body": "auth.trusted-keys — register a public key with cyoda so JWTs you sign offline with the matching private key are accepted, without an IdP or `/oauth/token` round-trip."
    },
    {
      "name": "GOAL",
      "body": "You have a workload that can sign JWTs (a CI job, a controller, an air-gapped service) and you don't want to depend on cyoda being reachable for token minting or an external IdP for discovery. Register the public key once; thereafter your workload signs JWTs locally and cyoda validates them by `kid` lookup against the registry.\n\nUse this path when token minting must work even when cyoda's `/oauth/token` is unreachable, or when you want zero IdP infrastructure.\n\n**Feature flag.** The 5 trusted-key endpoints under `/oauth/keys/trusted/*` are **off by default**. The operator must set `CYODA_IAM_TRUSTED_KEY_REGISTRATION_ENABLED=true` to enable them; otherwise every endpoint returns `404 FEATURE_DISABLED`. This is intentional — trusted keys move the trust boundary, and that posture should be explicit."
    },
    {
      "name": "PREREQUISITES",
      "body": "**Admin (cyoda operator) sets up:**\n\n- `CYODA_IAM_MODE=jwt`.\n- `CYODA_IAM_TRUSTED_KEY_REGISTRATION_ENABLED=true` (gate; see callout above).\n- `CYODA_IAM_TRUSTED_KEY_MAX_PER_TENANT` (default `10`) — per-tenant cap on currently-valid trusted keys. Counts active + within-validity entries only.\n- `CYODA_IAM_TRUSTED_KEY_MAX_VALIDITY_DAYS` (default `365`) — default validity for trusted keys when not specified at registration.\n\n**Client (you) needs:**\n\n- A keypair you generated yourself. cyoda-go v0.8.0 supports `kty: \"RSA\"` only. Cloud also supports `kty: \"EC\"` and `kty: \"OKP\"`; cyoda-go parity is tracked for a future release.\n- A `ROLE_ADMIN` cyoda token to register / delete / lifecycle the entry."
    },
    {
      "name": "REQUEST FLOW",
      "body": "### Register a public key\n\n```bash\n# Generate a keypair locally\nopenssl genrsa -out signing.pem 2048\nopenssl rsa -in signing.pem -pubout -out signing.pub\n# Convert the public key to JWK shape — your tooling of choice;\n# the API expects the JWK members at the top level.\n\ncurl -X POST https://cyoda.example.com/api/oauth/keys/trusted \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"keyId\": \"my-signing-key-2026-06\",\n        \"kty\":   \"RSA\",\n        \"n\":     \"<base64url-modulus>\",\n        \"e\":     \"AQAB\"\n      }'\n```\n\nResponse (`200 OK`) echoes the registered key shape plus lifecycle metadata.\n\nPick a stable, descriptive `keyId`. It becomes the JWT `kid` header you must set when signing.\n\n### List trusted keys\n\n```bash\ncurl -X GET https://cyoda.example.com/api/oauth/keys/trusted \\\n  -H \"Authorization: Bearer ${TOKEN}\"\n```\n\nReturns the tenant's keys with status (active / invalidated) and validity window.\n\n### Invalidate / reactivate\n\n```bash\n# Stop accepting tokens signed with this key, without removing the entry.\ncurl -X POST https://cyoda.example.com/api/oauth/keys/trusted/${KEY_ID}/invalidate \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n\n# Re-enable.\ncurl -X POST https://cyoda.example.com/api/oauth/keys/trusted/${KEY_ID}/reactivate \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n```\n\n### Delete\n\n```bash\ncurl -X DELETE https://cyoda.example.com/api/oauth/keys/trusted/${KEY_ID} \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n```\n\n### Sign and present a token\n\nYour workload signs a JWT with the matching private key, setting `kid` to the registered `keyId`:\n\n```text\nHeader:  { \"alg\": \"RS256\", \"typ\": \"JWT\", \"kid\": \"my-signing-key-2026-06\" }\nPayload: { universal cyoda claim contract — see auth.tokens }\n```\n\nPresent it like any other cyoda token:\n\n```bash\ncurl -X GET https://cyoda.example.com/api/clients \\\n  -H \"Authorization: Bearer ${SIGNED_JWT}\"\n```\n\ncyoda looks up `kid` in the trusted-key registry, validates the RS256 signature against the registered public key, then enforces the universal claim checks (`iss`, `aud`, `exp`, …)."
    },
    {
      "name": "TOKEN",
      "body": "JWTs you sign with a trusted-key private key must conform to the universal cyoda claim contract documented in `auth.tokens`. In particular:\n\n- `iss` must match `CYODA_JWT_ISSUER` (typically the value you set on the cyoda deployment) — trusted-key tokens are *not* issuer-bound to your IdP because there is no IdP.\n- `aud` is checked against `CYODA_JWT_AUDIENCE` if set.\n- `caas_org_id` must match the tenant that registered the key.\n\nCyoda does not mint trusted-key JWTs — you sign them. This page covers the registration + lifecycle of the public key; the claim contract is in `auth.tokens`."
    },
    {
      "name": "ERRORS",
      "body": "- `errors.FEATURE_DISABLED` (`404`) — trusted-key endpoints called with `CYODA_IAM_TRUSTED_KEY_REGISTRATION_ENABLED=false`.\n- `errors.TRUSTED_KEY_NOT_FOUND` (`404`) — referenced `keyId` not in the registry (also returned for cross-tenant access — the existence of another tenant's key is never confirmed).\n- `errors.TRUSTED_KEY_CAP_REACHED` (`400`) — per-tenant cap reached; delete or invalidate an old key first.\n- `errors.KEY_OWNED_BY_DIFFERENT_TENANT` (`409`) — registration request specifies a `keyId` that already belongs to another tenant. Pick a fresh `keyId`.\n- `errors.UNSUPPORTED_KEY_TYPE` (`400`) — `kty` is not `\"RSA\"` (the only type cyoda-go v0.8.0 accepts).\n- `errors.UNAUTHORIZED` (`401`) — caller lacks a valid bearer for the management call; or, at validation time, the signing key is unknown / invalidated / expired."
    },
    {
      "name": "SEE ALSO",
      "body": "- `auth.tokens` — universal JWT claim contract\n- `config.auth` — `CYODA_IAM_TRUSTED_KEY_*` env vars\n- `openapi` — `cyoda help openapi tags` and look for the `IAM` tag's `/oauth/keys/trusted/*` operations"
    }
  ],
  "see_also": [
    "auth",
    "auth.tokens",
    "config.auth",
    "errors.TRUSTED_KEY_NOT_FOUND",
    "errors.TRUSTED_KEY_CAP_REACHED",
    "errors.KEY_OWNED_BY_DIFFERENT_TENANT",
    "errors.UNSUPPORTED_KEY_TYPE",
    "errors.FEATURE_DISABLED"
  ],
  "stability": "evolving",
  "actions": []
}
