{
  "topic": "auth.oidc",
  "path": [
    "auth",
    "oidc"
  ],
  "title": "auth.oidc — federated OIDC providers",
  "synopsis": "auth.oidc — register an external OIDC identity provider so JWTs it issues are accepted by cyoda directly, without re-minting at `/oauth/token`.",
  "body": "# auth.oidc\n\n## NAME\n\nauth.oidc — register an external OIDC identity provider so JWTs it issues are accepted by cyoda directly, without re-minting at `/oauth/token`.\n\n## GOAL\n\nYou already have an OIDC IdP — Cognito, Keycloak, Auth0, Okta, your own — and you want clients to present that IdP's JWTs to cyoda. Register the provider once; cyoda fetches its JWKS, validates inbound tokens against the keys, maps roles from the configured claim, and binds the resulting identity to a tenant.\n\nUse this path when the IdP is the source of truth for user accounts. For pure M2M, `auth.clients` + `auth.tokens` is simpler.\n\n## PREREQUISITES\n\n**Admin (cyoda operator) sets up:**\n\n- `CYODA_IAM_MODE=jwt` (federated OIDC is unavailable in mock mode).\n- `CYODA_OIDC_REQUIRE_HTTPS=true` for production. Set to `false` only for dev IdPs over plain HTTP.\n- `CYODA_OIDC_ALLOW_PRIVATE_NETWORKS=false` for production. Setting to `true` disables the SSRF blocklist that prevents `wellKnownConfigUri` resolving to RFC 1918 / loopback / link-local addresses.\n- `CYODA_OIDC_ROLES_CLAIM` (default `roles`) — global default JWT claim from which roles are read. Per-provider override available at registration.\n- `CYODA_OIDC_CONNECT_TIMEOUT_MS`, `CYODA_OIDC_SOCKET_TIMEOUT_MS`, `CYODA_OIDC_CONNECTION_REQUEST_TIMEOUT_MS` (each default `5000`) — HTTP timeouts for discovery + JWKS fetches.\n\n**Client (you) needs:**\n\n- A working `.well-known/openid-configuration` URL on the IdP.\n- A `ROLE_ADMIN` cyoda token to register the provider.\n- A **UUID-shaped tenant ID** — the bootstrap convenience literal `default-tenant` is rejected by registration (returns `OIDC_INVALID_TENANT`) because non-UUID tenant identifiers collide in storage.\n\n## REQUEST FLOW\n\n### Register a provider\n\n```bash\ncurl -X POST https://cyoda.example.com/api/oauth/oidc/providers \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"wellKnownConfigUri\": \"https://idp.example.com/.well-known/openid-configuration\",\n        \"issuers\":            [\"https://idp.example.com/\"],\n        \"expectedAudiences\":  [\"cyoda-prod\"],\n        \"rolesClaim\":         \"cognito:groups\"\n      }'\n```\n\nResponse (`200 OK`) carries the full provider DTO:\n\n```json\n{\n  \"id\":                 \"f47ac10b-58cc-…\",\n  \"wellKnownConfigUri\": \"https://idp.example.com/.well-known/openid-configuration\",\n  \"active\":             true,\n  \"createdAt\":          \"2026-06-17T12:00:00Z\",\n  \"issuers\":            [\"https://idp.example.com/\"],\n  \"expectedAudiences\":  [\"cyoda-prod\"],\n  \"rolesClaim\":         \"cognito:groups\"\n}\n```\n\nRegistration succeeds even when the IdP's discovery / JWKS endpoints are unreachable — cyoda warms them asynchronously after the response. Tokens issued by an un-warmed provider fail with `401 UNAUTHORIZED` until the next warmup cycle (or an explicit `/reload`). See **DIAGNOSTICS** below for the operator path.\n\nBehaviour on `expectedAudiences`:\n\n- Omitted, `null`, or empty array → no `aud` enforcement (issuer-binding is the trust anchor).\n- Non-empty → inbound JWT `aud` claim must match at least one entry byte-wise.\n\nBehaviour on `issuers`:\n\n- Omitted, `null`, or empty → cyoda enforces the `iss` claim matches the discovery document's `issuer` field byte-wise (OIDC Core 1.0 §2).\n- Non-empty → inbound JWT `iss` must match one of the listed values byte-wise.\n\n### List providers\n\n```bash\ncurl -X GET \"https://cyoda.example.com/api/oauth/oidc/providers?activeOnly=true\" \\\n  -H \"Authorization: Bearer ${TOKEN}\"\n```\n\n`activeOnly=true` filters out invalidated providers. Available to any authenticated tenant member, not just admin.\n\n### Update a provider (tri-state PATCH)\n\n```bash\ncurl -X PATCH https://cyoda.example.com/api/oauth/oidc/providers/${PROVIDER_ID} \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"expectedAudiences\": [\"cyoda-prod\", \"cyoda-staging\"]\n      }'\n```\n\nPer-field tri-state semantics: absent = unchanged, `null` or `[]` = clear, value = set. Same for `issuers` and `rolesClaim`.\n\n### Invalidate / reactivate / delete\n\n```bash\n# Invalidate — token validation stops accepting this provider's JWTs immediately.\ncurl -X POST https://cyoda.example.com/api/oauth/oidc/providers/${PROVIDER_ID}/invalidate \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n\n# Reactivate — by default refreshes JWKS from upstream.\ncurl -X POST https://cyoda.example.com/api/oauth/oidc/providers/${PROVIDER_ID}/reactivate \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"reactivateKeys\": true}'\n\n# Delete permanently.\ncurl -X DELETE https://cyoda.example.com/api/oauth/oidc/providers/${PROVIDER_ID} \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n```\n\n### Reload JWKS for all providers\n\n```bash\ncurl -X POST https://cyoda.example.com/api/oauth/oidc/providers/reload \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n```\n\nForces an immediate JWKS refresh for every active provider on the receiving node. In a multi-node cluster the reload is broadcast.\n\n### Present an IdP-issued JWT\n\nOnce registered, clients present the IdP's JWT directly:\n\n```bash\ncurl -X GET https://cyoda.example.com/api/clients \\\n  -H \"Authorization: Bearer ${IDP_ISSUED_JWT}\"\n```\n\nThe token is validated against the provider's JWKS; roles are read from the configured `rolesClaim`; identity is bound to the tenant that registered the provider.\n\n## TOKEN\n\nJWTs issued by the federated IdP must conform to the universal cyoda claim contract documented in `auth.tokens`. In particular:\n\n- `iss` must match per the `issuers` / discovery-document rule above.\n- `aud` must match `expectedAudiences` if set.\n- The configured `rolesClaim` (per-provider override or `CYODA_OIDC_ROLES_CLAIM`) is looked up as a literal **top-level** JWT claim name. The value at that key may be any of:\n  1. **JSON array of strings** — `[\"admin\",\"warehouse\"]` → used as-is.\n  2. **JSON object** — `{ \"admin\": {…}, \"warehouse\": {…} }` → roles are the **top-level keys** (`[\"admin\",\"warehouse\"]`); inner values are ignored. This is the shape Zitadel emits for `urn:zitadel:iam:org:project:roles` with `projectRoleAssertion=true`.\n  3. **String** — `\"admin warehouse\"` → split on whitespace per RFC 6749 §3.3 / RFC 8693 §4.2; a lone token `\"admin\"` yields one role.\n\n  Empty / absent / non-collection scalar (number, bool) → no roles, no error (the user is authenticated but unprivileged). Role names containing a comma are dropped silently (cyoda comma-joins roles for downstream serialisation; no major IdP emits commas in role names by convention).\n\n  Common per-IdP values for `rolesClaim`:\n\n```text\n  IdP                              rolesClaim value\n  -------------------------------  -----------------------------------------------\n  Default (no override)            roles\n  Amazon Cognito                   cognito:groups\n  Zitadel (project-wide)           urn:zitadel:iam:org:project:roles\n  Zitadel (per-project)            urn:zitadel:iam:org:project:<projectId>:roles\n  Auth0 (namespaced custom claim)  https://your-app.example.com/roles\n```\n\n  Note: there is no path/dot-walking syntax — whatever string you configure is used as a single literal claim-key lookup against the JWT, so colons, slashes, and dots inside the value are treated as part of the key name (which is exactly what Zitadel, Cognito, and Auth0 need).\n\n  Keycloak's default `realm_access.roles` lives one level deep inside the `realm_access` object, which the no-dot-walking rule means cyoda cannot reach. Configure a Keycloak token mapper (hardcoded-claim or role-list, at realm or client scope) to flatten the role list to a top-level `roles` claim, then point `rolesClaim` at that top-level key.\n\nCyoda does not re-mint federated tokens — they are validated and trusted directly.\n\n## DIAGNOSTICS\n\n**Every token-validation failure surfaces as `401 UNAUTHORIZED` with a uniform problem-detail body. No precise error code distinguishes audience mismatch, expired token, unknown `kid`, JWKS-unreachable, claims-invalid, or ambiguous-tenant routing.** A precise wire code would enumerate IdP / tenant / kid / claim-shape recognition to an unauthenticated caller, so the wire stays uniform by design.\n\nThe diagnostic path for every failure mode is **server-side logs**, not the HTTP response. Look at `oidc.registry` (KID resolution), `oidc.validator` (claims + signature), and `oidc.discovery` / `oidc.jwks` (transport) events. The bearer-auth middleware emits one structured WARN per failed request with the failure reason slug.\n\nSymptom → cause map for the common cases:\n\n- **\"I get 401 but my token looks valid\":** check `oidc.registry` resolve logs. The most common cause is the provider's JWKS hasn't warmed yet (registration succeeds before discovery completes); force-warm via `/oauth/oidc/providers/reload`.\n- **\"After re-registering, valid tokens are rejected\":** the second-most-common case — two tenants registered the same IdP with overlapping or empty `expectedAudiences`. Internally `ErrAmbiguousProvider` (`internal/auth/oidc/registry.go`) wraps to `ErrUnknownKID`. To resolve, pick disjoint `expectedAudiences` per tenant.\n- **\"Tokens reject for the right tenant but the IdP is up\":** verify the JWT `iss` claim matches either an explicit `issuers` entry or the discovery document's `issuer` byte-for-byte. Trailing slashes count.\n\nRegistration-time failures (these DO carry precise codes — see ERRORS) are SSRF, duplicate URI per tenant, malformed tenant UUID, and provider-not-found / invalid-state for lifecycle ops. Registration succeeds even when the IdP itself is unreachable — discovery failures don't fail the response; they delay the warmup.\n\n## ERRORS\n\nRegistration / lifecycle (precise codes — admin-facing surface):\n\n- `errors.OIDC_INVALID_TENANT` (`400`) — caller's tenant ID is not UUID-shaped (commonly: bootstrap `default-tenant` literal).\n- `errors.OIDC_SSRF_BLOCKED` (`400`) — `wellKnownConfigUri` resolves to a blocked address range (set `CYODA_OIDC_ALLOW_PRIVATE_NETWORKS=true` for dev).\n- `errors.OIDC_PROVIDER_DUPLICATE` (`409`) — same `wellKnownConfigUri` already registered for this tenant.\n- `errors.OIDC_PROVIDER_NOT_FOUND` (`404`) — referenced provider ID absent in this tenant.\n- `errors.OIDC_PROVIDER_INACTIVE` (`409`) — update or operation attempted on an invalidated provider; reactivate first.\n\nToken validation (always opaque — see DIAGNOSTICS):\n\n- `errors.UNAUTHORIZED` (`401`) — every token-validation failure path. The wire body never distinguishes audience mismatch, expired token, unknown `kid`, JWKS-unreachable, claims-invalid, or ambiguous-tenant routing.\n\n## SEE ALSO\n\n- `auth.tokens` — universal claim contract\n- `config.auth` — `CYODA_OIDC_*` env vars\n- `openapi` — `cyoda help openapi tags` and look for `OAuth, OIDC Providers`\n",
  "sections": [
    {
      "name": "NAME",
      "body": "auth.oidc — register an external OIDC identity provider so JWTs it issues are accepted by cyoda directly, without re-minting at `/oauth/token`."
    },
    {
      "name": "GOAL",
      "body": "You already have an OIDC IdP — Cognito, Keycloak, Auth0, Okta, your own — and you want clients to present that IdP's JWTs to cyoda. Register the provider once; cyoda fetches its JWKS, validates inbound tokens against the keys, maps roles from the configured claim, and binds the resulting identity to a tenant.\n\nUse this path when the IdP is the source of truth for user accounts. For pure M2M, `auth.clients` + `auth.tokens` is simpler."
    },
    {
      "name": "PREREQUISITES",
      "body": "**Admin (cyoda operator) sets up:**\n\n- `CYODA_IAM_MODE=jwt` (federated OIDC is unavailable in mock mode).\n- `CYODA_OIDC_REQUIRE_HTTPS=true` for production. Set to `false` only for dev IdPs over plain HTTP.\n- `CYODA_OIDC_ALLOW_PRIVATE_NETWORKS=false` for production. Setting to `true` disables the SSRF blocklist that prevents `wellKnownConfigUri` resolving to RFC 1918 / loopback / link-local addresses.\n- `CYODA_OIDC_ROLES_CLAIM` (default `roles`) — global default JWT claim from which roles are read. Per-provider override available at registration.\n- `CYODA_OIDC_CONNECT_TIMEOUT_MS`, `CYODA_OIDC_SOCKET_TIMEOUT_MS`, `CYODA_OIDC_CONNECTION_REQUEST_TIMEOUT_MS` (each default `5000`) — HTTP timeouts for discovery + JWKS fetches.\n\n**Client (you) needs:**\n\n- A working `.well-known/openid-configuration` URL on the IdP.\n- A `ROLE_ADMIN` cyoda token to register the provider.\n- A **UUID-shaped tenant ID** — the bootstrap convenience literal `default-tenant` is rejected by registration (returns `OIDC_INVALID_TENANT`) because non-UUID tenant identifiers collide in storage."
    },
    {
      "name": "REQUEST FLOW",
      "body": "### Register a provider\n\n```bash\ncurl -X POST https://cyoda.example.com/api/oauth/oidc/providers \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"wellKnownConfigUri\": \"https://idp.example.com/.well-known/openid-configuration\",\n        \"issuers\":            [\"https://idp.example.com/\"],\n        \"expectedAudiences\":  [\"cyoda-prod\"],\n        \"rolesClaim\":         \"cognito:groups\"\n      }'\n```\n\nResponse (`200 OK`) carries the full provider DTO:\n\n```json\n{\n  \"id\":                 \"f47ac10b-58cc-…\",\n  \"wellKnownConfigUri\": \"https://idp.example.com/.well-known/openid-configuration\",\n  \"active\":             true,\n  \"createdAt\":          \"2026-06-17T12:00:00Z\",\n  \"issuers\":            [\"https://idp.example.com/\"],\n  \"expectedAudiences\":  [\"cyoda-prod\"],\n  \"rolesClaim\":         \"cognito:groups\"\n}\n```\n\nRegistration succeeds even when the IdP's discovery / JWKS endpoints are unreachable — cyoda warms them asynchronously after the response. Tokens issued by an un-warmed provider fail with `401 UNAUTHORIZED` until the next warmup cycle (or an explicit `/reload`). See **DIAGNOSTICS** below for the operator path.\n\nBehaviour on `expectedAudiences`:\n\n- Omitted, `null`, or empty array → no `aud` enforcement (issuer-binding is the trust anchor).\n- Non-empty → inbound JWT `aud` claim must match at least one entry byte-wise.\n\nBehaviour on `issuers`:\n\n- Omitted, `null`, or empty → cyoda enforces the `iss` claim matches the discovery document's `issuer` field byte-wise (OIDC Core 1.0 §2).\n- Non-empty → inbound JWT `iss` must match one of the listed values byte-wise.\n\n### List providers\n\n```bash\ncurl -X GET \"https://cyoda.example.com/api/oauth/oidc/providers?activeOnly=true\" \\\n  -H \"Authorization: Bearer ${TOKEN}\"\n```\n\n`activeOnly=true` filters out invalidated providers. Available to any authenticated tenant member, not just admin.\n\n### Update a provider (tri-state PATCH)\n\n```bash\ncurl -X PATCH https://cyoda.example.com/api/oauth/oidc/providers/${PROVIDER_ID} \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"expectedAudiences\": [\"cyoda-prod\", \"cyoda-staging\"]\n      }'\n```\n\nPer-field tri-state semantics: absent = unchanged, `null` or `[]` = clear, value = set. Same for `issuers` and `rolesClaim`.\n\n### Invalidate / reactivate / delete\n\n```bash\n# Invalidate — token validation stops accepting this provider's JWTs immediately.\ncurl -X POST https://cyoda.example.com/api/oauth/oidc/providers/${PROVIDER_ID}/invalidate \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n\n# Reactivate — by default refreshes JWKS from upstream.\ncurl -X POST https://cyoda.example.com/api/oauth/oidc/providers/${PROVIDER_ID}/reactivate \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"reactivateKeys\": true}'\n\n# Delete permanently.\ncurl -X DELETE https://cyoda.example.com/api/oauth/oidc/providers/${PROVIDER_ID} \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n```\n\n### Reload JWKS for all providers\n\n```bash\ncurl -X POST https://cyoda.example.com/api/oauth/oidc/providers/reload \\\n  -H \"Authorization: Bearer ${ADMIN_TOKEN}\"\n```\n\nForces an immediate JWKS refresh for every active provider on the receiving node. In a multi-node cluster the reload is broadcast.\n\n### Present an IdP-issued JWT\n\nOnce registered, clients present the IdP's JWT directly:\n\n```bash\ncurl -X GET https://cyoda.example.com/api/clients \\\n  -H \"Authorization: Bearer ${IDP_ISSUED_JWT}\"\n```\n\nThe token is validated against the provider's JWKS; roles are read from the configured `rolesClaim`; identity is bound to the tenant that registered the provider."
    },
    {
      "name": "TOKEN",
      "body": "JWTs issued by the federated IdP must conform to the universal cyoda claim contract documented in `auth.tokens`. In particular:\n\n- `iss` must match per the `issuers` / discovery-document rule above.\n- `aud` must match `expectedAudiences` if set.\n- The configured `rolesClaim` (per-provider override or `CYODA_OIDC_ROLES_CLAIM`) is looked up as a literal **top-level** JWT claim name. The value at that key may be any of:\n  1. **JSON array of strings** — `[\"admin\",\"warehouse\"]` → used as-is.\n  2. **JSON object** — `{ \"admin\": {…}, \"warehouse\": {…} }` → roles are the **top-level keys** (`[\"admin\",\"warehouse\"]`); inner values are ignored. This is the shape Zitadel emits for `urn:zitadel:iam:org:project:roles` with `projectRoleAssertion=true`.\n  3. **String** — `\"admin warehouse\"` → split on whitespace per RFC 6749 §3.3 / RFC 8693 §4.2; a lone token `\"admin\"` yields one role.\n\n  Empty / absent / non-collection scalar (number, bool) → no roles, no error (the user is authenticated but unprivileged). Role names containing a comma are dropped silently (cyoda comma-joins roles for downstream serialisation; no major IdP emits commas in role names by convention).\n\n  Common per-IdP values for `rolesClaim`:\n\n```text\n  IdP                              rolesClaim value\n  -------------------------------  -----------------------------------------------\n  Default (no override)            roles\n  Amazon Cognito                   cognito:groups\n  Zitadel (project-wide)           urn:zitadel:iam:org:project:roles\n  Zitadel (per-project)            urn:zitadel:iam:org:project:<projectId>:roles\n  Auth0 (namespaced custom claim)  https://your-app.example.com/roles\n```\n\n  Note: there is no path/dot-walking syntax — whatever string you configure is used as a single literal claim-key lookup against the JWT, so colons, slashes, and dots inside the value are treated as part of the key name (which is exactly what Zitadel, Cognito, and Auth0 need).\n\n  Keycloak's default `realm_access.roles` lives one level deep inside the `realm_access` object, which the no-dot-walking rule means cyoda cannot reach. Configure a Keycloak token mapper (hardcoded-claim or role-list, at realm or client scope) to flatten the role list to a top-level `roles` claim, then point `rolesClaim` at that top-level key.\n\nCyoda does not re-mint federated tokens — they are validated and trusted directly."
    },
    {
      "name": "DIAGNOSTICS",
      "body": "**Every token-validation failure surfaces as `401 UNAUTHORIZED` with a uniform problem-detail body. No precise error code distinguishes audience mismatch, expired token, unknown `kid`, JWKS-unreachable, claims-invalid, or ambiguous-tenant routing.** A precise wire code would enumerate IdP / tenant / kid / claim-shape recognition to an unauthenticated caller, so the wire stays uniform by design.\n\nThe diagnostic path for every failure mode is **server-side logs**, not the HTTP response. Look at `oidc.registry` (KID resolution), `oidc.validator` (claims + signature), and `oidc.discovery` / `oidc.jwks` (transport) events. The bearer-auth middleware emits one structured WARN per failed request with the failure reason slug.\n\nSymptom → cause map for the common cases:\n\n- **\"I get 401 but my token looks valid\":** check `oidc.registry` resolve logs. The most common cause is the provider's JWKS hasn't warmed yet (registration succeeds before discovery completes); force-warm via `/oauth/oidc/providers/reload`.\n- **\"After re-registering, valid tokens are rejected\":** the second-most-common case — two tenants registered the same IdP with overlapping or empty `expectedAudiences`. Internally `ErrAmbiguousProvider` (`internal/auth/oidc/registry.go`) wraps to `ErrUnknownKID`. To resolve, pick disjoint `expectedAudiences` per tenant.\n- **\"Tokens reject for the right tenant but the IdP is up\":** verify the JWT `iss` claim matches either an explicit `issuers` entry or the discovery document's `issuer` byte-for-byte. Trailing slashes count.\n\nRegistration-time failures (these DO carry precise codes — see ERRORS) are SSRF, duplicate URI per tenant, malformed tenant UUID, and provider-not-found / invalid-state for lifecycle ops. Registration succeeds even when the IdP itself is unreachable — discovery failures don't fail the response; they delay the warmup."
    },
    {
      "name": "ERRORS",
      "body": "Registration / lifecycle (precise codes — admin-facing surface):\n\n- `errors.OIDC_INVALID_TENANT` (`400`) — caller's tenant ID is not UUID-shaped (commonly: bootstrap `default-tenant` literal).\n- `errors.OIDC_SSRF_BLOCKED` (`400`) — `wellKnownConfigUri` resolves to a blocked address range (set `CYODA_OIDC_ALLOW_PRIVATE_NETWORKS=true` for dev).\n- `errors.OIDC_PROVIDER_DUPLICATE` (`409`) — same `wellKnownConfigUri` already registered for this tenant.\n- `errors.OIDC_PROVIDER_NOT_FOUND` (`404`) — referenced provider ID absent in this tenant.\n- `errors.OIDC_PROVIDER_INACTIVE` (`409`) — update or operation attempted on an invalidated provider; reactivate first.\n\nToken validation (always opaque — see DIAGNOSTICS):\n\n- `errors.UNAUTHORIZED` (`401`) — every token-validation failure path. The wire body never distinguishes audience mismatch, expired token, unknown `kid`, JWKS-unreachable, claims-invalid, or ambiguous-tenant routing."
    },
    {
      "name": "SEE ALSO",
      "body": "- `auth.tokens` — universal claim contract\n- `config.auth` — `CYODA_OIDC_*` env vars\n- `openapi` — `cyoda help openapi tags` and look for `OAuth, OIDC Providers`"
    }
  ],
  "see_also": [
    "auth",
    "auth.tokens",
    "config.auth",
    "errors.OIDC_INVALID_TENANT",
    "errors.OIDC_PROVIDER_DUPLICATE",
    "errors.OIDC_PROVIDER_NOT_FOUND",
    "errors.OIDC_PROVIDER_INACTIVE",
    "errors.OIDC_SSRF_BLOCKED",
    "errors.UNAUTHORIZED"
  ],
  "stability": "evolving",
  "actions": []
}
