-
-
Notifications
You must be signed in to change notification settings - Fork 1
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
base: master
Are you sure you want to change the base?
Changes from 3 commits
a3b2998
623b332
d472499
a4cc856
bbc911a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,3 +14,6 @@ themes/base | |
|
||
# OSX files | ||
.DS_Store | ||
|
||
# Maven shade plugin | ||
admin/dependency-reduced-pom.xml |
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()) { | ||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be using an actual string format, |
||
} | ||
|
||
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 + "]"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be the default since the |
||
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; | ||
} | ||
} |
There was a problem hiding this comment.
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 ofc.getValue().isEmpty()
- not sure if it can ever be whitespace though.