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 3 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,93 @@
package org.tidepool.keycloak.extensions.authenticator;

import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.authentication.Authenticator;
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";

private static final String ISSUER = "iss";

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

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

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,14 @@
package org.tidepool.keycloak.extensions.broker;

import ca.uhn.fhir.context.FhirContext;

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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package org.tidepool.keycloak.extensions.broker;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.parser.IParser;
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.hl7.fhir.r4.model.ContactPoint;
import org.hl7.fhir.r4.model.Patient;
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);

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

public static final String FHIR_R4 = "R4";

private final SMARTIdentityProviderConfig config;

public SMARTIdentityProvider(KeycloakSession session, SMARTIdentityProviderConfig config) {
super(session, discoverConfig(session, config.getIssuer()));
getConfig().setClientId(config.getClientId());
getConfig().setClientSecret(config.getClientSecret());
getConfig().setDefaultScope(config.getScopes());
getConfig().setAlias(config.getAlias());
getConfig().setForwardParameters(withDefaultForwardParameters(config.getForwardParameters()));
getConfig().setDisableUserInfoService(true);

this.config = config;
}

@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().isEmpty()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use c.getValue().isBlank() instead of c.getValue().isEmpty() - not sure if it can ever be whitespace though.

identity.setEmail(c.getValue());
break;
}
}

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

return identity;
}

private Practitioner getPractitioner(String id, String accessToken) {
if (!FHIR_R4.equals(config.getFHIRVersion())) {
throw new IdentityBrokerException("Unsupported FHIR Version: " + config.getFHIRVersion());
}

FhirContext ctx = FHIRContext.getR4();
IClientInterceptor authInterceptor = new BearerTokenAuthInterceptor(accessToken);
IGenericClient client = ctx.newRestfulGenericClient(config.getIssuer());
client.registerInterceptor(authInterceptor);
Practitioner practitioner = client.read().resource(Practitioner.class).withId(id).execute();

if (LOG.isTraceEnabled()) {
IParser parser = ctx.newJsonParser();
String serialized = parser.encodeResourceToString(practitioner);
LOG.tracef("Retrieved practitioner resource: " + serialized);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be using an actual string format, LOG.tracef("....%s", serialized) ?

}

return practitioner;
}

private Patient getPatient(String id, String accessToken) {
if (!FHIR_R4.equals(config.getFHIRVersion())) {
throw new IdentityBrokerException("Unsupported FHIR Version: " + config.getFHIRVersion());
}

FhirContext ctx = FHIRContext.getR4();
IClientInterceptor authInterceptor = new BearerTokenAuthInterceptor(accessToken);
IGenericClient client = ctx.newRestfulGenericClient(config.getIssuer());
return client.read().resource(Patient.class).withId(id).execute();
}

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

HashSet<String> set = new HashSet<>(Arrays.asList(params.split(",")));
set.addAll(Arrays.asList(defaultForwardParameters));
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 msg = "failed to invoke url [" + smartConfigurationUrl + "]";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be the default since the throw new IdentityBrokerException("Failed to invoke url [" + smartConfigurationUrl + "]: " + msg); would produce something like throw new IdentityBrokerException("Failed to invoke url [" + smartConfigurationUrl + "]: " + "failed to invoke url [" + smartConfigurationUrl + "]") - seems like it's just repeating it at that point.

String tmp = response.asString();
if (tmp != null) msg = tmp;

throw new IdentityBrokerException("Failed to invoke url [" + smartConfigurationUrl + "]: " + msg);
}

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

return identityProviderConfig;
}
}
Loading