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

[BACK-3125] Add SMART identity provider #30

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ themes/base

# OSX files
.DS_Store

# Maven shade plugin
admin/dependency-reduced-pom.xml
32 changes: 32 additions & 0 deletions admin/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@
</properties>

<dependencies>
<dependency>
<groupId>ca.uhn.hapi.fhir</groupId>
<artifactId>hapi-fhir-client</artifactId>
<version>7.4.5</version>
</dependency>
<dependency>
<groupId>ca.uhn.hapi.fhir</groupId>
<artifactId>hapi-fhir-structures-r4</artifactId>
<version>7.4.5</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
Expand Down Expand Up @@ -62,6 +72,28 @@
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.4.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<include>ca.uhn.hapi.fhir:*</include>
<include>io.opentelemetry:*</include>
<include>org.apache.commons:*</include>
</includes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package org.tidepool.keycloak.extensions.authenticator;

import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.authentication.Authenticator;
import org.keycloak.events.Errors;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.ClientSessionCode;

import jakarta.ws.rs.core.Response;
import java.net.URI;
import java.util.Optional;

public class SMARTIdentityProviderAuthenticator implements Authenticator {

private static final Logger LOG = Logger.getLogger(SMARTIdentityProviderAuthenticator.class);

protected static final String ACCEPTS_PROMPT_NONE = "acceptsPromptNoneForwardFromClient";

public static final String CORRELATION_ID = "correlation_id";

@Override
public void authenticate(AuthenticationFlowContext context) {
String issuer = context.getUriInfo().getQueryParameters().getFirst(OIDCLoginProtocol.ISSUER);
if (issuer == null || issuer.isBlank()) {
LOG.warnf("No issuer %s query parameter provided", OIDCLoginProtocol.ISSUER);
respondWithInvalidRequest(context, OIDCLoginProtocol.ISSUER + "query parameter is required");
return;
}

String correlationId = context.getUriInfo().getQueryParameters().getFirst(CORRELATION_ID);
if (correlationId == null || issuer.isBlank()) {
LOG.warnf("No correlationId %s query parameter provided", CORRELATION_ID);
respondWithInvalidRequest(context, CORRELATION_ID + "query parameter is required");
return;
}

context.getAuthenticationSession().setClientNote(CORRELATION_ID, correlationId);

LOG.infof("Redirecting with correlationId %s: %s set to %s", correlationId, OIDCLoginProtocol.ISSUER, issuer);
redirect(context, issuer);
}

protected void respondWithInvalidRequest(AuthenticationFlowContext context, String errorMessage) {
context.getEvent().error(Errors.IDENTITY_PROVIDER_ERROR);
Response challenge = context.form().setError("Invalid request: " + errorMessage).createErrorPage(Response.Status.BAD_REQUEST);
context.failure(AuthenticationFlowError.IDENTITY_PROVIDER_ERROR, challenge, "Invalid request", errorMessage);
}

protected void redirect(AuthenticationFlowContext context, String issuer) {
Optional<IdentityProviderModel> idp = context.getRealm().getIdentityProvidersStream()
.filter(IdentityProviderModel::isEnabled)
.filter(identityProvider -> identityProvider.getConfig().getOrDefault("issuer", "").equals(issuer))
.findFirst();
if (idp.isPresent()) {
String providerId = idp.get().getProviderId();

String accessCode = new ClientSessionCode<>(context.getSession(), context.getRealm(), context.getAuthenticationSession()).getOrGenerateCode();
String clientId = context.getAuthenticationSession().getClient().getClientId();
String tabId = context.getAuthenticationSession().getTabId();
String clientData = AuthenticationProcessor.getClientData(context.getSession(), context.getAuthenticationSession());
URI location = Urls.identityProviderAuthnRequest(context.getUriInfo().getBaseUri(), providerId, context.getRealm().getName(), accessCode, clientId, tabId, clientData, null);
Response response = Response.seeOther(location)
.build();

// will forward the request to the IDP with prompt=none if the IDP accepts forwards with prompt=none.
if ("none".equals(context.getAuthenticationSession().getClientNote(OIDCLoginProtocol.PROMPT_PARAM)) &&
Boolean.parseBoolean(idp.get().getConfig().get(ACCEPTS_PROMPT_NONE))) {
context.getAuthenticationSession().setAuthNote(AuthenticationProcessor.FORWARDED_PASSIVE_LOGIN, "true");
}

LOG.debugf("Redirecting to %s", providerId);
context.forceChallenge(response);
return;
}

LOG.warnf("Smart issuer %s not found or not enabled for realm", issuer);
context.attempted();
}

@Override
public void action(AuthenticationFlowContext context) {
}

@Override
public boolean requiresUser() {
return false;
}

@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return true;
}

@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
}

@Override
public void close() {
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package org.tidepool.keycloak.extensions.authenticator;

import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;

import java.util.List;

public class SMARTIdentityProviderAuthenticatorFactory implements AuthenticatorFactory {
protected static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.DISABLED
};

public static final String PROVIDER_ID = "smart-identity-provider-redirector";

@Override
public String getDisplayType() {
return "SMART Identity Provider Redirector";
}

@Override
public String getReferenceCategory() {
return null;
}

@Override
public boolean isConfigurable() {
return true;
}

@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}

@Override
public boolean isUserSetupAllowed() {
return true;
}

@Override
public String getHelpText() {
return "Redirects to a SMART Identity Provider specified with issuer query parameter";
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}

@Override
public Authenticator create(KeycloakSession session) {
return new SMARTIdentityProviderAuthenticator();
}

@Override
public void init(Config.Scope config) {
}

@Override
public void postInit(KeycloakSessionFactory factory) {
}

@Override
public void close() {
}

@Override
public String getId() {
return PROVIDER_ID;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.tidepool.keycloak.extensions.broker;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IClientInterceptor;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.interceptor.BearerTokenAuthInterceptor;
import org.keycloak.broker.provider.IdentityBrokerException;

public class FHIRContext {
// Singleton instance - the creation of this object is expensive
private static final FhirContext R4 = FhirContext.forR4();

private FHIRContext() {}

public static FhirContext getR4() {
return R4;
}

public static IGenericClient getFHIRClient(String version, String baseUrl, String accessToken) {
if (!SMARTIdentityProviderFactory.FHIR_R4.equals(version)) {
throw new IdentityBrokerException("Unsupported FHIR Version: " + version);
}

FhirContext ctx = FHIRContext.getR4();
IClientInterceptor authInterceptor = new BearerTokenAuthInterceptor(accessToken);
IGenericClient client = ctx.newRestfulGenericClient(baseUrl);
client.registerInterceptor(authInterceptor);

return client;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package org.tidepool.keycloak.extensions.broker;

import ca.uhn.fhir.parser.IParser;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import org.hl7.fhir.r4.model.ContactPoint;
import org.hl7.fhir.r4.model.Practitioner;
import org.jboss.logging.Logger;
import org.keycloak.broker.oidc.OIDCIdentityProvider;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.models.KeycloakSession;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.JsonWebToken;

import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;

public class SMARTIdentityProvider extends OIDCIdentityProvider {
private static final Logger LOG = Logger.getLogger(SMARTIdentityProvider.class);

public static final String FHIR_VERSION = "smart/fhir_version";
public static final String FHIR_BASE_URL = "smart/fhir_base_url";

private static final String[] DEFAULT_FORWARD_PARAMETERS = {"launch", "aud", "iss"};

private final SMARTIdentityProviderConfig config;

public SMARTIdentityProvider(KeycloakSession session, SMARTIdentityProviderConfig config) {
super(session, discoverConfig(session, config.getIssuer()));

this.config = config;
getConfig().setClientId(config.getClientId());
getConfig().setClientSecret(config.getClientSecret());
getConfig().setDefaultScope(config.getScopes());
getConfig().setAlias(config.getAlias());
getConfig().setForwardParameters(withDefaultForwardParameters(config.getForwardParameters()));
getConfig().setDisableUserInfoService(true);
}

@Override
protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenResponse, String accessToken, JsonWebToken idToken) throws IOException {
BrokeredIdentityContext identity = super.extractIdentity(tokenResponse, accessToken, idToken);

Practitioner practitioner = getPractitioner(idToken.getSubject(), accessToken);
for (ContactPoint c : practitioner.getTelecom()) {
if (c.getSystem() == ContactPoint.ContactPointSystem.EMAIL && c.getValue() != null && !c.getValue().isBlank()) {
identity.setEmail(c.getValue());
break;
}
}

identity.setFirstName(practitioner.getNameFirstRep().getGivenAsSingleString());
identity.setLastName(practitioner.getNameFirstRep().getFamily());

identity.getContextData().put(FHIR_VERSION, config.getFHIRVersion());
identity.getContextData().put(FHIR_BASE_URL, config.getIssuer());

return identity;
}

private Practitioner getPractitioner(String id, String accessToken) {
IGenericClient client = FHIRContext.getFHIRClient(config.getFHIRVersion(), config.getIssuer(), accessToken);
Practitioner practitioner = client.read().resource(Practitioner.class).withId(id).execute();

if (LOG.isTraceEnabled()) {
IParser parser = FHIRContext.getR4().newJsonParser();
LOG.tracef("Retrieved practitioner resource: %s", parser.encodeResourceToString(practitioner));
}

return practitioner;
}

private static String withDefaultForwardParameters(String params){
if (params == null) {
params = "";
}

HashSet<String> set = new HashSet<>(Arrays.asList(params.split(",")));
set.addAll(Arrays.asList(DEFAULT_FORWARD_PARAMETERS));
return String.join(",", set.stream().map(String::trim).toArray(String[]::new));
}

private static OIDCIdentityProviderConfig discoverConfig(KeycloakSession session, String issuer) {
OIDCIdentityProviderFactory factory = new OIDCIdentityProviderFactory();
OIDCIdentityProviderConfig identityProviderConfig = factory.createConfig();

if (issuer == null || issuer.isEmpty()) {
return identityProviderConfig;
}

if (!issuer.endsWith("/")) {
issuer = issuer + "/";
}

String smartConfigurationUrl = issuer + ".well-known/smart-configuration";
SimpleHttp request = SimpleHttp.doGet(smartConfigurationUrl, session).header("Accept", "application/fhir+json");

try {
SimpleHttp.Response response = request.asResponse();
if (response.getStatus() != 200) {
String detail = String.format("Unexpected response %d", response.getStatus());
String tmp = response.asString();
if (tmp != null) detail = tmp;

String msg = String.format("Failed to invoke url [%s]: %s", smartConfigurationUrl, detail) ;

throw new IdentityBrokerException(msg);
}

identityProviderConfig.setConfig(factory.parseConfig(session, response.asString()));
} catch (IOException e) {
throw new IdentityBrokerException("Unable to retrieve smart configuration");
}

return identityProviderConfig;
}
}
Loading