Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Keycloak: Authenticate user with a custom JWT

Situation: We use keycloak to authenticate users in our web application (A) through normal browser authentication flow using the JavaScript adapter. This works perfectly!

Goal: Now, a new group of users should be able to access A. But they log in with username and password in a trusted third-party application (B) without Keycloak. In B they have a link to A with a custom JWT (essentially containing username and roles) as a query parameter. So when the user clicks on the link, he lands on our application entry point where we are able to read the JWT out of the URL. What needs to happen now is some sort of token exchange. We want to send this custom JWT to Keycloak, which sends back an access token analog to the normal login process.

Question: Is there built-in support in Keycloak for such a usecase?

Attempts:

I tried to create a confidential client with "Signed JWT" as "Client Authenticator" as suggested in the docs. After some testing I don't think this is the right track, even if the name is promising.

Another track was "Client suggested identity provider" by implementing a custom identity provider. But I don't see, how I can send the JWT within the request.

Currently I'm trying to use the Autentication SPI to extend the authentication flow with a custom authenticator.

Maybe it is much simpler than I think. Can anyone lead me in the right direction?

like image 318
Manuel Avatar asked Feb 06 '18 08:02

Manuel


1 Answers

So I was finally able to solve it with the Authentication SPI mentioned in the question.

In Keycloak, I made a copy of the "browser" authentication flow (since you can not modify built-in flows) and introduced an additional step "Portal JWT" (see picture below). I then bound it to "Browser Flow" in the "Bindings" tab

Custom Authentication Flow

Behind "Portal JWT" is my custom authenticator which extracts the JWT from the query parameter in the redirect uri and parses it to get username and roles out of it. The user is then added to keycloak with a custom attribute "isExternal". Here is an extract of it:

public class JwtAuthenticator implements Authenticator {

private final JwtReader reader;

JwtAuthenticator(JwtReader reader) {
    this.reader = reader;
}

@Override
public void authenticate(AuthenticationFlowContext context) {
    Optional<String> externalCredential = hasExternalCredential(context);
    if (externalCredential.isPresent()) {
        ExternalUser externalUser = reader.read(context.getAuthenticatorConfig(), externalCredential.get());
        String username = externalUser.getUsername();
        UserModel user = context.getSession().users().getUserByUsername(username, context.getRealm());
        if (user == null) {
            user = context.getSession().users().addUser(context.getRealm(), username);
            user.setEnabled(true);
            user.setSingleAttribute("isExternal", "true");
        }
        for (String roleName : externalUser.getRoles()) {
            RoleModel role = context.getRealm().getRole(roleName);
            if (role == null) {
                role = context.getRealm().addRole(roleName);
            }
            user.grantRole(role);
        }
        context.setUser(user);
        context.success();
    } else {
        context.attempted();
    }
}

private Optional<String> hasExternalCredential(AuthenticationFlowContext context) {
    String redirectUri = context.getUriInfo().getQueryParameters().getFirst("redirect_uri);
    try {
        List<NameValuePair> queryParams = URLEncodedUtils.parse(new URI(redirectUri), "UTF-8");
        Optional<NameValuePair> jwtParam = queryParams.stream()
                .filter(nv -> "jwt".equalsIgnoreCase(nv.getName())).findAny();
        if (jwtParam.isPresent()) {
            String jwt = jwtParam.get().getValue();
            if (LOG.isDebugEnabled()) {
                LOG.debug("JWT found: " + jwt);
            }
            return Optional.of(jwt);
        }
    } catch (URISyntaxException e) {
        LOG.error("Redirect URL not as expected: " + redirectUri);
    }
    return Optional.empty();
}
like image 68
Manuel Avatar answered Oct 27 '22 16:10

Manuel