Skip to content

feat: optionally resolve OIDC provider secrets from file:// and base64:// URIs - #4586

Draft
jasonhernandez wants to merge 1 commit into
ory:masterfrom
jasonhernandez:feat/oidc-client-secret-from-file
Draft

feat: optionally resolve OIDC provider secrets from file:// and base64:// URIs#4586
jasonhernandez wants to merge 1 commit into
ory:masterfrom
jasonhernandez:feat/oidc-client-secret-from-file

Conversation

@jasonhernandez

@jasonhernandez jasonhernandez commented Jul 29, 2026

Copy link
Copy Markdown

Problem

An OIDC provider's client_secret (and apple_private_key) can only be supplied inline, so the credential has to live in the Kratos configuration itself. On Kubernetes that has an awkward consequence: the official Helm chart renders all of kratos.config into a ConfigMap (templates/configmap-config.yaml), so upstream IdP client secrets end up somewhere that

  • is not covered by Kubernetes Secret encryption at rest,
  • is readable by anyone holding get configmap — a far more commonly granted verb than get secret,
  • appears unredacted in kubectl describe, support bundles, and cluster backups.

The chart already routes dsn, secrets.*, and courier.smtp.connection_uri through a Secret + secretKeyRef (templates/secrets.yaml), so the "this value is too sensitive for a ConfigMap" pattern exists — provider secrets just can't participate while inline is the only way to express them.

What this PR does

When the new opt-in setting security.allow_secret_uris_in_oidc_config is enabled, client_secret and apple_private_key may be given as file:// or base64:// URIs — the same indirection mapper_url already supports — and are resolved to the referenced value when the provider is used:

security:
  allow_secret_uris_in_oidc_config: true   # default: false

selfservice:
  methods:
    oidc:
      config:
        providers:
          - id: acme
            provider: generic
            client_id: ...
            client_secret: file:///etc/secrets/oidc/acme
            mapper_url: base64://bG9jYWwg...

On Kubernetes, mounting a Secret via the chart's existing deployment.extraVolumes / extraVolumeMounts keeps the credential out of the ConfigMap entirely — no chart change required.

Design

  • Opt-in, ctx-scoped security setting (default false), following the precedent of security.disallow_ref_in_identity_schemas (40ce7fc). Whoever can write provider configuration also controls token_url, so an always-on file:// resolver would be an arbitrary-file-read-and-exfiltrate primitive wherever provider config is written by untrusted parties (e.g. through an API in multi-tenant deployments). Because the setting is read from ctx, embedders can pin it per project. With the default, URI-shaped values pass through verbatim — exactly today's behavior, so the change is fully backward compatible.
  • Resolution happens in (*Strategy).Provider, on the per-use copy of the provider configuration. Provider enumeration (login/registration/settings form rendering, unlink, linkability checks) does no file I/O, and a broken secret reference only fails flows that actually use that provider — never the whole login screen. The shared configuration collection is never mutated, so resolved plaintext is never cached or written back.
  • Local schemes only, enforced by the fetcher. The fetcher is built once per strategy with fetcher.WithAllowedSchemes("file", "base64"). http(s):// is deliberately not resolved — fetching a credential over the network on the callback path is undesirable — so an https:// value keeps its current meaning (a literal secret).
  • Not cached. The value is re-read each time the provider is used, so rotating the mounted file or Secret takes effect without a restart. Since resolution never runs on render paths, this costs one os.ReadFile per actual OAuth2 exchange.
  • Exactly one trailing newline is trimmed, for file:// sources only — what echo or kubectl create secret --from-file of a newline-terminated file adds. Leading whitespace, trailing spaces, and additional newlines are preserved; base64:// decodes to exact bytes. An empty resolved value is treated as a misconfiguration (an inline empty secret remains legal for public/PKCE clients; this is documented in the schema).
  • Errors never expose the source. The herodot reason names only the provider id and field. The underlying fetcher error (which contains the file path) is logged server-side for the operator and attached via WithWrap only — deliberately not WithDebugf, because herodot serializes the debug field into the response returned by the self-service errors endpoint. The end-to-end test asserts the path appears nowhere in the error body; base64:// payloads are additionally redacted by ory/x/fetcher itself.

Related issue(s)

There is no design document for this, so please treat it as a starting point for discussion — I'm glad to convert it into an issue first if that's the preferred path.

  • Setting up a OIDC secrets via environment variables #1535Setting up OIDC secrets via environment variables. The whole-array env override did not work at the time (2021); it does in current Kratos (configx JSON-decodes env values into arrays — verified against this tree). See below for why this PR still adds value over that route.
  • Provide ability to get the provider.client_secret for OIDC providers from a secret in Kratos k8s#423 — asked for exactly this capability and was closed with the whole-array env-var workaround (SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS from a Secret). That workaround is legitimate but all-or-nothing: every provider's non-secret configuration moves into a single opaque JSON line inside a Secret, overriding just one provider's secret is impossible, and rotation requires a pod restart (env vars are immutable per pod) — where file:// picks up a rotated Secret volume without one.
  • Change kratos ConfigMap to kind Secret k8s#386Change kratos ConfigMap to kind Secret. Closed with "we strongly encourage users to supply sensitive information via a custom secret" — the pattern this PR completes for provider secrets.
  • How to set the `client_secret` in kratos.yml for an OIDC provider? #1186How to set the client_secret in kratos.yml for an OIDC provider? Answered with "write it in your kratos.yml", which is the behavior this change makes optional.
  • 40ce7fcreject file:// $ref in identity schemas — the security-gating pattern this PR follows.

Longer term, this could live in ory/x/configx as a general secret-reference mechanism (schema-annotation driven), so dsn, courier.smtp.connection_uri, and other Ory projects get uniform behavior. This PR is deliberately scoped to the OIDC provider fields; happy to open an issue for the general mechanism.

Checklist

  • I have read the contributing guidelines.
  • I have referenced an issue containing the design document if my change introduces a new feature. — not done, see above; happy to open one first.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have added or changed the documentation. — the config schema title/description/examples are updated here; the ory/docs social sign-in page needs a companion PR.

Testing

UnitTestConfigSecretResolution (provider_config_test.go), observing resolution through Strategy.Provider: file:// and base64:// resolution, single-trailing-newline trim semantics (leading/extra whitespace preserved, base64 untrimmed), inline and unsupported-scheme passthrough, default-off passthrough, missing file, empty resolved value, no path/payload leakage into the error reason or debug, a real PKCS#8 EC key for apple_private_key, no mutation of the shared collection, and pinned blast radius (a broken provider affects neither Config() enumeration nor other providers).

End-to-endTestStrategy/case=client_secret is resolved from a file and reaches the token exchange: a real Hydra client whose secret exists only behind a file:// reference completes a browser registration and an API login through the callback — proving the resolved secret is what reaches the OAuth2 code exchange. A final step deletes the secret file and asserts the login fails with a 500 whose body does not contain the file path.

go test ./selfservice/strategy/oidc/   ok   (incl. Hydra-backed TestStrategy, TestSettingsStrategy, TestPostEndpointRedirect)
go test ./embedx/...                   ok
go test ./driver/config/...            ok

Not addressed here

(*Strategy).Config logs the raw configuration on a decode failure (s.d.Logger().WithError(err).WithField("config", conf) in strategy.go), which writes every client_secret into the application log when decoding fails. That is the mirror image of the error-hygiene work in this PR and is fixed separately in my fix/oidc-config-decode-log branch — happy to fold it in if you'd rather have both together.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f416b526-36a4-4f87-9064-ffa31896f326

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

jasonhernandez added a commit to MaterializeInc/materialize-terraform-self-managed that referenced this pull request Jul 29, 2026
… mounted Secret

The Ory Kratos Helm chart renders all of kratos.config into a ConfigMap, so
every upstream provider's client_secret lands somewhere that is not covered
by Secret encryption at rest and that is readable by anyone holding
`get configmap` in the namespace.

With upstream_oidc_client_secret_from_file = true, the module writes each
provider's client_secret to a Kubernetes Secret, mounts it into the Kratos
pod, and puts a file:// URI in the configuration instead of the credential.

This depends on Kratos resolving file:// for client_secret, which is not in
a release yet (ory/kratos#4586). The variable therefore defaults to false
and the previous inline behaviour is unchanged, so this is safe to merge
ahead of that support landing.

The TLS volume locals are restructured along the way. mergo replaces lists
rather than appending them, so two separate deployment blocks each setting
extraVolumes would have silently dropped one; the volumes are now collected
and concatenated explicitly before a single merge.
@jasonhernandez
jasonhernandez force-pushed the feat/oidc-client-secret-from-file branch from 9626ac7 to 60883e5 Compare July 29, 2026 23:14
@jasonhernandez jasonhernandez changed the title feat: resolve OIDC client_secret from file:// and base64:// URIs feat: optionally resolve OIDC provider secrets from file:// and base64:// URIs Jul 29, 2026
…4:// URIs

An OIDC provider's client_secret could previously only be supplied
inline, forcing the credential into the rendered configuration — on
Kubernetes, the Helm chart's ConfigMap. When the new opt-in setting
security.allow_secret_uris_in_oidc_config is enabled, client_secret
and apple_private_key may instead be given as file:// or base64://
URIs, which are resolved to the referenced value when the provider
is used.

- Resolution is opt-in (default false) via a ctx-scoped security
  setting, following the precedent of
  security.disallow_ref_in_identity_schemas: whoever can write
  provider configuration also controls token_url, so an always-on
  file:// resolver would be an arbitrary-file-read-and-exfiltrate
  primitive in deployments where provider configuration is written
  by untrusted parties. With the default, URI-shaped values pass
  through verbatim, preserving existing behavior.
- Secrets are resolved in Strategy.Provider on the per-use copy of
  the provider configuration: provider enumeration (form rendering,
  unlink, linkability) does no file I/O, a broken reference only
  fails flows that use that provider, and the shared configuration
  collection is never mutated, so resolved plaintext is never
  written back.
- The fetcher is constructed once per strategy with
  WithAllowedSchemes("file", "base64"); remote schemes keep their
  current meaning as literal secrets.
- file:// values are trimmed of exactly one trailing newline (what
  echo or kubectl create secret --from-file adds); base64:// decodes
  to exact bytes. An empty resolved value is a misconfiguration.
- Errors identify only the provider id and field name. The
  underlying fetcher error, which contains the file path, is logged
  for the operator and attached via WithWrap only — not WithDebugf,
  because herodot serializes the debug field into the response
  returned by the self-service errors endpoint.
- Covered by unit tests observing resolution through
  Strategy.Provider (trim semantics, passthrough defaults, error
  hygiene, blast radius, a real PKCS#8 key for apple_private_key)
  and an end-to-end TestStrategy case in which a Hydra client's
  secret exists only behind a file:// reference: browser
  registration and API login complete through the callback, and
  removing the file yields a 500 whose body does not contain the
  path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jasonhernandez
jasonhernandez force-pushed the feat/oidc-client-secret-from-file branch from 60883e5 to 460c949 Compare July 29, 2026 23:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant