spring-projects / spring-security

Spring Security
http://spring.io/projects/spring-security
Apache License 2.0
8.75k stars 5.87k forks source link

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

Closed heruan closed 1 day ago

heruan commented 7 months ago

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:

https://github.com/spring-projects/spring-security/blob/9c6b5f90f7787404922450d9ec559415c55ec4c3/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java#L246-L248

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

https://github.com/spring-projects/spring-security/blob/9c6b5f90f7787404922450d9ec559415c55ec4c3/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/web/logout/OidcClientInitiatedLogoutSuccessHandler.java#L79-L80

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 https://github.com/spring-projects/spring-security/issues/10309

jzheaux commented 7 months ago

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?

heruan commented 7 months ago

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 commented 6 months ago

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 commented 4 months ago

@jzheaux any further comments on this? It's also being discussed in https://github.com/keycloak/keycloak/issues/24252#issuecomment-2018949211 first and then https://github.com/keycloak/keycloak/issues/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 commented 3 months ago

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

lyca commented 1 month ago

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 commented 1 month ago

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 commented 1 month ago

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 commented 1 month ago

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 commented 1 month ago

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 commented 1 month ago

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 commented 1 month ago

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 commented 3 weeks ago

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 commented 2 weeks ago

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

uyilmaz commented 6 days ago

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 commented 6 days ago

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 commented 1 day ago

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