Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support different OIDC issuer hostnames for frontend/backend endpoints #14633

Closed
heruan opened this issue Feb 18, 2024 · 17 comments · Fixed by #15716
Closed

Support different OIDC issuer hostnames for frontend/backend endpoints #14633

heruan opened this issue Feb 18, 2024 · 17 comments · Fixed by #15716
Assignees
Labels
in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) status: invalid An issue that we don't feel is valid type: enhancement A general enhancement

Comments

@heruan
Copy link
Contributor

heruan commented Feb 18, 2024

Expected Behavior

When the OIDC provider uses different hostnames from frontend and backend endpoints, fetching metadata from the configure issuer hostname does not fail.

Current Behavior

If the frontend and backend hostnames differs when fetching metadata, a validation exception is thrown.

Context

Consider a Spring application running inside a Kubernetes cluster, and it needs to authenticate against an OIDC server inside the same cluster (say Keycloak). Hostnames used inside the cluster are different from the public ones, and this is supported by Keycloak (frontend and backend hostnames can be different).

The Spring app has this configuration:

spring.security.oauth2.client.provider.keycloak.issuer-uri: http://internal:8180/realms/foo

When fetching metadata from there, Keycloak returns:

{
    "issuer": "https://external/realms/foo",
    "authorization_endpoint": "https://external/realms/foo/protocol/openid-connect/auth",
    "token_endpoint": "http://internal:8180/realms/foo/protocol/openid-connect/token",
    "introspection_endpoint": "http://internal:8180/realms/foo/protocol/openid-connect/token/introspect",
    "userinfo_endpoint": "http://internal:8180/realms/foo/protocol/openid-connect/userinfo",
    "end_session_endpoint": "https://external/realms/foo/protocol/openid-connect/logout",
    "...": "..."
}

As expected, the frontend endpoints use https://external and backend endpoints use http://internal:8180.

The problem is that when fetching metadata, Spring fails here:

Assert.state(issuer.equals(metadataIssuer),
() -> "The Issuer \"" + metadataIssuer + "\" provided in the configuration metadata did "
+ "not match the requested issuer \"" + issuer + "\"");

Fetching metadata from the issuer is necessary since some endpoints are read only from metadata, e.g the end_session_endpoint:

ProviderDetails providerDetails = clientRegistration.getProviderDetails();
Object endSessionEndpoint = providerDetails.getConfigurationMetadata().get("end_session_endpoint");

But also other back-channel endpoints without configuration properties in application.yaml.

Since I know both internal and external hostnames, how can I make Spring Security fetch metadata from the internal issuer hostname and accept the external hostname in metadata?

Note: a similar scenario has been solved for JWT decoders in #10309

@jzheaux
Copy link
Contributor

jzheaux commented Mar 4, 2024

Hi, @heruan! Thanks for the report.

I think the sticky part is the following from the Authorization Server Metadata spec:

The "issuer" value returned MUST be identical to the authorization
server's issuer identifier value into which the well-known URI string
was inserted to create the URL used to retrieve the metadata. If
these values are not identical, the data contained in the response
MUST NOT be used.

Because of that, Spring Security requires they match by default, which generates a question: Are you able to query using the external URI?

If not, the Boot property might not be the best fit for your arrangement. I don't think we want to add something that switches off spec-related validation, but I think it does make sense to add ClientRegistrations#fromMetadata(Map). This would allow you to make your internal request (say with RestTemplate), perform any validation, and then let ClientRegistrations do the rest:

@Bean 
ClientRegistrationRepository clients(RestTemplate rest) {
    Map<String, Object> metadata = rest.getForObject(...);
    // ... validate on your own
    return new InMemoryClientRegistrationRepository(ClientRegistrations.fromMetadata(metadata));
}

Would either of these approaches work for you?

@jzheaux jzheaux added in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) and removed status: waiting-for-triage An issue we've not yet triaged labels Mar 4, 2024
@heruan
Copy link
Contributor Author

heruan commented Mar 6, 2024

Hey @jzheaux thanks for the feedback! I'm not able to query using the external URI, so I'd need another approach. How would the fromMetadata(Map) work with other properties from the Boot configuration, e.g. client-id, client-secret or scope? Take for example this YAML:

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: http://internal:8180/realms/foo
        registration:
          keycloak:
            client-id: my-client
            client-secret: my-secret
            scope:
            - openid

With the current implementation the OAuth2ClientPropertiesMapper merges the metadata fetched from the issuer-uri with the properties in the registration subtree. Would it be possible to hook up your suggested approach in the mapper, so that if a issuer-uri is not provided for a registration there's still a chance to get a builder with provided metadata?

I suppose that would be somewhere around here: OAuth2ClientPropertiesMapper.java#L71-L74

I need this to be configuration based since applications run in a cluster and configuration is provisioned with config-maps so if I add code it should be generic enough to work with different configurations.

@heruan
Copy link
Contributor Author

heruan commented Mar 26, 2024

I've tried different approaches to this without much luck. Only configuring with Boot properties successfully configures everything needed for OIDC to work properly, e.g. scopes, tokens, back-channel logout, etc.

That part of the spec you mentioned looks to be known to be problematic, as it doesn't take into account valid scenarios like the one I described where the app communicates with the issuer with a backend URL. Quarkus provides a way to skip issuer validation in that sense and also Vault is dealing with this.

Would it be acceptable to have a Boot property to specify the issuer value expected from metadata to perform validation?

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: http://internal:8180/realms/foo
            metadata-issuer: https://external/realms/foo

@heruan
Copy link
Contributor Author

heruan commented May 22, 2024

@jzheaux any further comments on this? It's also being discussed in keycloak/keycloak#24252 (comment) first and then keycloak/keycloak#29783 for a Keycloak-specific approach, but I read mentions of the same topic being an issue in Vault and other OIDC based projects.

@chvndb
Copy link

chvndb commented Jun 10, 2024

I'm having the same issue, any solution or workaround for now?

@lyca
Copy link

lyca commented Aug 15, 2024

I'm having the same issue, any solution or workaround for now?

The easiest "workaround" is to not set the issuer-uri at all and instead only configure the jwk-set-uri.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://idp.example.org/.well-known/jwks.json

Without the issuer-uri configured, the demanded check from 4.3. OpenID Provider Configuration Validation would not be made.

The issuer value returned MUST be identical to the Issuer URL that was directly used to retrieve the configuration information.

Also the issuer validation with the JwtIssuerValidator will not be active. If the validation is needed, one could bring it back by configuring the JwtDecoder as a bean.

@vbbonev
Copy link

vbbonev commented Aug 23, 2024

I have the same problem (Spring boot + Keycloak behind nginx reverse proxy -> all in docker containers). The workaround that I use is to set alias on the nginx container (alias=external.com) and from spring --> keycloak.issuer-uri: https://external.com.....

@heruan
Copy link
Contributor Author

heruan commented Aug 31, 2024

I have created PR #15716 to address this with the minimum changes required to support the mentioned scenarios. If it gets merged, additional support for e.g. Spring Boot configuration properties could be discussed.

heruan added a commit to heruan/spring-security that referenced this issue Aug 31, 2024
heruan added a commit to heruan/spring-security that referenced this issue Aug 31, 2024
@heruan
Copy link
Contributor Author

heruan commented Aug 31, 2024

The easiest "workaround" is to not set the issuer-uri at all and instead only configure the jwk-set-uri.

This workaround only applies for a resource server, while for OIDC also other endpoints are fetched from the issuer.

@chvndb
Copy link

chvndb commented Aug 31, 2024

The easiest "workaround" is to not set the issuer-uri at all and instead only configure the jwk-set-uri.

Indeed, this only works for a resource server. The solution of @vbbonev is best when using plain docker containers. I am using Kubernetes, my currentworkaround is to add a rewrite rule to coredns, e.g.:

rewrite name ${issuer-uri} nginx-service.default.svc.cluster.local

I tested this locally and using this on Azure (AKS) in production:

apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns-custom
  namespace: kube-system
data:
    track.override: |
          rewrite name ${issuer-uri} nginx-service.default.svc.cluster.local

Obviously with your issuer-uri provided.

This would not be needed anymore with the PR of @heruan.

@heruan
Copy link
Contributor Author

heruan commented Aug 31, 2024

my currentworkaround is to add a rewrite rule to coredns

This on the other hand can only be done if you have control over the CoreDNS configuration.

@jzheaux
Copy link
Contributor

jzheaux commented Sep 3, 2024

Thanks for all the research and the PR, @heruan. Since there is a fair amount of content here, I'd like to catch up before proceeding to the PR.


Not sure why the Spring client might be verifying the issuer against the actual URL.

There might be some confusion there; the code compares the issuer value to the issuer that formulates the prefix of the URL. It is because of this line in the OIDC spec:

The issuer value returned MUST be identical to the Issuer URL that was used as the prefix to /.well-known/openid-configuration to retrieve the configuration information. This MUST also be identical to the iss Claim value in ID Tokens issued from this Issuer.


I've tried different approaches to this without much luck. Only configuring with Boot properties successfully configures everything needed for OIDC to work properly, e.g. scopes, tokens, back-channel logout, etc.

I'm sorry that this hasn't worked yet for you. The approach I posted doesn't use the Boot properties as stated; however, you could depend on the Boot properties like so:

@Bean 
ClientRegistrationRepository clients(OAuth2ClientProperties clients, RestTemplate rest) {
    Map<String, Object> metadata = rest.getForObject(...);
    // ... validate on your own
    ClientRegistration registration = ClientRegistrations.fromMetadata(metadata);
    // ... set scopes, etc.
    return new InMemoryClientRegistrationRepository(registration);
}

Given the number of votes, I don't mind raising this with the team to see what can be done to simplify this.

@heruan
Copy link
Contributor Author

heruan commented Sep 12, 2024

Thanks @jzheaux for the renewed interest in this! With your latest suggestion I was able to configure my client registration properly adding this method to ClientRegistrations:

public static ClientRegistration.Builder fromOidcConfiguration(Map<String, Object> configuration) {
    OIDCProviderMetadata metadata = parse(configuration, OIDCProviderMetadata::parse);
    ClientRegistration.Builder builder = withProviderConfiguration(metadata, metadata.getIssuer().getValue());
    builder.jwkSetUri(metadata.getJWKSetURI().toASCIIString());
    if (metadata.getUserInfoEndpointURI() != null) {
        builder.userInfoUri(metadata.getUserInfoEndpointURI().toASCIIString());
    }
    return builder;
}

I can update my PR to add this method instead of the current approach with the two different URIs.

heruan added a commit to heruan/spring-security that referenced this issue Sep 20, 2024
ClientRegistrations now provides the fromOidcConfiguration
method to create a ClientRegistration.Builder from a map
representation of an OpenID Provider Configuration Response.

This is useful when the OpenID Provider Configuration is not
available at a well-known location, or if custom validation
is needed for the issuer location (e.g. if the issuer is only
reachable via a back-channel URI that is different from the
issuer value in the configuration).

Fixes: spring-projectsgh-14633
heruan added a commit to heruan/spring-security that referenced this issue Sep 20, 2024
ClientRegistrations now provides the fromOidcConfiguration
method to create a ClientRegistration.Builder from a map
representation of an OpenID Provider Configuration Response.

This is useful when the OpenID Provider Configuration is not
available at a well-known location, or if custom validation
is needed for the issuer location (e.g. if the issuer is only
reachable via a back-channel URI that is different from the
issuer value in the configuration).

Fixes: spring-projectsgh-14633
heruan added a commit to heruan/spring-security that referenced this issue Sep 20, 2024
ClientRegistrations now provides the fromOidcConfiguration
method to create a ClientRegistration.Builder from a map
representation of an OpenID Provider Configuration Response.

This is useful when the OpenID Provider Configuration is not
available at a well-known location, or if custom validation
is needed for the issuer location (e.g. if the issuer is only
reachable via a back-channel URI that is different from the
issuer value in the configuration).

Fixes: spring-projectsgh-14633
@heruan
Copy link
Contributor Author

heruan commented Sep 20, 2024

@jzheaux I have updated the PR so that it now only adds the method you suggested. Hope it helps going forward with this!

@uyilmaz
Copy link

uyilmaz commented Sep 28, 2024

My problem is simpler, my app and keycloak are in the same vpc on aws. Keycloak has a public hostname starting with https so people on the internet can login. SSL is terminated by the AWS application load balancer, so I can't use issuer-uri starting with https because the traffic between my app and Keycloak is on local network. If I use https then spring complains about mismatching url. The only mismatched part is the http(s)

@heruan
Copy link
Contributor Author

heruan commented Sep 28, 2024

My problem is simpler, my app and keycloak are in the same vpc on aws. Keycloak has a public hostname starting with https so people on the internet can login. SSL is terminated by the AWS application load balancer, so I can't use issuer-uri starting with https because the traffic between my app and Keycloak is on local network. If I use https then spring complains about mismatching url. The only mismatched part is the http(s)

Sounds the same issue to me: the front-channel and the back-channel URIs differ, if just for the scheme. Unfortunately the spec does not consider such case either. You should be able to use #15716 and build a ClientRegistration after fetching configuration from your back-channel URI.

@rwinch rwinch self-assigned this Oct 2, 2024
@rwinch rwinch added the status: invalid An issue that we don't feel is valid label Oct 2, 2024
@rwinch
Copy link
Member

rwinch commented Oct 2, 2024

Thanks for creating this ticket I'm closing this in favor of gh-15716

@rwinch rwinch closed this as completed Oct 2, 2024
rwinch pushed a commit that referenced this issue Oct 2, 2024
ClientRegistrations now provides the fromOidcConfiguration
method to create a ClientRegistration.Builder from a map
representation of an OpenID Provider Configuration Response.

This is useful when the OpenID Provider Configuration is not
available at a well-known location, or if custom validation
is needed for the issuer location (e.g. if the issuer is only
reachable via a back-channel URI that is different from the
issuer value in the configuration).

Fixes: gh-14633
malte-laukoetter added a commit to digitalservicebund/ris-norms that referenced this issue Jan 6, 2025
We use a service called localhost to be able to address the keycloak
service as "localhost:8443" in the application.yaml of our application.
Using "keycloak:8443" is not possible as spring would then redirect the
user to "keycloak:8443" to login, but that is a host that is not
available on the host system. The spring boot oauth implementation is
very restrictive on the issuer and therefore creates errors if the hosts
used by it and the user to connect to keycloak differ.

See also:
 - spring-projects/spring-security#14633
 - keycloak/keycloak#29783
 - keycloak/keycloak#24252
 - https://medium.com/@kostapchuk/integrating-keycloak-with-spring-boot-in-a-dockerized-environment-813eab1f140c

RISDEV-5805
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) status: invalid An issue that we don't feel is valid type: enhancement A general enhancement
Projects
None yet
Development

Successfully merging a pull request may close this issue.

7 participants