refactor: 💡 Refactored GA4GH Passports and visas

Refactored the GA4GH claim source and related things to be extensible
for other implementations.
Configuration changes:
Elixir GA4GH claim source class needs to be updated to:`custom.claim.[claimName].source.class=cz.muni.ics.oidc.server.ga4gh.ElixirGa4ghClaimSource`
Elixir Access token modifier has been moved and has to be configured as: `accessTokenClaimsModifier=cz.muni.ics.oidc.server.ga4gh.Ga4ghAccessTokenModifier`

BREAKING CHANGE: 🧨 Ga4gh Claim source class for ELIXIR has been changed. Also, the
ElixirAccessTokenModifier class has been moved and renamed.
pull/1580/head
Dominik Frantisek Bucik 2021-12-02 07:46:41 +01:00
parent fe36808016
commit a94fd992dd
No known key found for this signature in database
GPG Key ID: 25014C8DB2E7E62D
13 changed files with 943 additions and 629 deletions

View File

@ -1,5 +1,5 @@
<%@ tag pageEncoding="UTF-8" trimDirectiveWhitespaces="true"
import="cz.muni.ics.oidc.server.elixir.GA4GHClaimSource" %>
import="cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportAndVisaClaimSource" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="o" tagdir="/WEB-INF/tags" %>
@ -53,7 +53,7 @@
<c:forEach var="subValue" items="${claim.value}">
<c:choose>
<c:when test="${claim.key=='ga4gh_passport_v1'}">
<li><%= GA4GHClaimSource.parseAndVerifyVisa(
<li><%= Ga4ghPassportAndVisaClaimSource.parseAndVerifyVisa(
(String) jspContext.findAttribute("subValue")).getPrettyString() %></li>
</c:when>
<c:otherwise>

View File

@ -1,4 +1,4 @@
<%@ page import="cz.muni.ics.oidc.server.elixir.GA4GHClaimSource" %>
<%@ page import="cz.muni.ics.oidc.server.ga4gh.ElixirGa4ghClaimSource" %>
<%@ page import="java.util.ArrayList" %>
<%@ page import="java.util.List" %>
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" trimDirectiveWhitespaces="true"%>

View File

@ -1,4 +1,4 @@
package cz.muni.ics.oidc.server.elixir;
package cz.muni.ics.oidc.server;
import java.io.IOException;
import org.springframework.http.HttpRequest;
@ -12,12 +12,12 @@ import org.springframework.http.client.ClientHttpResponse;
*
* @author Martin Kuba <makub@ics.muni.cz>
*/
class AddHeaderInterceptor implements ClientHttpRequestInterceptor {
public class AddHeaderInterceptor implements ClientHttpRequestInterceptor {
private final String header;
private final String value;
AddHeaderInterceptor(String header, String value) {
public AddHeaderInterceptor(String header, String value) {
this.header = header;
this.value = value;
}

View File

@ -41,6 +41,17 @@ public class ClaimSourceInitContext {
return properties.getProperty(propertyPrefix + "." + suffix, defaultValue);
}
public Long getLongProperty(String suffix, Long defaultValue) {
String propKey = propertyPrefix + '.' + suffix;
String prop = properties.getProperty(propertyPrefix + "." + suffix);
try {
return Long.parseLong(prop);
} catch (NumberFormatException e) {
log.warn("Could not parse value '{}' for property '{}' as Long", prop, propKey);
}
return defaultValue;
}
public JWTSigningAndValidationService getJwtService() {
return jwtService;
}

View File

@ -1,591 +0,0 @@
package cz.muni.ics.oidc.server.elixir;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKMatcher;
import com.nimbusds.jose.jwk.JWKSelector;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.JWTParser;
import com.nimbusds.jwt.SignedJWT;
import cz.muni.ics.jwt.signer.service.JWTSigningAndValidationService;
import cz.muni.ics.oidc.models.PerunAttribute;
import cz.muni.ics.oidc.server.claims.ClaimSource;
import cz.muni.ics.oidc.server.claims.ClaimSourceInitContext;
import cz.muni.ics.oidc.server.claims.ClaimSourceProduceContext;
import cz.muni.ics.oidc.server.connectors.Affiliation;
import cz.muni.ics.openid.connect.web.JWKSetPublishingEndpoint;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLEncoder;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.client.InterceptingClientHttpRequestFactory;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
/**
* Class producing GA4GH Passport claim. The claim is specified in
* https://bit.ly/ga4gh-passport-v1
*
* Configuration (replace [claimName] with the name of the claim):
* <ul>
* <li><b>custom.claim.[claimName].source.config_file</b> - full path to the configuration file for this claim. See
* configuration templates for such a file.</li>
* <li><b>custom.claim.[claimName].source.bonaFideStatus.attr</b> - mapping for bonaFideStatus Attriute</li>
* <li><b>custom.claim.[claimName].source.bonaFideStatusREMS.attr</b> - mapping for bonaFideStatus Attriute</li>
* <li><b>custom.claim.[claimName].source.groupAffiliations.attr</b> - mapping for groupAffiliations Attriute</li>
* </ul>
*
* @author Martin Kuba <makub@ics.muni.cz>
*/
@Slf4j
public class GA4GHClaimSource extends ClaimSource {
static final String GA4GH_SCOPE = "ga4gh_passport_v1";
private static final String GA4GH_CLAIM = "ga4gh_passport_v1";
private static final String BONA_FIDE_URL = "https://doi.org/10.1038/s41431-018-0219-y";
private static final String ELIXIR_ORG_URL = "https://elixir-europe.org/";
private static final String ELIXIR_ID = "elixir_id";
private final JWTSigningAndValidationService jwtService;
private final URI jku;
private final String issuer;
private static final List<ClaimRepository> claimRepositories = new ArrayList<>();
private static final Map<URI, RemoteJWKSet<SecurityContext>> remoteJwkSets = new HashMap<>();
private static final Map<URI, String> signers = new HashMap<>();
private final String bonaFideStatusAttr;
private final String bonaFideStatusREMSAttr;
private final String groupAffiliationsAttr;
public GA4GHClaimSource(ClaimSourceInitContext ctx) throws URISyntaxException {
super(ctx);
log.debug("initializing");
//remember context
jwtService = ctx.getJwtService();
issuer = ctx.getPerunOidcConfig().getConfigBean().getIssuer();
jku = new URI(issuer + JWKSetPublishingEndpoint.URL);
// load config file
parseConfigFile(ctx.getProperty("config_file", "/etc/mitreid/elixir/ga4gh_config.yml"));
bonaFideStatusAttr = ctx.getProperty("bonaFideStatus.attr", null);
bonaFideStatusREMSAttr = ctx.getProperty("bonaFideStatusREMS.attr", null);
groupAffiliationsAttr = ctx.getProperty("groupAffiliations.attr", null);
}
static void parseConfigFile(String file) {
YAMLMapper mapper = new YAMLMapper();
try {
JsonNode root = mapper.readValue(new File(file), JsonNode.class);
// prepare claim repositories
for (JsonNode repo : root.path("repos")) {
String name = repo.path("name").asText();
String actionURL = repo.path("url").asText();
JsonNode headers = repo.path("headers");
Map<String, String> headersWithValues = new HashMap<>();
for (JsonNode header: headers) {
headersWithValues.put(header.path("header").asText(), header.path("value").asText());
}
if (actionURL == null || headersWithValues.isEmpty()) {
log.error("claim repository " + repo + " not defined with url|auth_header|auth_value ");
continue;
}
RestTemplate restTemplate = new RestTemplate();
restTemplate.setRequestFactory(
new InterceptingClientHttpRequestFactory(restTemplate.getRequestFactory(),
headersWithValues.entrySet()
.stream()
.map(e -> new AddHeaderInterceptor(e.getKey(), e.getValue()))
.collect(Collectors.toList()))
);
claimRepositories.add(new ClaimRepository(name, restTemplate, actionURL));
log.info("GA4GH Claims Repository " + name + " configured at " + actionURL);
}
// prepare claim signers
for (JsonNode signer : root.path("signers")) {
String name = signer.path("name").asText();
String jwks = signer.path("jwks").asText();
try {
URL jku = new URL(jwks);
remoteJwkSets.put(jku.toURI(), new RemoteJWKSet<>(jku));
signers.put(jku.toURI(), name);
log.info("JWKS Signer " + name + " added with keys " + jwks);
} catch (MalformedURLException | URISyntaxException e) {
log.error("cannot add to RemoteJWKSet map: " + name + " " + jwks, e);
}
}
} catch (IOException ex) {
log.error("cannot read GA4GH config file", ex);
}
}
@Override
public Set<String> getAttrIdentifiers() {
Set<String> set = new HashSet<>();
if (bonaFideStatusAttr != null) {
set.add(bonaFideStatusAttr);
}
if (bonaFideStatusREMSAttr != null) {
set.add(bonaFideStatusREMSAttr);
}
if (groupAffiliationsAttr != null) {
set.add(groupAffiliationsAttr);
}
return set;
}
@Override
public JsonNode produceValue(ClaimSourceProduceContext pctx) {
if (pctx.getClient() == null) {
log.debug("client is not set");
return JsonNodeFactory.instance.textNode("Global Alliance For Genomic Health structured claim");
}
if (!pctx.getClient().getScope().contains(GA4GH_SCOPE)) {
log.debug("Client '{}' does not have scope ga4gh", pctx.getClient().getClientName());
return null;
}
List<Affiliation> affiliations = pctx.getPerunAdapter()
.getAdapterRpc()
.getUserExtSourcesAffiliations(pctx.getPerunUserId());
ArrayNode ga4gh_passport_v1 = JsonNodeFactory.instance.arrayNode();
long now = Instant.now().getEpochSecond();
addAffiliationAndRoles(now, pctx, ga4gh_passport_v1, affiliations);
addAcceptedTermsAndPolicies(now, pctx, ga4gh_passport_v1);
addResearcherStatuses(now, pctx, ga4gh_passport_v1, affiliations);
addControlledAccessGrants(now, pctx, ga4gh_passport_v1);
return ga4gh_passport_v1;
}
private void addAffiliationAndRoles(long now, ClaimSourceProduceContext pctx, ArrayNode passport, List<Affiliation> affiliations) {
//by=system for users with affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
for (Affiliation affiliation : affiliations) {
//expires 1 year after the last login from the IdP asserting the affiliation
long expires = Instant.ofEpochSecond(affiliation.getAsserted()).atZone(ZoneId.systemDefault()).plusYears(1L).toEpochSecond();
if (expires < now) continue;
JsonNode visa = createPassportVisa("AffiliationAndRole", pctx, affiliation.getValue(), affiliation.getSource(), "system", affiliation.getAsserted(), expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
private void addAcceptedTermsAndPolicies(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
//by=self for members of the group 10432 "Bona Fide Researchers"
boolean userInGroup = pctx.getPerunAdapter().isUserInGroup(pctx.getPerunUserId(), 10432L);
if (userInGroup) {
PerunAttribute bonaFideStatus = pctx.getPerunAdapter()
.getAdapterRpc()
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusAttr);
String valueCreatedAt = bonaFideStatus.getValueCreatedAt();
long asserted;
if (valueCreatedAt != null) {
asserted = Timestamp.valueOf(valueCreatedAt).getTime() / 1000L;
} else {
asserted = System.currentTimeMillis() / 1000L;
}
long expires = Instant.ofEpochSecond(asserted).atZone(ZoneId.systemDefault()).plusYears(100L).toEpochSecond();
if (expires < now) return;
JsonNode visa = createPassportVisa("AcceptedTermsAndPolicies", pctx, BONA_FIDE_URL, ELIXIR_ORG_URL, "self", asserted, expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
private void addResearcherStatuses(long now, ClaimSourceProduceContext pctx, ArrayNode passport, List<Affiliation> affiliations) {
//by=peer for users with attribute elixirBonaFideStatusREMS
PerunAttribute elixirBonaFideStatusREMS = pctx.getPerunAdapter()
.getAdapterRpc()
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusREMSAttr);
String valueCreatedAt = null;
if (elixirBonaFideStatusREMS != null) {
valueCreatedAt = elixirBonaFideStatusREMS.getValueCreatedAt();
}
if (valueCreatedAt != null) {
long asserted = Timestamp.valueOf(valueCreatedAt).getTime() / 1000L;
long expires = ZonedDateTime.now().plusYears(1L).toEpochSecond();
if (expires > now) {
JsonNode visa = createPassportVisa("ResearcherStatus", pctx, BONA_FIDE_URL, ELIXIR_ORG_URL, "peer", asserted, expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
//by=system for users with faculty affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
for (Affiliation affiliation : affiliations) {
if (affiliation.getValue().startsWith("faculty@")) {
long expires = Instant.ofEpochSecond(affiliation.getAsserted()).atZone(ZoneId.systemDefault()).plusYears(1L).toEpochSecond();
if (expires < now) continue;
JsonNode visa = createPassportVisa("ResearcherStatus", pctx, BONA_FIDE_URL, affiliation.getSource(), "system", affiliation.getAsserted(), expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
//by=so for users with faculty affiliation asserted by membership in a group with groupAffiliations attribute
for (Affiliation affiliation : pctx.getPerunAdapter().getGroupAffiliations(pctx.getPerunUserId(), groupAffiliationsAttr)) {
if (affiliation.getValue().startsWith("faculty@")) {
long expires = ZonedDateTime.now().plusYears(1L).toEpochSecond();
JsonNode visa = createPassportVisa("ResearcherStatus", pctx, BONA_FIDE_URL, ELIXIR_ORG_URL, "so", affiliation.getAsserted(), expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
}
private static String isoDate(long linuxTime) {
return DateTimeFormatter.ISO_LOCAL_DATE.format(ZonedDateTime.ofInstant(Instant.ofEpochSecond(linuxTime), ZoneId.systemDefault()));
}
private static String isoDateTime(long linuxTime) {
return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(ZonedDateTime.ofInstant(Instant.ofEpochSecond(linuxTime), ZoneId.systemDefault()));
}
private JsonNode createPassportVisa(String type, ClaimSourceProduceContext pctx, String value, String source, String by, long asserted, long expires, JsonNode condition) {
long now = System.currentTimeMillis() / 1000L;
if (asserted > now) {
log.warn("visa asserted in future ! perunUserId {} sub {} type {} value {} source {} by {} asserted {}", pctx.getPerunUserId(), pctx.getSub(), type, value, source, by, Instant.ofEpochSecond(asserted));
return null;
}
if (expires <= now) {
log.warn("visa already expired ! perunUserId {} sub {} type {} value {} source {} by {} expired {}", pctx.getPerunUserId(), pctx.getSub(), type, value, source, by, Instant.ofEpochSecond(expires));
return null;
}
Map<String, Object> passportVisaObject = new HashMap<>();
passportVisaObject.put("type", type);
passportVisaObject.put("asserted", asserted);
passportVisaObject.put("value", value);
passportVisaObject.put("source", source);
passportVisaObject.put("by", by);
if (condition != null && !condition.isNull() && !condition.isMissingNode()) {
passportVisaObject.put("condition", condition);
}
JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.parse(jwtService.getDefaultSigningAlgorithm().getName()))
.keyID(jwtService.getDefaultSignerKeyId())
.type(JOSEObjectType.JWT)
.jwkURL(jku)
.build();
JWTClaimsSet jwtClaimsSet = new JWTClaimsSet.Builder()
.issuer(issuer)
.issueTime(new Date())
.expirationTime(new Date(expires * 1000L))
.subject(pctx.getSub())
.jwtID(UUID.randomUUID().toString())
.claim("ga4gh_visa_v1", passportVisaObject)
.build();
SignedJWT myToken = new SignedJWT(jwsHeader, jwtClaimsSet);
jwtService.signJwt(myToken);
return JsonNodeFactory.instance.textNode(myToken.serialize());
}
private void addControlledAccessGrants(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
Set<String> linkedIdentities = new HashSet<>();
//call Resource Entitlement Management System
for (ClaimRepository repo : claimRepositories) {
callPermissionsJwtAPI(repo, Collections.singletonMap(ELIXIR_ID, pctx.getSub()), pctx, passport, linkedIdentities);
}
if (!linkedIdentities.isEmpty()) {
for (String linkedIdentity : linkedIdentities) {
JsonNode visa = createPassportVisa("LinkedIdentities", pctx, linkedIdentity, ELIXIR_ORG_URL, "system", now, now + 3600L * 24 * 365, null);
if (visa != null) {
passport.add(visa);
}
}
}
}
private void callPermissionsJwtAPI(ClaimRepository repo, Map<String, String> uriVariables, ClaimSourceProduceContext pctx, ArrayNode passport, Set<String> linkedIdentities) {
JsonNode response = callHttpJsonAPI(repo, uriVariables);
if (response != null) {
JsonNode visas = response.path(GA4GH_CLAIM);
if (visas.isArray()) {
for (JsonNode visaNode : visas) {
if (visaNode.isTextual()) {
PassportVisa visa = parseAndVerifyVisa(visaNode.asText());
if (visa.isVerified()) {
log.debug("adding a visa to passport: {}", visa);
passport.add(passport.textNode(visa.getJwt()));
linkedIdentities.add(visa.getLinkedIdentity());
} else {
log.warn("skipping visa: {}", visa);
}
} else {
log.warn("element of ga4gh_passport_v1 is not a String: {}", visaNode);
}
}
} else {
log.warn("ga4gh_passport_v1 is not an array in {}", response);
}
}
}
public static PassportVisa parseAndVerifyVisa(String jwtString) {
PassportVisa visa = new PassportVisa(jwtString);
try {
SignedJWT signedJWT = (SignedJWT) JWTParser.parse(jwtString);
URI jku = signedJWT.getHeader().getJWKURL();
if (jku == null) {
log.error("JKU is missing in JWT header");
return visa;
}
visa.setSigner(signers.get(jku));
RemoteJWKSet<SecurityContext> remoteJWKSet = remoteJwkSets.get(jku);
if (remoteJWKSet == null) {
log.error("JKU {} is not among trusted key sets", jku);
return visa;
}
List<JWK> keys = remoteJWKSet.get(new JWKSelector(new JWKMatcher.Builder().keyID(signedJWT.getHeader().getKeyID()).build()), null);
RSASSAVerifier verifier = new RSASSAVerifier(((RSAKey) keys.get(0)).toRSAPublicKey());
visa.setVerified(signedJWT.verify(verifier));
if (visa.isVerified()) {
processPayload(visa, signedJWT.getPayload());
}
} catch (Exception ex) {
log.error("visa " + jwtString + " cannot be parsed and verified", ex);
}
return visa;
}
static private final ObjectMapper JSON_MAPPER = new ObjectMapper();
static private void processPayload(PassportVisa visa, Payload payload) throws IOException {
JsonNode doc = JSON_MAPPER.readValue(payload.toString(), JsonNode.class);
checkVisaKey(visa, doc, "sub");
checkVisaKey(visa, doc, "exp");
checkVisaKey(visa, doc, "iss");
JsonNode visa_v1 = doc.path("ga4gh_visa_v1");
checkVisaKey(visa, visa_v1, "type");
checkVisaKey(visa, visa_v1, "asserted");
checkVisaKey(visa, visa_v1, "value");
checkVisaKey(visa, visa_v1, "source");
checkVisaKey(visa, visa_v1, "by");
if (!visa.isVerified()) return;
long exp = doc.get("exp").asLong();
if (exp < Instant.now().getEpochSecond()) {
log.warn("visa expired on " + isoDateTime(exp));
visa.setVerified(false);
return;
}
visa.setLinkedIdentity(URLEncoder.encode(doc.get("sub").asText(), "utf-8") + "," + URLEncoder.encode(doc.get("iss").asText(), "utf-8"));
visa.setPrettyPayload(
visa_v1.get("type").asText() + ": \"" + visa_v1.get("value").asText() + "\" asserted " + isoDate(visa_v1.get("asserted").asLong())
);
}
static private void checkVisaKey(PassportVisa visa, JsonNode jsonNode, String key) {
if (jsonNode.path(key).isMissingNode()) {
log.warn(key + " is missing");
visa.setVerified(false);
} else {
switch (key) {
case "sub":
visa.setSub(jsonNode.path(key).asText());
break;
case "iss":
visa.setIss(jsonNode.path(key).asText());
break;
case "type":
visa.setType(jsonNode.path(key).asText());
break;
case "value":
visa.setValue(jsonNode.path(key).asText());
break;
}
}
}
@SuppressWarnings("Duplicates")
private static JsonNode callHttpJsonAPI(ClaimRepository repo, Map<String, String> uriVariables) {
//get permissions data
try {
JsonNode result;
//make the call
try {
if (log.isDebugEnabled()) {
log.debug("calling Permissions API at {}", repo.getRestTemplate().getUriTemplateHandler().expand(repo.getActionURL(), uriVariables));
}
result = repo.getRestTemplate().getForObject(repo.getActionURL(), JsonNode.class, uriVariables);
} catch (HttpClientErrorException ex) {
MediaType contentType = ex.getResponseHeaders().getContentType();
String body = ex.getResponseBodyAsString();
log.error("HTTP ERROR " + ex.getRawStatusCode() + " URL " + repo.getActionURL() + " Content-Type: " + contentType);
if (ex.getRawStatusCode() == 404) {
log.warn("Got status 404 from Permissions endpoint {}, ELIXIR AAI user is not linked to user at Permissions API", repo.getActionURL());
return null;
}
if ("json".equals(contentType.getSubtype())) {
try {
log.error(new ObjectMapper().readValue(body, JsonNode.class).path("message").asText());
} catch (IOException e) {
log.error("cannot parse error message from JSON", e);
}
} else {
log.error("cannot make REST call, exception: {} message: {}", ex.getClass().getName(), ex.getMessage());
}
return null;
}
log.debug("Permissions API response: {}", result);
return result;
} catch (Exception ex) {
log.error("Cannot get dataset permissions", ex);
}
return null;
}
public static class PassportVisa {
String jwt;
boolean verified = false;
String linkedIdentity;
String signer;
String prettyPayload;
private String sub;
private String iss;
private String type;
private String value;
PassportVisa(String jwt) {
this.jwt = jwt;
}
public String getJwt() {
return jwt;
}
public boolean isVerified() {
return verified;
}
void setVerified(boolean verified) {
this.verified = verified;
}
String getLinkedIdentity() {
return linkedIdentity;
}
void setLinkedIdentity(String linkedIdentity) {
this.linkedIdentity = linkedIdentity;
}
void setSigner(String signer) {
this.signer = signer;
}
void setPrettyPayload(String prettyPayload) {
this.prettyPayload = prettyPayload;
}
public String getPrettyString() {
return prettyPayload + ", signed by " + signer;
}
@Override
public String toString() {
return "PassportVisa{" +
// "jwt='" + jwt + '\'' +
" type=" + type +
", sub=" + sub +
", iss=" + iss +
", value=" + value +
", verified=" + verified +
", linkedIdentity=" + linkedIdentity +
'}';
}
public void setSub(String sub) {
this.sub = sub;
}
public String getSub() {
return sub;
}
public void setIss(String iss) {
this.iss = iss;
}
public String getIss() {
return iss;
}
public void setType(String type) {
this.type = type;
}
public String getType() {
return type;
}
public void setValue(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
public static class ClaimRepository {
private final String name;
private final RestTemplate restTemplate;
private final String actionURL;
public ClaimRepository(String name, RestTemplate restTemplate, String actionURL) {
this.name = name;
this.restTemplate = restTemplate;
this.actionURL = actionURL;
}
public RestTemplate getRestTemplate() {
return restTemplate;
}
public String getActionURL() {
return actionURL;
}
public String getName() {
return name;
}
}
}

View File

@ -0,0 +1,184 @@
package cz.muni.ics.oidc.server.bbmri;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_PEER;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SELF;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SO;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SYSTEM;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_ACCEPTED_TERMS_AND_POLICIES;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_AFFILIATION_AND_ROLE;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_LINKED_IDENTITIES;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_RESEARCHER_STATUS;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import cz.muni.ics.oidc.models.PerunAttribute;
import cz.muni.ics.oidc.server.claims.ClaimSourceInitContext;
import cz.muni.ics.oidc.server.claims.ClaimSourceProduceContext;
import cz.muni.ics.oidc.server.connectors.Affiliation;
import cz.muni.ics.oidc.server.ga4gh.Ga4ghClaimRepository;
import cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportAndVisaClaimSource;
import java.net.URISyntaxException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
/**
* Class producing GA4GH Passport claim. The claim is specified in
* https://bit.ly/ga4gh-passport-v1
*
* Configuration (replace [claimName] with the name of the claim):
* <ul>
* <li><b>custom.claim.[claimName].source.config_file</b> - full path to the configuration file for this claim. See
* configuration templates for such a file.</li> (Passed to Ga4ghPassportAndVisaClaimSource.class)
* <li><b>custom.claim.[claimName].source.bonaFideStatus.attr</b> - mapping for bonaFideStatus Attribute</li>
* <li><b>custom.claim.[claimName].source.groupAffiliations.attr</b> - mapping for groupAffiliations Attribute</li>
* <li><b>custom.claim.[claimName].source.termsAndPoliciesGroupId</b> - ID of group in which the membership represents acceptance of terms and policies</li>
* </ul>
*
* @author Martin Kuba <makub@ics.muni.cz>
*/
@Slf4j
public class BbmriGa4ghClaimSource extends Ga4ghPassportAndVisaClaimSource {
private static final String BONA_FIDE_URL = "https://doi.org/10.1038/s41431-018-0219-y";
private final static String BBMRI_ERIC_ORG_URL = "https://www.bbmri-eric.eu/";
private static final String BBMRI_ID = "bbmri_id";
private final String bonaFideStatusAttr;
private final String groupAffiliationsAttr;
private final Long termsAndPoliciesGroupId;
public BbmriGa4ghClaimSource(ClaimSourceInitContext ctx) throws URISyntaxException {
super(ctx, "BBMRI-ERIC");
log.debug("initializing");
//remember context
bonaFideStatusAttr = ctx.getProperty("bonaFideStatus.attr", null);
groupAffiliationsAttr = ctx.getProperty("groupAffiliations.attr", null);
//TODO: update group ID
termsAndPoliciesGroupId = ctx.getLongProperty("termsAndPoliciesGroupId", 10432L);
}
@Override
public Set<String> getAttrIdentifiers() {
Set<String> set = new HashSet<>();
if (bonaFideStatusAttr != null) {
set.add(bonaFideStatusAttr);
}
if (groupAffiliationsAttr != null) {
set.add(groupAffiliationsAttr);
}
return set;
}
@Override
protected String getDefaultConfigFilePath() {
return "/etc/mitreid/bbmri/ga4gh_config.yml";
}
@Override
protected void addAffiliationAndRoles(long now, ClaimSourceProduceContext pctx, ArrayNode passport, List<Affiliation> affiliations) {
//by=system for users with affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
for (Affiliation affiliation : affiliations) {
//expires 1 year after the last login from the IdP asserting the affiliation
long expires = Instant.ofEpochSecond(affiliation.getAsserted()).atZone(ZoneId.systemDefault()).plusYears(1L).toEpochSecond();
if (expires < now) continue;
JsonNode visa = createPassportVisa(TYPE_AFFILIATION_AND_ROLE, pctx, affiliation.getValue(), affiliation.getSource(), BY_SYSTEM, affiliation.getAsserted(), expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
@Override
protected void addAcceptedTermsAndPolicies(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
//by=self for members of the group 10432 "Bona Fide Researchers"
boolean userInGroup = pctx.getPerunAdapter().isUserInGroup(pctx.getPerunUserId(), termsAndPoliciesGroupId);
if (userInGroup) {
PerunAttribute bonaFideStatus = pctx.getPerunAdapter()
.getAdapterRpc()
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusAttr);
String valueCreatedAt = bonaFideStatus.getValueCreatedAt();
long asserted;
if (valueCreatedAt != null) {
asserted = Timestamp.valueOf(valueCreatedAt).getTime() / 1000L;
} else {
asserted = System.currentTimeMillis() / 1000L;
}
long expires = Instant.ofEpochSecond(asserted).atZone(ZoneId.systemDefault()).plusYears(100L).toEpochSecond();
if (expires < now) return;
JsonNode visa = createPassportVisa(TYPE_ACCEPTED_TERMS_AND_POLICIES, pctx, BONA_FIDE_URL, BBMRI_ERIC_ORG_URL, BY_SELF, asserted, expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
@Override
protected void addResearcherStatuses(long now, ClaimSourceProduceContext pctx, ArrayNode passport, List<Affiliation> affiliations) {
//by=peer for users with attribute elixirBonaFideStatusREMS
PerunAttribute bbmriBonaFideStatus = pctx.getPerunAdapter()
.getAdapterRpc()
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusAttr);
String valueCreatedAt = null;
if (bbmriBonaFideStatus != null) {
valueCreatedAt = bbmriBonaFideStatus.getValueCreatedAt();
}
if (valueCreatedAt != null) {
long asserted = Timestamp.valueOf(valueCreatedAt).getTime() / 1000L;
long expires = ZonedDateTime.now().plusYears(1L).toEpochSecond();
if (expires > now) {
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL, BBMRI_ERIC_ORG_URL, BY_PEER, asserted, expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
//by=system for users with faculty affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
for (Affiliation affiliation : affiliations) {
if (affiliation.getValue().startsWith("faculty@")) {
long expires = Instant.ofEpochSecond(affiliation.getAsserted()).atZone(ZoneId.systemDefault()).plusYears(1L).toEpochSecond();
if (expires < now) continue;
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL, affiliation.getSource(), BY_SYSTEM, affiliation.getAsserted(), expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
//by=so for users with faculty affiliation asserted by membership in a group with groupAffiliations attribute
for (Affiliation affiliation : pctx.getPerunAdapter().getGroupAffiliations(pctx.getPerunUserId(), groupAffiliationsAttr)) {
if (affiliation.getValue().startsWith("faculty@")) {
long expires = ZonedDateTime.now().plusYears(1L).toEpochSecond();
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL, BBMRI_ERIC_ORG_URL, BY_SO, affiliation.getAsserted(), expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
}
@Override
protected void addControlledAccessGrants(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
Set<String> linkedIdentities = new HashSet<>();
//call Resource Entitlement Management System
for (Ga4ghClaimRepository repo: CLAIM_REPOSITORIES) {
callPermissionsJwtAPI(repo, Collections.singletonMap(BBMRI_ID, pctx.getSub()), passport, linkedIdentities);
}
if (!linkedIdentities.isEmpty()) {
for (String linkedIdentity : linkedIdentities) {
JsonNode visa = createPassportVisa(TYPE_LINKED_IDENTITIES, pctx, linkedIdentity, BBMRI_ERIC_ORG_URL, BY_SYSTEM, now, now + 3600L * 24 * 365, null);
if (visa != null) {
passport.add(visa);
}
}
}
}
}

View File

@ -0,0 +1,186 @@
package cz.muni.ics.oidc.server.ga4gh;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_PEER;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SELF;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SO;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.BY_SYSTEM;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_ACCEPTED_TERMS_AND_POLICIES;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_AFFILIATION_AND_ROLE;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_LINKED_IDENTITIES;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportVisa.TYPE_RESEARCHER_STATUS;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import cz.muni.ics.oidc.models.PerunAttribute;
import cz.muni.ics.oidc.server.claims.ClaimSourceInitContext;
import cz.muni.ics.oidc.server.claims.ClaimSourceProduceContext;
import cz.muni.ics.oidc.server.connectors.Affiliation;
import java.net.URISyntaxException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
/**
* Class producing GA4GH Passport claim. The claim is specified in
* https://bit.ly/ga4gh-passport-v1
*
* Configuration (replace [claimName] with the name of the claim):
* <ul>
* <li><b>custom.claim.[claimName].source.config_file</b> - full path to the configuration file for this claim. See
* configuration templates for such a file.</li> (Passed to Ga4ghPassportAndVisaClaimSource.class)
* <li><b>custom.claim.[claimName].source.bonaFideStatus.attr</b> - mapping for bonaFideStatus Attribute</li>
* <li><b>custom.claim.[claimName].source.bonaFideStatusREMS.attr</b> - mapping for bonaFideStatus Attribute</li>
* <li><b>custom.claim.[claimName].source.groupAffiliations.attr</b> - mapping for groupAffiliations Attribute</li>
* <li><b>custom.claim.[claimName].source.termsAndPoliciesGroupId</b> - ID of group in which the membership represents acceptance of terms and policies</li>
* </ul>
*
* @author Martin Kuba <makub@ics.muni.cz>
*/
@Slf4j
public class ElixirGa4ghClaimSource extends Ga4ghPassportAndVisaClaimSource {
private static final String BONA_FIDE_URL = "https://doi.org/10.1038/s41431-018-0219-y";
private static final String ELIXIR_ORG_URL = "https://elixir-europe.org/";
private static final String ELIXIR_ID = "elixir_id";
private final String bonaFideStatusAttr;
private final String bonaFideStatusREMSAttr;
private final String groupAffiliationsAttr;
private final Long termsAndPoliciesGroupId;
public ElixirGa4ghClaimSource(ClaimSourceInitContext ctx) throws URISyntaxException {
super(ctx, "ELIXIR");
log.debug("Initializing ELIXIR GA4GH Passports and Visa Claim Source");
bonaFideStatusAttr = ctx.getProperty("bonaFideStatus.attr", null);
bonaFideStatusREMSAttr = ctx.getProperty("bonaFideStatusREMS.attr", null);
groupAffiliationsAttr = ctx.getProperty("groupAffiliations.attr", null);
termsAndPoliciesGroupId = ctx.getLongProperty("termsAndPoliciesGroupId", 10432L);
}
@Override
public Set<String> getAttrIdentifiers() {
Set<String> set = new HashSet<>();
if (bonaFideStatusAttr != null) {
set.add(bonaFideStatusAttr);
}
if (bonaFideStatusREMSAttr != null) {
set.add(bonaFideStatusREMSAttr);
}
if (groupAffiliationsAttr != null) {
set.add(groupAffiliationsAttr);
}
return set;
}
@Override
protected String getDefaultConfigFilePath() {
return "/etc/mitreid/elixir/ga4gh_config.yml";
}
@Override
protected void addAffiliationAndRoles(long now, ClaimSourceProduceContext pctx, ArrayNode passport, List<Affiliation> affiliations) {
//by=system for users with affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
for (Affiliation affiliation : affiliations) {
//expires 1 year after the last login from the IdP asserting the affiliation
long expires = Instant.ofEpochSecond(affiliation.getAsserted()).atZone(ZoneId.systemDefault()).plusYears(1L).toEpochSecond();
if (expires < now) continue;
JsonNode visa = createPassportVisa(TYPE_AFFILIATION_AND_ROLE, pctx, affiliation.getValue(), affiliation.getSource(), BY_SYSTEM, affiliation.getAsserted(), expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
@Override
protected void addAcceptedTermsAndPolicies(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
//by=self for members of the group 10432 "Bona Fide Researchers"
boolean userInGroup = pctx.getPerunAdapter().isUserInGroup(pctx.getPerunUserId(), termsAndPoliciesGroupId);
if (userInGroup) {
PerunAttribute bonaFideStatus = pctx.getPerunAdapter()
.getAdapterRpc()
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusAttr);
String valueCreatedAt = bonaFideStatus.getValueCreatedAt();
long asserted;
if (valueCreatedAt != null) {
asserted = Timestamp.valueOf(valueCreatedAt).getTime() / 1000L;
} else {
asserted = System.currentTimeMillis() / 1000L;
}
long expires = Instant.ofEpochSecond(asserted).atZone(ZoneId.systemDefault()).plusYears(100L).toEpochSecond();
if (expires < now) return;
JsonNode visa = createPassportVisa(TYPE_ACCEPTED_TERMS_AND_POLICIES, pctx, BONA_FIDE_URL, ELIXIR_ORG_URL, BY_SELF, asserted, expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
@Override
protected void addResearcherStatuses(long now, ClaimSourceProduceContext pctx, ArrayNode passport, List<Affiliation> affiliations) {
//by=peer for users with attribute elixirBonaFideStatusREMS
PerunAttribute elixirBonaFideStatusREMS = pctx.getPerunAdapter()
.getAdapterRpc()
.getUserAttribute(pctx.getPerunUserId(), bonaFideStatusREMSAttr);
String valueCreatedAt = null;
if (elixirBonaFideStatusREMS != null) {
valueCreatedAt = elixirBonaFideStatusREMS.getValueCreatedAt();
}
if (valueCreatedAt != null) {
long asserted = Timestamp.valueOf(valueCreatedAt).getTime() / 1000L;
long expires = ZonedDateTime.now().plusYears(1L).toEpochSecond();
if (expires > now) {
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL, ELIXIR_ORG_URL, BY_PEER, asserted, expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
//by=system for users with faculty affiliation asserted by their IdP (set in UserExtSource attribute "affiliation")
for (Affiliation affiliation : affiliations) {
if (affiliation.getValue().startsWith("faculty@")) {
long expires = Instant.ofEpochSecond(affiliation.getAsserted()).atZone(ZoneId.systemDefault()).plusYears(1L).toEpochSecond();
if (expires < now) continue;
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL, affiliation.getSource(), BY_SYSTEM, affiliation.getAsserted(), expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
//by=so for users with faculty affiliation asserted by membership in a group with groupAffiliations attribute
for (Affiliation affiliation : pctx.getPerunAdapter().getGroupAffiliations(pctx.getPerunUserId(), groupAffiliationsAttr)) {
if (affiliation.getValue().startsWith("faculty@")) {
long expires = ZonedDateTime.now().plusYears(1L).toEpochSecond();
JsonNode visa = createPassportVisa(TYPE_RESEARCHER_STATUS, pctx, BONA_FIDE_URL, ELIXIR_ORG_URL, BY_SO, affiliation.getAsserted(), expires, null);
if (visa != null) {
passport.add(visa);
}
}
}
}
@Override
protected void addControlledAccessGrants(long now, ClaimSourceProduceContext pctx, ArrayNode passport) {
Set<String> linkedIdentities = new HashSet<>();
//call Resource Entitlement Management System
for (Ga4ghClaimRepository repo : CLAIM_REPOSITORIES) {
callPermissionsJwtAPI(repo, Collections.singletonMap(ELIXIR_ID, pctx.getSub()), passport, linkedIdentities);
}
if (!linkedIdentities.isEmpty()) {
for (String linkedIdentity : linkedIdentities) {
JsonNode visa = createPassportVisa(TYPE_LINKED_IDENTITIES, pctx, linkedIdentity, ELIXIR_ORG_URL, BY_SYSTEM, now, now + 3600L * 24 * 365, null);
if (visa != null) {
passport.add(visa);
}
}
}
}
}

View File

@ -1,20 +1,21 @@
package cz.muni.ics.oidc.server.elixir;
import static cz.muni.ics.oidc.server.elixir.GA4GHClaimSource.parseAndVerifyVisa;
package cz.muni.ics.oidc.server.ga4gh;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTParser;
import com.nimbusds.jwt.SignedJWT;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.text.ParseException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* This class is a command-line debugging tool. It parses JSON in GA4GH Passport format,
@ -24,41 +25,38 @@ import java.time.format.DateTimeFormatter;
*/
public class GA4GHTokenParser {
static ObjectMapper jsonMapper = new ObjectMapper();
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final List<Ga4ghClaimRepository> CLAIM_REPOSITORIES = new ArrayList<>();
private static final Map<URI, RemoteJWKSet<SecurityContext>> REMOTE_JWK_SETS = new HashMap<>();
private static final Map<URI, String> SIGNERS = new HashMap<>();
public static void main(String[] args) throws IOException, ParseException, JOSEException {
GA4GHClaimSource.parseConfigFile("ga4gh_config.yml");
Ga4ghUtils.parseConfigFile("ga4gh_config.yml", CLAIM_REPOSITORIES, REMOTE_JWK_SETS, SIGNERS);
String userinfo = "/tmp/ga4gh.json";
JsonNode doc = jsonMapper.readValue(new File(userinfo), JsonNode.class);
JsonNode ga4gh = doc.get("ga4gh_passport_v1");
JsonNode doc = MAPPER.readValue(new File(userinfo), JsonNode.class);
JsonNode ga4gh = doc.get(Ga4ghPassportAndVisaClaimSource.GA4GH_CLAIM);
long startx = System.currentTimeMillis();
System.out.println();
for (JsonNode jwtString : ga4gh) {
String s = jwtString.asText();
GA4GHClaimSource.PassportVisa visa = parseAndVerifyVisa(s);
Ga4ghPassportVisa visa = Ga4ghUtils.parseAndVerifyVisa(s, SIGNERS, REMOTE_JWK_SETS, MAPPER);
if(!visa.isVerified()) {
System.out.println("visa not verified: "+s);
System.out.println("visa not verified: " + s);
System.out.println("visa = " + visa.getPrettyString());
// System.exit(1);
} else {
System.out.println("OK: "+visa.getPrettyString());
System.out.println("OK: " + visa.getPrettyString());
}
SignedJWT jwt = (SignedJWT) JWTParser.parse(s);
ObjectWriter prettyPrinter = jsonMapper.writerWithDefaultPrettyPrinter();
ObjectWriter prettyPrinter = MAPPER.writerWithDefaultPrettyPrinter();
JsonNode visaHeader = jsonMapper.readValue(jwt.getHeader().toString(), JsonNode.class);
JsonNode visaHeader = MAPPER.readValue(jwt.getHeader().toString(), JsonNode.class);
System.out.println(prettyPrinter.writeValueAsString(visaHeader));
JsonNode visaPayload = jsonMapper.readValue(jwt.getPayload().toString(), JsonNode.class);
JsonNode visaPayload = MAPPER.readValue(jwt.getPayload().toString(), JsonNode.class);
System.out.println(prettyPrinter.writeValueAsString(visaPayload));
}
long endx = System.currentTimeMillis();
System.out.println("signature verification time: " + (endx - startx));
}
private static String isoDateTime(long linuxTime) {
return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(ZonedDateTime.ofInstant(Instant.ofEpochSecond(linuxTime), ZoneId.systemDefault()));
}
}

View File

@ -1,32 +1,36 @@
package cz.muni.ics.oidc.server.elixir;
package cz.muni.ics.oidc.server.ga4gh;
import com.nimbusds.jwt.JWTClaimsSet;
import cz.muni.ics.oidc.server.PerunAccessTokenEnhancer;
import cz.muni.ics.openid.connect.model.UserInfo;
import java.util.Collections;
import java.util.Set;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
/**
* Implements changes required by GA4GH specification followed by the ELIXIR AAI.
* Implements changes required by GA4GH specification.
*
* @author Martin Kuba <makub@ics.muni.cz>
*/
@SuppressWarnings("unused")
@Slf4j
public class ElixirAccessTokenModifier implements PerunAccessTokenEnhancer.AccessTokenClaimsModifier {
public ElixirAccessTokenModifier() {
}
@NoArgsConstructor
public class Ga4ghAccessTokenModifier implements PerunAccessTokenEnhancer.AccessTokenClaimsModifier {
@Override
public void modifyClaims(String sub, JWTClaimsSet.Builder builder, OAuth2AccessToken accessToken, OAuth2Authentication authentication, UserInfo userInfo) {
public void modifyClaims(String sub,
JWTClaimsSet.Builder builder,
OAuth2AccessToken accessToken,
OAuth2Authentication authentication,
UserInfo userInfo)
{
Set<String> scopes = accessToken.getScope();
//GA4GH
if (scopes.contains(GA4GHClaimSource.GA4GH_SCOPE)) {
log.debug("adding claims required by GA4GH to access token");
if (scopes.contains(ElixirGa4ghClaimSource.GA4GH_SCOPE)) {
log.debug("Adding claims required by GA4GH to access token");
builder.audience(Collections.singletonList(authentication.getOAuth2Request().getClientId()));
}
}

View File

@ -0,0 +1,19 @@
package cz.muni.ics.oidc.server.ga4gh;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import org.springframework.web.client.RestTemplate;
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class Ga4ghClaimRepository {
private final String name;
private final String actionURL;
private final RestTemplate restTemplate;
}

View File

@ -0,0 +1,228 @@
package cz.muni.ics.oidc.server.ga4gh;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import cz.muni.ics.jwt.signer.service.JWTSigningAndValidationService;
import cz.muni.ics.oidc.server.claims.ClaimSource;
import cz.muni.ics.oidc.server.claims.ClaimSourceInitContext;
import cz.muni.ics.oidc.server.claims.ClaimSourceProduceContext;
import cz.muni.ics.oidc.server.connectors.Affiliation;
import cz.muni.ics.openid.connect.web.JWKSetPublishingEndpoint;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.client.HttpClientErrorException;
/**
* Class producing GA4GH Passport claim. The claim is specified in
* https://bit.ly/ga4gh-passport-v1
*
* Configuration (replace [claimName] with the name of the claim):
* <ul>
* <li><b>custom.claim.[claimName].source.config_file</b> - full path to the configuration file for this claim. See
* configuration templates for such a file.</li>
* </ul>
*
* @author Martin Kuba <makub@ics.muni.cz>
*/
@Slf4j
@Getter
public abstract class Ga4ghPassportAndVisaClaimSource extends ClaimSource {
public static final String GA4GH_SCOPE = "ga4gh_passport_v1";
public static final String GA4GH_CLAIM = "ga4gh_passport_v1";
protected static final List<Ga4ghClaimRepository> CLAIM_REPOSITORIES = new ArrayList<>();
protected static final Map<URI, RemoteJWKSet<SecurityContext>> REMOTE_JWK_SETS = new HashMap<>();
protected static final Map<URI, String> SIGNERS = new HashMap<>();
protected static final ObjectMapper MAPPER = new ObjectMapper();
private final JWTSigningAndValidationService jwtService;
private final URI jku;
private final String issuer;
public Ga4ghPassportAndVisaClaimSource(ClaimSourceInitContext ctx, String implType) throws URISyntaxException {
super(ctx);
log.debug("Initializing GA4GH Passports and Visa Claim Source - (version by {})", implType);
//remember context
jwtService = ctx.getJwtService();
issuer = ctx.getPerunOidcConfig().getConfigBean().getIssuer();
jku = new URI(issuer + JWKSetPublishingEndpoint.URL);
// load config file
String configFile = ctx.getProperty("config_file", getDefaultConfigFilePath());
Ga4ghUtils.parseConfigFile(configFile, CLAIM_REPOSITORIES, REMOTE_JWK_SETS, SIGNERS);
}
@Override
public JsonNode produceValue(ClaimSourceProduceContext pctx) {
if (pctx.getClient() == null) {
log.debug("Client is not set");
return JsonNodeFactory.instance.textNode("Global Alliance For Genomic Health structured claim");
}
if (!pctx.getClient().getScope().contains(GA4GH_SCOPE)) {
log.debug("Client '{}' does not have scope ga4gh", pctx.getClient().getClientName());
return null;
}
List<Affiliation> affiliations = pctx.getPerunAdapter()
.getAdapterRpc()
.getUserExtSourcesAffiliations(pctx.getPerunUserId());
ArrayNode ga4gh_passport_v1 = JsonNodeFactory.instance.arrayNode();
long now = Instant.now().getEpochSecond();
addAffiliationAndRoles(now, pctx, ga4gh_passport_v1, affiliations);
addAcceptedTermsAndPolicies(now, pctx, ga4gh_passport_v1);
addResearcherStatuses(now, pctx, ga4gh_passport_v1, affiliations);
addControlledAccessGrants(now, pctx, ga4gh_passport_v1);
return ga4gh_passport_v1;
}
public static Ga4ghPassportVisa parseAndVerifyVisa(String subValue) {
return Ga4ghUtils.parseAndVerifyVisa(subValue, SIGNERS, REMOTE_JWK_SETS, MAPPER);
}
protected abstract String getDefaultConfigFilePath();
protected abstract void addAffiliationAndRoles(long now, ClaimSourceProduceContext pctx,
ArrayNode passport, List<Affiliation> affiliations);
protected abstract void addAcceptedTermsAndPolicies(long now, ClaimSourceProduceContext pctx, ArrayNode passport);
protected abstract void addResearcherStatuses(long now, ClaimSourceProduceContext pctx,
ArrayNode passport, List<Affiliation> affiliations);
protected abstract void addControlledAccessGrants(long now, ClaimSourceProduceContext pctx, ArrayNode passport);
protected JsonNode createPassportVisa(String type, ClaimSourceProduceContext pctx, String value, String source,
String by, long asserted, long expires, JsonNode condition)
{
long now = System.currentTimeMillis() / 1000L;
if (asserted > now) {
log.warn("Visa asserted in future, it will be ignored!");
log.debug("Visa information: perunUserId={}, sub={}, type={}, value={}, source={}, by={}, asserted={}",
pctx.getPerunUserId(), pctx.getSub(), type, value, source, by, Instant.ofEpochSecond(asserted));
return null;
}
if (expires <= now) {
log.warn("Visa is expired, it will be ignored!");
log.debug("Visa information: perunUserId={}, sub={}, type={}, value={}, source={}, by={}, expired={}",
pctx.getPerunUserId(), pctx.getSub(), type, value, source, by, Instant.ofEpochSecond(expires));
return null;
}
Map<String, Object> passportVisaObject = new HashMap<>();
passportVisaObject.put(Ga4ghPassportVisa.TYPE, type);
passportVisaObject.put(Ga4ghPassportVisa.ASSERTED, asserted);
passportVisaObject.put(Ga4ghPassportVisa.VALUE, value);
passportVisaObject.put(Ga4ghPassportVisa.SOURCE, source);
passportVisaObject.put(Ga4ghPassportVisa.BY, by);
if (condition != null && !condition.isNull() && !condition.isMissingNode()) {
passportVisaObject.put(Ga4ghPassportVisa.CONDITION, condition);
}
JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.parse(jwtService.getDefaultSigningAlgorithm().getName()))
.keyID(jwtService.getDefaultSignerKeyId())
.type(JOSEObjectType.JWT)
.jwkURL(jku)
.build();
JWTClaimsSet jwtClaimsSet = new JWTClaimsSet.Builder()
.issuer(issuer)
.issueTime(new Date())
.expirationTime(new Date(expires * 1000L))
.subject(pctx.getSub())
.jwtID(UUID.randomUUID().toString())
.claim(Ga4ghPassportVisa.GA4GH_VISA_V1, passportVisaObject)
.build();
SignedJWT myToken = new SignedJWT(jwsHeader, jwtClaimsSet);
jwtService.signJwt(myToken);
return JsonNodeFactory.instance.textNode(myToken.serialize());
}
protected void callPermissionsJwtAPI(Ga4ghClaimRepository repo,
Map<String, String> uriVariables,
ArrayNode passport,
Set<String> linkedIdentities)
{
JsonNode response = callHttpJsonAPI(repo, uriVariables);
if (response != null) {
JsonNode visas = response.path(GA4GH_CLAIM);
if (visas.isArray()) {
for (JsonNode visaNode : visas) {
if (visaNode.isTextual()) {
Ga4ghPassportVisa visa = Ga4ghUtils.parseAndVerifyVisa(visaNode.asText(), SIGNERS, REMOTE_JWK_SETS, MAPPER);
if (visa.isVerified()) {
log.debug("Adding a visa to passport: {}", visa);
passport.add(passport.textNode(visa.getJwt()));
linkedIdentities.add(visa.getLinkedIdentity());
} else {
log.warn("Skipping visa: {}", visa);
}
} else {
log.warn("Element of {} is not a String: {}", GA4GH_CLAIM, visaNode);
}
}
} else {
log.warn("{} is not an array in {}", GA4GH_CLAIM, response);
}
}
}
@SuppressWarnings("Duplicates")
private static JsonNode callHttpJsonAPI(Ga4ghClaimRepository repo, Map<String, String> uriVariables) {
//get permissions data
try {
JsonNode result;
try {
if (log.isDebugEnabled()) {
log.debug("Calling Permissions API at {}", repo.getRestTemplate().getUriTemplateHandler().expand(repo.getActionURL(), uriVariables));
}
result = repo.getRestTemplate().getForObject(repo.getActionURL(), JsonNode.class, uriVariables);
} catch (HttpClientErrorException ex) {
MediaType contentType = ex.getResponseHeaders().getContentType();
String body = ex.getResponseBodyAsString();
log.error("HTTP ERROR: {}, URL: {}, Content-Type: {}", ex.getRawStatusCode(),
repo.getActionURL(), contentType);
if (ex.getRawStatusCode() == 404) {
log.warn("Got status 404 from Permissions endpoint {}, ELIXIR AAI user is not linked to user at Permissions API",
repo.getActionURL());
return null;
}
if ("json".equals(contentType.getSubtype())) {
try {
log.error(new ObjectMapper().readValue(body, JsonNode.class).path("message").asText());
} catch (IOException e) {
log.error("cannot parse error message from JSON", e);
}
} else {
log.error("cannot make REST call, exception: {} message: {}", ex.getClass().getName(), ex.getMessage());
}
return null;
}
log.debug("Permissions API response: {}", result);
return result;
} catch (Exception ex) {
log.error("Cannot get dataset permissions", ex);
}
return null;
}
}

View File

@ -0,0 +1,60 @@
package cz.muni.ics.oidc.server.ga4gh;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
@EqualsAndHashCode
public class Ga4ghPassportVisa {
public static final String GA4GH_VISA_V1 = "ga4gh_visa_v1";
public static final String TYPE_AFFILIATION_AND_ROLE = "AffiliationAndRole";
public static final String TYPE_ACCEPTED_TERMS_AND_POLICIES = "AcceptedTermsAndPolicies";
public static final String TYPE_RESEARCHER_STATUS = "ResearcherStatus";
public static final String TYPE_LINKED_IDENTITIES = "LinkedIdentities";
public static final String BY_SYSTEM = "system";
public static final String BY_SO = "so";
public static final String BY_PEER = "peer";
public static final String BY_SELF = "self";
public static final String SUB = "sub";
public static final String EXP = "exp";
public static final String ISS = "iss";
public static final String TYPE = "type";
public static final String ASSERTED = "asserted";
public static final String VALUE = "value";
public static final String SOURCE = "source";
public static final String BY = "by";
public static final String CONDITION = "condition";
private boolean verified = false;
private String linkedIdentity;
private String sub;
private String iss;
private String type;
private String value;
@ToString.Exclude
private String signer;
@ToString.Exclude
private String jwt;
@ToString.Exclude
private String prettyPayload;
public Ga4ghPassportVisa(String jwt) {
this.jwt = jwt;
}
public String getPrettyString() {
return prettyPayload + ", signed by " + signer;
}
}

View File

@ -0,0 +1,215 @@
package cz.muni.ics.oidc.server.ga4gh;
import static cz.muni.ics.oidc.server.ga4gh.Ga4ghPassportAndVisaClaimSource.GA4GH_CLAIM;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKMatcher;
import com.nimbusds.jose.jwk.JWKSelector;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTParser;
import com.nimbusds.jwt.SignedJWT;
import cz.muni.ics.oidc.server.AddHeaderInterceptor;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLEncoder;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.client.InterceptingClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
@Slf4j
public class Ga4ghUtils {
public static final String CONF_KEY_REPOS = "repos";
public static final String CONF_KEY_NAME = "name";
public static final String CONF_KEY_URL = "url";
public static final String CONF_KEY_HEADERS = "headers";
public static final String CONF_KEY_HEADER = "header";
public static final String CONF_KEY_VALUE = "value";
public static final String CONF_KEY_SIGNERS = "signers";
public static final String CONF_KEY_JWKS = "jwks";
public static void parseConfigFile(String file,
List<Ga4ghClaimRepository> claimRepositories,
Map<URI, RemoteJWKSet<SecurityContext>> remoteJwkSets,
Map<URI, String> signers)
{
YAMLMapper mapper = new YAMLMapper();
try {
JsonNode root = mapper.readValue(new File(file), JsonNode.class);
// prepare claim repositories
for (JsonNode repo : root.path(CONF_KEY_REPOS)) {
initializeRepo(repo, claimRepositories);
}
// prepare claim signers
for (JsonNode signer : root.path(CONF_KEY_SIGNERS)) {
initializeSigner(signer, signers, remoteJwkSets);
}
} catch (IOException ex) {
log.error("cannot read GA4GH config file", ex);
}
}
public static Ga4ghPassportVisa parseAndVerifyVisa(String jwtString,
Map<URI, String> signers,
Map<URI, RemoteJWKSet<SecurityContext>> remoteJwkSets,
ObjectMapper mapper)
{
Ga4ghPassportVisa visa = new Ga4ghPassportVisa(jwtString);
try {
SignedJWT signedJWT = (SignedJWT) JWTParser.parse(jwtString);
URI jku = signedJWT.getHeader().getJWKURL();
if (jku == null) {
log.error("JKU is missing in JWT header");
return visa;
}
visa.setSigner(signers.get(jku));
RemoteJWKSet<SecurityContext> remoteJWKSet = remoteJwkSets.get(jku);
if (remoteJWKSet == null) {
log.error("JKU '{}' is not among trusted key sets", jku);
return visa;
}
List<JWK> keys = remoteJWKSet.get(new JWKSelector(
new JWKMatcher.Builder().keyID(signedJWT.getHeader().getKeyID()).build()), null);
RSASSAVerifier verifier = new RSASSAVerifier(((RSAKey) keys.get(0)).toRSAPublicKey());
visa.setVerified(signedJWT.verify(verifier));
if (visa.isVerified()) {
Ga4ghUtils.processPayload(mapper, visa, signedJWT.getPayload());
}
} catch (Exception ex) {
log.error("Visa '{}' cannot be parsed and verified", jwtString, ex);
}
return visa;
}
public static void processPayload(ObjectMapper mapper, Ga4ghPassportVisa visa, Payload payload)
throws IOException
{
JsonNode doc = mapper.readValue(payload.toString(), JsonNode.class);
checkVisaKey(visa, doc, Ga4ghPassportVisa.SUB);
checkVisaKey(visa, doc, Ga4ghPassportVisa.EXP);
checkVisaKey(visa, doc, Ga4ghPassportVisa.ISS);
JsonNode visa_v1 = doc.path(Ga4ghPassportVisa.GA4GH_VISA_V1);
if (visa_v1.isMissingNode() || visa_v1.isNull() || visa_v1.isEmpty()) {
log.warn("Nothing available in '{}', considering visa as not verified", Ga4ghPassportVisa.GA4GH_VISA_V1);
visa.setVerified(false);
return;
}
checkVisaKey(visa, visa_v1, Ga4ghPassportVisa.TYPE);
checkVisaKey(visa, visa_v1, Ga4ghPassportVisa.ASSERTED);
checkVisaKey(visa, visa_v1, Ga4ghPassportVisa.VALUE);
checkVisaKey(visa, visa_v1, Ga4ghPassportVisa.SOURCE);
checkVisaKey(visa, visa_v1, Ga4ghPassportVisa.BY);
if (!visa.isVerified()) {
return;
}
long exp = doc.get(Ga4ghPassportVisa.EXP).asLong();
if (exp < Instant.now().getEpochSecond()) {
log.warn("visa expired on {}", isoDateTime(exp));
visa.setVerified(false);
return;
}
visa.setLinkedIdentity(URLEncoder.encode(doc.get(Ga4ghPassportVisa.SUB).asText(), "utf-8") +
',' + URLEncoder.encode(doc.get(Ga4ghPassportVisa.ISS).asText(), "utf-8"));
visa.setPrettyPayload(
visa_v1.get(Ga4ghPassportVisa.TYPE).asText() + ": '"
+ visa_v1.get(Ga4ghPassportVisa.VALUE).asText() + "' asserted at '"
+ isoDate(visa_v1.get(Ga4ghPassportVisa.ASSERTED).asLong()) + '\''
);
}
private static void initializeSigner(JsonNode signer,
Map<URI, String> signers,
Map<URI, RemoteJWKSet<SecurityContext>> remoteJwkSets)
{
String name = signer.path(CONF_KEY_NAME).asText();
String jwks = signer.path(CONF_KEY_JWKS).asText();
try {
URL jku = new URL(jwks);
remoteJwkSets.put(jku.toURI(), new RemoteJWKSet<>(jku));
signers.put(jku.toURI(), name);
log.info("JWKS Signer '{}' added with keys '{}'", name, jwks);
} catch (MalformedURLException | URISyntaxException e) {
log.error("cannot add to RemoteJWKSet map: '{}' -> '{}'", name, jwks, e);
}
}
private static void initializeRepo(JsonNode repo, List<Ga4ghClaimRepository> claimRepositories) {
String name = repo.path(CONF_KEY_NAME).asText();
String actionURL = repo.path(CONF_KEY_URL).asText();
JsonNode headers = repo.path(CONF_KEY_HEADERS);
Map<String, String> headersWithValues = new HashMap<>();
for (JsonNode header: headers) {
headersWithValues.put(header.path(CONF_KEY_HEADER).asText(), header.path(CONF_KEY_VALUE).asText());
}
if (actionURL == null || headersWithValues.isEmpty()) {
log.error("claim repository '{}' not defined with url|auth_header|auth_value", repo);
return;
}
RestTemplate restTemplate = new RestTemplate();
restTemplate.setRequestFactory(
new InterceptingClientHttpRequestFactory(restTemplate.getRequestFactory(),
headersWithValues.entrySet()
.stream()
.map(e -> new AddHeaderInterceptor(e.getKey(), e.getValue()))
.collect(Collectors.toList()))
);
claimRepositories.add(new Ga4ghClaimRepository(name, actionURL, restTemplate));
log.info("GA4GH Claims Repository '{}' configured at '{}'", name, actionURL);
}
private static void checkVisaKey(Ga4ghPassportVisa visa, JsonNode jsonNode, String key) {
if (jsonNode.path(key).isMissingNode()) {
log.warn("Key '{}' is missing in the Visa, therefore cannot be verified", key);
visa.setVerified(false);
} else {
switch (key) {
case Ga4ghPassportVisa.SUB:
visa.setSub(jsonNode.path(key).asText());
break;
case Ga4ghPassportVisa.ISS:
visa.setIss(jsonNode.path(key).asText());
break;
case Ga4ghPassportVisa.TYPE:
visa.setType(jsonNode.path(key).asText());
break;
case Ga4ghPassportVisa.VALUE:
visa.setValue(jsonNode.path(key).asText());
break;
}
}
}
private static String isoDate(long linuxTime) {
return isoFormat(linuxTime, DateTimeFormatter.ISO_LOCAL_DATE);
}
private static String isoDateTime(long linuxTime) {
return isoFormat(linuxTime, DateTimeFormatter.ISO_DATE_TIME);
}
private static String isoFormat(long linuxTime, DateTimeFormatter formatter) {
ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.ofEpochSecond(linuxTime), ZoneId.systemDefault());
return formatter.format(zdt);
}
}