9
votes

Has anyone successfully configured Spring Boot OAuth2 with ADFS as the identity provider? I followed this tutorial successfully for Facebook, https://spring.io/guides/tutorials/spring-boot-oauth2/, but ADFS doesn't appear to have a userInfoUri. I think ADFS returns the claims data in the token itself (JWT format?), but not sure how to make that work with Spring. Here is what I have so far in my properties file:

security:
  oauth2:
    client:
      clientId: [client id setup with ADFS]
      userAuthorizationUri: https://[adfs domain]/adfs/oauth2/authorize?resource=[MyRelyingPartyTrust]
      accessTokenUri: https://[adfs domain]/adfs/oauth2/token
      tokenName: code
      authenticationScheme: query
      clientAuthenticationScheme: form
      grant-type: authorization_code
    resource:
      userInfoUri: [not sure what to put here?]
4

4 Answers

13
votes

tldr; ADFS embeds user information in the oauth token. You need to create and override the org.springframework.boot.autoconfigure.security.oauth2.resource.UserInfoTokenServices object to extract this information and add it to the Principal object

To get started, first follow the Spring OAuth2 tutorial: https://spring.io/guides/tutorials/spring-boot-oauth2/. Use these application properties (fill in your own domain):

security:
  oauth2:
    client:
      clientId: [client id setup with ADFS]
      userAuthorizationUri: https://[adfs domain]/adfs/oauth2/authorize?resource=[MyRelyingPartyTrust]
      accessTokenUri: https://[adfs domain]/adfs/oauth2/token
      tokenName: code
      authenticationScheme: query
      clientAuthenticationScheme: form
      grant-type: authorization_code
    resource:
      userInfoUri: https://[adfs domain]/adfs/oauth2/token

Note: We will be ignoring whatever is in the userInfoUri, but Spring OAuth2 seems to require something be there.

Create a new class, AdfsUserInfoTokenServices, which you can copy and tweak below (you will want to clean it up some). This is a copy of the Spring class; You could probably extend it if you want, but I made enough changes where that didn't seem like it gained me much:

package edu.bowdoin.oath2sample;

import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.security.oauth2.resource.AuthoritiesExtractor;
import org.springframework.boot.autoconfigure.security.oauth2.resource.FixedAuthoritiesExtractor;
import org.springframework.boot.autoconfigure.security.oauth2.resource.FixedPrincipalExtractor;
import org.springframework.boot.autoconfigure.security.oauth2.resource.PrincipalExtractor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.client.OAuth2RestOperations;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices;
import org.springframework.util.Assert;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

public class AdfsUserInfoTokenServices implements ResourceServerTokenServices {

protected final Logger logger = LoggerFactory.getLogger(getClass());

private final String userInfoEndpointUrl;

private final String clientId;

private String tokenType = DefaultOAuth2AccessToken.BEARER_TYPE;

private AuthoritiesExtractor authoritiesExtractor = new FixedAuthoritiesExtractor();

private PrincipalExtractor principalExtractor = new FixedPrincipalExtractor();

public AdfsUserInfoTokenServices(String userInfoEndpointUrl, String clientId) {
    this.userInfoEndpointUrl = userInfoEndpointUrl;
    this.clientId = clientId;
}

public void setTokenType(String tokenType) {
    this.tokenType = tokenType;
}

public void setRestTemplate(OAuth2RestOperations restTemplate) {
    // not used
}

public void setAuthoritiesExtractor(AuthoritiesExtractor authoritiesExtractor) {
    Assert.notNull(authoritiesExtractor, "AuthoritiesExtractor must not be null");
    this.authoritiesExtractor = authoritiesExtractor;
}

public void setPrincipalExtractor(PrincipalExtractor principalExtractor) {
    Assert.notNull(principalExtractor, "PrincipalExtractor must not be null");
    this.principalExtractor = principalExtractor;
}

@Override
public OAuth2Authentication loadAuthentication(String accessToken)
        throws AuthenticationException, InvalidTokenException {
    Map<String, Object> map = getMap(this.userInfoEndpointUrl, accessToken);
    if (map.containsKey("error")) {
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("userinfo returned error: " + map.get("error"));
        }
        throw new InvalidTokenException(accessToken);
    }
    return extractAuthentication(map);
}

private OAuth2Authentication extractAuthentication(Map<String, Object> map) {
    Object principal = getPrincipal(map);
    List<GrantedAuthority> authorities = this.authoritiesExtractor
            .extractAuthorities(map);
    OAuth2Request request = new OAuth2Request(null, this.clientId, null, true, null,
            null, null, null, null);
    UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
            principal, "N/A", authorities);
    token.setDetails(map);
    return new OAuth2Authentication(request, token);
}

/**
 * Return the principal that should be used for the token. The default implementation
 * delegates to the {@link PrincipalExtractor}.
 * @param map the source map
 * @return the principal or {@literal "unknown"}
 */
protected Object getPrincipal(Map<String, Object> map) {
    Object principal = this.principalExtractor.extractPrincipal(map);
    return (principal == null ? "unknown" : principal);
}

@Override
public OAuth2AccessToken readAccessToken(String accessToken) {
    throw new UnsupportedOperationException("Not supported: read access token");
}

private Map<String, Object> getMap(String path, String accessToken) {
    if (this.logger.isDebugEnabled()) {
        this.logger.debug("Getting user info from: " + path);
    }
    try {
        DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(
                accessToken);
        token.setTokenType(this.tokenType);

        logger.debug("Token value: " + token.getValue());

        String jwtBase64 = token.getValue().split("\\.")[1];

        logger.debug("Token: Encoded JWT: " + jwtBase64);
        logger.debug("Decode: " + Base64.getDecoder().decode(jwtBase64.getBytes()));

        String jwtJson = new String(Base64.getDecoder().decode(jwtBase64.getBytes()));

        ObjectMapper mapper = new ObjectMapper();

        return mapper.readValue(jwtJson, new TypeReference<Map<String, Object>>(){});
    }
    catch (Exception ex) {
        this.logger.warn("Could not fetch user details: " + ex.getClass() + ", "
                + ex.getMessage());
        return Collections.<String, Object>singletonMap("error",
                "Could not fetch user details");
    }
}
}

The getMap method is where the token value is parsed and the JWT formatted user info is extracted and decoded (error checking can be improved here, this is a rough draft, but gives you the gist). See toward the bottom of this link for information on how ADFS embeds data in the token: https://blogs.technet.microsoft.com/askpfeplat/2014/11/02/adfs-deep-dive-comparing-ws-fed-saml-and-oauth/

Add this to your configuration:

@Autowired
private ResourceServerProperties sso;

@Bean
public ResourceServerTokenServices userInfoTokenServices() {
    return new AdfsUserInfoTokenServices(sso.getUserInfoUri(), sso.getClientId());
}

Now follow the first part of these instructions to setup an ADFS client and a relying party trust: https://vcsjones.com/2015/05/04/authenticating-asp-net-5-to-ad-fs-oauth/

You need to add the id of your relying party trust to the properties file userAuthorizationUri as the value of the parameter 'resource'.

Claim Rules:

If you don't want to have to create your own PrincipalExtractor or AuthoritiesExtractor (see the AdfsUserInfoTokenServices code), set whatever attribute you are using for the username (e.g. SAM-Account-Name) so that it has and Outgoing Claim Type 'username'. When creating claim rules for groups, make sure the Claim type is "authorities" (ADFS just let me type that in, there isn't an existing claim type by that name). Otherwise, you can write extractors to work with the ADFS claim types.

Once that is all done, you should have a working example. There are a lot of details here, but once you get it down, it's not too bad (easier than getting SAML to work with ADFS). The key is understanding the way ADFS embeds data in the OAuth2 token and understanding how to use the UserInfoTokenServices object. Hope this helps someone else.

2
votes

Additionally to the accepted answer:

@Ashika wants to know if you can use this with REST instead of form login. Just switch from @EnableOAuth2Sso to @EnableResourceServer annotation.

With the @EnableResourceServer annotation you keep the cabability to use SSO although you didn't use the @EnableOAuth2Sso annotation. Your running as a resource server.

https://docs.spring.io/spring-security-oauth2-boot/docs/current/reference/htmlsingle/#boot-features-security-oauth2-resource-server

2
votes

Although this question is old, there is no other reference on the web on how to integrate Spring OAuth2 with ADFS.

I therefore added a sample project on how to integrate with Microsoft ADFS using the out of the box spring boot auto-configuration for Oauth2 Client:

https://github.com/selvinsource/spring-security/tree/oauth2login-adfs-sample/samples/boot/oauth2login#adfs-login

0
votes

@Erik, This is a very good explanation of how to get things going in terms of using ADFS as both identity and authorization provider. There was on thing I stumbled on was to get "upn" and "email" information in the JWT token. This is the decoded JWT information I received -

2017-07-13 19:43:15.548  INFO 3848 --- [nio-8080-exec-7] c.e.demo.AdfsUserInfoTokenServices       : Decoded JWT: {"aud":"http://localhost:8080/web-app","iss":"http://adfs1.example.com/adfs/services/trust","iat":1500000192,"exp":1500003792,"apptype":"Confidential","appid":"1fd9b444-8ba4-4d82-942e-91aaf79f5fd0","authmethod":"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport","auth_time":"2017-07-14T02:43:12.570Z","ver":"1.0"}

But post adding both email-id and upn in under the "Issuance Transform Rules" and adding "Send LDAP Attributes as Claims" claims rule to send User-Principal-Name as user_id (on of the PRINCIPAL_KETS that FixedPrincipalExtractor - Spring security) I was able to record the user_id being used to login on my UI application. Here is the decoded JWT post adding the claims rule -

2017-07-13 20:16:05.918  INFO 8048 --- [nio-8080-exec-3] c.e.demo.AdfsUserInfoTokenServices       : Decoded JWT: {"aud":"http://localhost:8080/web-app","iss":"http://adfs1.example.com/adfs/services/trust","iat":1500002164,"exp":1500005764,"upn":"[email protected]","apptype":"Confidential","appid":"1fd9b444-8ba4-4d82-942e-91aaf79f5fd0","authmethod":"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport","auth_time":"2017-07-14T03:16:04.745Z","ver":"1.0"}