diff --git a/deploy.cfg.example b/deploy.cfg.example index c41f6350..ce7f7da1 100644 --- a/deploy.cfg.example +++ b/deploy.cfg.example @@ -98,3 +98,5 @@ identity-provider-OrcID-client-id = identity-provider-OrcID-client-secret = identity-provider-OrcID-login-redirect-url = https://kbase.us/services/auth/login/complete/orcid identity-provider-OrcID-link-redirect-url = https://kbase.us/services/auth/link/complete/orcid +# uncomment to disable using MFA. Required if OrcID plan doesn't support MFA +#identity-provider-OrcID-custom-disable-mfa = true diff --git a/src/main/java/us/kbase/auth2/providers/OrcIDIdentityProviderFactory.java b/src/main/java/us/kbase/auth2/providers/OrcIDIdentityProviderFactory.java index da0645a6..391051f8 100644 --- a/src/main/java/us/kbase/auth2/providers/OrcIDIdentityProviderFactory.java +++ b/src/main/java/us/kbase/auth2/providers/OrcIDIdentityProviderFactory.java @@ -6,6 +6,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.Base64; import java.util.List; import java.util.Map; import java.util.Set; @@ -33,6 +34,7 @@ import us.kbase.auth2.lib.identity.RemoteIdentity; import us.kbase.auth2.lib.identity.RemoteIdentityDetails; import us.kbase.auth2.lib.identity.RemoteIdentityID; +import us.kbase.auth2.lib.token.MFAStatus; /** A factory for a OrcID identity provider. * @author gaprice@lbl.gov @@ -46,7 +48,14 @@ public IdentityProvider configure(final IdentityProviderConfig cfg) { } /** An identity provider for OrcID accounts. - * @author gaprice@lbl.gov + * + * Multi-Factor Authentication (MFA) Status Handling: + * - Uses OpenID Connect JWT tokens to determine MFA status via AMR claims + * - Configuration option "disable-mfa" (default: false): + * - false: Requires OpenID scope, throws error on missing or malformed JWT + * - true: Skips MFA check, returns MFAStatus.UNKNOWN (for non-member API apps) + * - Valid JWT with AMR claim: returns MFAStatus.USED or MFAStatus.NOT_USED based on + * "mfa" presence * */ public static class OrcIDIdentityProvider implements IdentityProvider { @@ -57,8 +66,10 @@ public static class OrcIDIdentityProvider implements IdentityProvider { /* Get creds: https://sandbox.orcid.org/developer-tools */ + private static final String DISABLE_MFA = "disable-mfa"; private static final String NAME = "OrcID"; - private static final String SCOPE = "/authenticate"; + private static final String SCOPE_OPENID = "openid /authenticate"; + private static final String SCOPE_NO_OPENID = "/authenticate"; private static final String LOGIN_PATH = "/oauth/authorize"; private static final String TOKEN_PATH = "/oauth/token"; private static final String RECORD_PATH = "/v2.1"; @@ -69,6 +80,7 @@ public static class OrcIDIdentityProvider implements IdentityProvider { private static final ObjectMapper MAPPER = new ObjectMapper(); private final IdentityProviderConfig cfg; + private final boolean skipMFA; /** Create an identity provider for OrcID. * @param idc the configuration for this provider. @@ -82,6 +94,7 @@ public OrcIDIdentityProvider(final IdentityProviderConfig idc) { idc.getIdentityProviderFactoryClassName()); } this.cfg = idc; + skipMFA = "true".equals(idc.getCustomConfiguation().get(DISABLE_MFA)); } @Override @@ -102,11 +115,12 @@ public URI getLoginURI( final boolean link, final String environment) throws NoSuchEnvironmentException { + final String scope = skipMFA ? SCOPE_NO_OPENID : SCOPE_OPENID; // note that OrcID does not currently implement PKCE so we ignore the code // challenge: https://github.com/ORCID/ORCID-Source/issues/5977 return UriBuilder.fromUri(toURI(cfg.getLoginURL())) .path(LOGIN_PATH) - .queryParam("scope", SCOPE) + .queryParam("scope", scope) .queryParam("state", state) .queryParam("redirect_uri", getRedirectURL(link, environment)) .queryParam("response_type", "code") @@ -147,7 +161,7 @@ public IdentityProviderResponse getIdentities( final OrcIDAccessTokenResponse accessToken = getAccessToken( authcode, link, environment); final RemoteIdentity ri = getIdentity(accessToken); - return IdentityProviderResponse.from(ri); + return IdentityProviderResponse.from(ri, accessToken.mfa); } private RemoteIdentity getIdentity(final OrcIDAccessTokenResponse accessToken) @@ -210,11 +224,14 @@ private static class OrcIDAccessTokenResponse { private final String accessToken; private final String fullName; private final String orcID; + private final MFAStatus mfa; private OrcIDAccessTokenResponse( final String accessToken, final String fullName, - final String orcID) + final String orcID, + final MFAStatus mfaStatus + ) throws IdentityRetrievalException { if (accessToken == null || accessToken.trim().isEmpty()) { throw new IdentityRetrievalException( @@ -227,6 +244,7 @@ private OrcIDAccessTokenResponse( this.accessToken = accessToken.trim(); this.fullName = fullName == null ? null : fullName.trim(); this.orcID = orcID.trim(); + this.mfa = mfaStatus; } } @@ -255,10 +273,88 @@ private OrcIDAccessTokenResponse getAccessToken( throw new IdentityRetrievalException("Authtoken retrieval failed: " + msg[msg.length - 1].trim()); } + + // Determine MFA status based on configuration + final MFAStatus mfaStatus; + if (skipMFA) { + // MFA checking disabled - no OpenID scope, so no id_token expected + mfaStatus = MFAStatus.UNKNOWN; + } else { + // MFA checking enabled - parse JWT from id_token + final String idToken = (String) m.get("id_token"); + mfaStatus = parseAmrClaim(idToken); + } + return new OrcIDAccessTokenResponse( (String) m.get("access_token"), (String) m.get("name"), - (String) m.get("orcid")); + (String) m.get("orcid"), + mfaStatus + ); + } + + /** + * Parses the Authentication Method Reference (AMR) claim from an OpenID Connect ID token + * to determine if multi-factor authentication was used. + * + * @param jwt the JWT ID token from ORCID + * @return MFAStatus indicating whether MFA was used + * @throws IdentityRetrievalException if JWT is missing, malformed, or unparseable + */ + private MFAStatus parseAmrClaim(final String jwt) throws IdentityRetrievalException { + if (jwt == null || jwt.trim().isEmpty()) { + throw new IdentityRetrievalException( + "No JWT token provided by ORCID. For non-member API applications, " + + "set disable-mfa to true in provider configuration"); + } + + // JWT format: header.payload.signature + final String[] parts = jwt.split("\\."); + if (parts.length != 3) { + // Invalid JWT format + throw new IdentityRetrievalException( + "Invalid JWT format from ORCID: expected 3 parts, got " + parts.length + ); + } + + // Decode the payload (second part) - URL-safe base64 + final String payload; + try { + payload = new String(Base64.getUrlDecoder().decode(parts[1])); + } catch (IllegalArgumentException e) { + // Base64 decoding failed - invalid JWT format + throw new IdentityRetrievalException("Unable to decode JWT from ORCID", e); + } + + // Parse JSON payload to extract claims + final Map claims; + try { + @SuppressWarnings("unchecked") + final Map parsedClaims = MAPPER.readValue(payload, Map.class); + claims = parsedClaims; + } catch (IOException e) { + // JSON parsing failed - malformed payload + throw new IdentityRetrievalException("Unable to parse JWT payload from ORCID", e); + } + + final Object amrClaim = claims.get("amr"); + if (amrClaim == null) { + // No AMR claim present - MFA status unknown + return MFAStatus.UNKNOWN; + } else if (amrClaim instanceof List) { + // OpenID Connect spec: AMR should be an array of strings + @SuppressWarnings("unchecked") + final List amrList = (List) amrClaim; + return amrList.contains("mfa") ? MFAStatus.USED : MFAStatus.NOT_USED; + } else if (amrClaim instanceof String) { + // ORCID may return single string - handle as fallback + return "mfa".equals(amrClaim) ? MFAStatus.USED : MFAStatus.NOT_USED; + } + + // AMR claim present but in unexpected format + throw new IdentityRetrievalException( + "AMR claim from ORCID in unexpected format: " + amrClaim + ); } private Map orcIDPostRequest( diff --git a/src/test/java/us/kbase/test/auth2/providers/OrcIDIdentityProviderTest.java b/src/test/java/us/kbase/test/auth2/providers/OrcIDIdentityProviderTest.java index 651f489d..9c530d86 100644 --- a/src/test/java/us/kbase/test/auth2/providers/OrcIDIdentityProviderTest.java +++ b/src/test/java/us/kbase/test/auth2/providers/OrcIDIdentityProviderTest.java @@ -3,11 +3,11 @@ import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertThat; import static org.junit.Assert.fail; +import static us.kbase.test.auth2.TestCommon.list; import static us.kbase.test.auth2.TestCommon.set; import java.net.MalformedURLException; import java.net.URI; -import java.net.URISyntaxException; import java.net.URL; import java.util.Arrays; import java.util.Collections; @@ -34,6 +34,8 @@ import us.kbase.auth2.lib.identity.RemoteIdentity; import us.kbase.auth2.lib.identity.RemoteIdentityDetails; import us.kbase.auth2.lib.identity.RemoteIdentityID; +import us.kbase.auth2.lib.token.MFAStatus; +import us.kbase.auth2.lib.identity.IdentityProviderConfig.Builder; import us.kbase.auth2.lib.identity.IdentityProviderConfig.IdentityProviderConfigurationException; import us.kbase.auth2.lib.identity.IdentityProviderResponse; import us.kbase.auth2.providers.OrcIDIdentityProviderFactory; @@ -90,10 +92,11 @@ public void tearDownTest() { mockClientAndServer.reset(); } - private static final IdentityProviderConfig CFG; + private static final IdentityProviderConfig CFG_NO_MFA; + private static final IdentityProviderConfig CFG_MFA; static { try { - CFG = IdentityProviderConfig.getBuilder( + Builder base = IdentityProviderConfig.getBuilder( OrcIDIdentityProviderFactory.class.getName(), new URL("https://ologin.com"), new URL("https://osetapiurl.com"), @@ -102,47 +105,48 @@ public void tearDownTest() { new URL("https://ologinredir.com"), new URL("https://olinkredir.com")) .withEnvironment("myenv", - new URL("https://myologinred.com"), new URL("https://myolinkred.com")) - .build(); + new URL("https://myologinred.com"), new URL("https://myolinkred.com")); + CFG_MFA = base.build(); + CFG_NO_MFA = base.withCustomConfiguration("disable-mfa", "true").build(); } catch (IdentityProviderConfigurationException | MalformedURLException e) { throw new RuntimeException("Fix yer tests newb", e); } } @Test - public void simpleOperationsWithConfigurator() throws Exception { + public void simpleOperationsWithConfiguratorWithMFA() throws Exception { final OrcIDIdentityProviderFactory gc = new OrcIDIdentityProviderFactory(); - final IdentityProvider oip = gc.configure(CFG); + final IdentityProvider oip = gc.configure(CFG_MFA); assertThat("incorrect provider name", oip.getProviderName(), is("OrcID")); assertThat("incorrect environments", oip.getEnvironments(), is(set("myenv"))); assertThat("incorrect login url", oip.getLoginURI("foo3", "pkce", false, null), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo3&redirect_uri=https%3A%2F%2Fologinredir.com" + "&response_type=code&client_id=ofoo"))); assertThat("incorrect link url", oip.getLoginURI("foo4", "pkce", true, null), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo4&redirect_uri=https%3A%2F%2Folinkredir.com" + "&response_type=code&client_id=ofoo"))); assertThat("incorrect login url", oip.getLoginURI("foo3", "pkce", false, "myenv"), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo3&redirect_uri=https%3A%2F%2Fmyologinred.com" + "&response_type=code&client_id=ofoo"))); assertThat("incorrect link url", oip.getLoginURI("foo4", "pkce", true, "myenv"), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo4&redirect_uri=https%3A%2F%2Fmyolinkred.com" + "&response_type=code&client_id=ofoo"))); } @Test - public void simpleOperationsWithoutConfigurator() throws Exception { + public void simpleOperationsWithoutConfiguratorWithoutMFA() throws Exception { - final IdentityProvider oip = new OrcIDIdentityProvider(CFG); + final IdentityProvider oip = new OrcIDIdentityProvider(CFG_NO_MFA); assertThat("incorrect provider name", oip.getProviderName(), is("OrcID")); assertThat("incorrect environments", oip.getEnvironments(), is(set("myenv"))); assertThat("incorrect login url", oip.getLoginURI("foo5", "pkce", false, null), @@ -174,12 +178,12 @@ public void createFail() throws Exception { failCreate(null, new NullPointerException("idc")); failCreate(IdentityProviderConfig.getBuilder( "foo", - CFG.getLoginURL(), - CFG.getApiURL(), - CFG.getClientID(), - CFG.getClientSecret(), - CFG.getLoginRedirectURL(), - CFG.getLinkRedirectURL()) + CFG_NO_MFA.getLoginURL(), + CFG_NO_MFA.getApiURL(), + CFG_NO_MFA.getClientID(), + CFG_NO_MFA.getClientSecret(), + CFG_NO_MFA.getLoginRedirectURL(), + CFG_NO_MFA.getLinkRedirectURL()) .build(), new IllegalArgumentException( "Configuration class name doesn't match factory class name: foo")); @@ -196,7 +200,7 @@ private void failCreate(final IdentityProviderConfig cfg, final Exception except @Test public void illegalAuthcode() throws Exception { - final IdentityProvider idp = new OrcIDIdentityProvider(CFG); + final IdentityProvider idp = new OrcIDIdentityProvider(CFG_NO_MFA); failGetIdentities(idp, null, "pkce", true, new IllegalArgumentException( "authcode cannot be null or empty")); failGetIdentities(idp, " \t \n ", "pkce", true, new IllegalArgumentException( @@ -205,7 +209,7 @@ public void illegalAuthcode() throws Exception { @Test public void noSuchEnvironment() throws Exception { - final IdentityProvider idp = new OrcIDIdentityProvider(CFG); + final IdentityProvider idp = new OrcIDIdentityProvider(CFG_NO_MFA); failGetIdentities(idp, "foo", "pkce", true, "myenv1", new NoSuchEnvironmentException("myenv1")); @@ -238,10 +242,12 @@ private void failGetIdentities( } } - private IdentityProviderConfig getTestIDConfig() - throws IdentityProviderConfigurationException, MalformedURLException, - URISyntaxException { - return IdentityProviderConfig.getBuilder( + private IdentityProviderConfig getTestIDConfig() throws Exception { + return getTestIDConfig(false); + } + + private IdentityProviderConfig getTestIDConfig(final boolean withMFA) throws Exception { + final Builder b = IdentityProviderConfig.getBuilder( OrcIDIdentityProviderFactory.class.getName(), new URL("http://localhost:" + mockClientAndServer.getPort()), new URL("http://localhost:" + mockClientAndServer.getPort()), @@ -249,8 +255,12 @@ private IdentityProviderConfig getTestIDConfig() "obar", new URL("https://ologinredir.com"), new URL("https://olinkredir.com")) - .withEnvironment("e3", new URL("https://lo.com"), new URL("https://li.com")) - .build(); + .withEnvironment( + "e3", new URL("https://lo.com"), new URL("https://li.com")); + if (!withMFA) { + b.withCustomConfiguration("disable-mfa", "true"); + } + return b.build(); } @Test @@ -322,6 +332,68 @@ public void returnsBadResponseAuthToken() throws Exception { "Error: whee!. Error description: whoo!")); } + @Test + public void returnsBadResponseJWT() throws Exception { + final String orcID = "0000-0001-1234-5678"; + + failParseJWT(null, new IdentityRetrievalException( + "No JWT token provided by ORCID. For non-member API applications, " + + "set disable-mfa to true in provider configuration" + )); + failParseJWT(" \t ", new IdentityRetrievalException( + "No JWT token provided by ORCID. For non-member API applications, " + + "set disable-mfa to true in provider configuration" + )); + failParseJWT("who wrote this bloody token", new IdentityRetrievalException( + "Invalid JWT format from ORCID: expected 3 parts, got 1") + ); + failParseJWT("header.payload", new IdentityRetrievalException( + "Invalid JWT format from ORCID: expected 3 parts, got 2")); + failParseJWT("invalid.jwt.token", new IdentityRetrievalException( + "Unable to parse JWT payload from ORCID" + )); + failParseJWT( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid==base64!!.signature", + new IdentityRetrievalException("Unable to decode JWT from ORCID") + ); + final String invalidJSON = "{\"sub\":\"" + orcID + "\",\"amr\":}"; + final String encodedPayload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(invalidJSON.getBytes()); + final String invalidJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + encodedPayload + ".signature"; + failParseJWT(invalidJWT, new IdentityRetrievalException( + "Unable to parse JWT payload from ORCID") + ); + failParseJWT( + jwt(orcID, map("amr", Collections.emptyMap())), new IdentityRetrievalException( + "AMR claim from ORCID in unexpected format: {}")); + } + + private void failParseJWT(final String jwt, final Exception expected) throws Exception { + final String authCode = "authcode2"; + final IdentityProviderConfig idconfig = getTestIDConfig(true); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + + setUpCallAuthToken( + authCode, + "footoken3", + "https://ologinredir.com", + idconfig.getClientID(), + idconfig.getClientSecret(), + " My name ", + orcID, + jwt + ); + failGetIdentities( + idp, + authCode, + "pkce", + false, + expected + ); + } + @Test public void returnsBadResponseIdentity() throws Exception { final IdentityProviderConfig cfg = getTestIDConfig(); @@ -379,29 +451,80 @@ public void returnsBadResponseIdentity() throws Exception { @Test public void getIdentityWithLoginURL() throws Exception { - getIdentityWithLoginURL(null, map()); - getIdentityWithLoginURL(null, map("email", null)); - getIdentityWithLoginURL(null, map("email", Collections.emptyList())); - getIdentityWithLoginURL(null, map("email", Arrays.asList(map()))); - getIdentityWithLoginURL(null, map("email", Arrays.asList(map("email", null)))); - getIdentityWithLoginURL(null, map("email", Arrays.asList(map("email", " \t \n ")))); - getIdentityWithLoginURL("email3", map("email", Arrays.asList(map("email", "email3")))); + /* For now we only test MFA with login since it's only relevant there and the code + * path is identical to linking. The only difference is the redirect url transmitted to + * OrcID + */ + final String orcID = "0000-0001-1234-5678"; + // MFA checking is skipped + getIdentityWithLoginURL(orcID, null, map(), null, MFAStatus.UNKNOWN); + getIdentityWithLoginURL( + orcID, + null, + map("email", null), + jwt(orcID, map("iss", "https://orcid.org")), + MFAStatus.UNKNOWN + ); + getIdentityWithLoginURL( + orcID, + null, + map("email", Collections.emptyList()), + jwt(orcID, map("amr", "pwd")), + MFAStatus.NOT_USED + ); + getIdentityWithLoginURL( + orcID, + null, + map("email", Arrays.asList(map())), + jwt(orcID, map("amr", "mfa")), + MFAStatus.USED + ); + getIdentityWithLoginURL( + orcID, + null, + map("email", Arrays.asList(map("email", null))), + jwt(orcID, map("amr", list())), + MFAStatus.NOT_USED + ); + getIdentityWithLoginURL( + orcID, + null, + map("email", Arrays.asList(map("email", " \t \n "))), + jwt(orcID, map("amr", list("pwd"))), + MFAStatus.NOT_USED + ); + getIdentityWithLoginURL( + orcID, + "email3", + map("email", Arrays.asList(map("email", "email3"))), + jwt(orcID, map("amr", list("pwd", "mfa"))), + MFAStatus.USED + ); } - private void getIdentityWithLoginURL(final String email, final Map response) - throws Exception { + private void getIdentityWithLoginURL( + final String orcID, + final String email, + final Map identityResponse, + final String jwt, + final MFAStatus mfa + ) throws Exception { final String authCode = "authcode2"; - final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProviderConfig idconfig = getTestIDConfig(jwt != null); final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); - final String orcID = "0000-0001-1234-5678"; setUpCallAuthToken(authCode, "footoken3", "https://ologinredir.com", - idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID); - setupCallID("footoken3", orcID, APP_JSON, 200, MAPPER.writeValueAsString(response)); + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, jwt + ); + setupCallID( + "footoken3", orcID, APP_JSON, 200, MAPPER.writeValueAsString(identityResponse) + ); final IdentityProviderResponse ipr = idp.getIdentities(authCode, "pkce", false, null); assertThat("incorrect ident set", ipr, is(IdentityProviderResponse.from( new RemoteIdentity(new RemoteIdentityID(ORCID, orcID), - new RemoteIdentityDetails(orcID, "My name", email)) + new RemoteIdentityDetails(orcID, "My name", email) + ), + mfa ))); } @@ -445,6 +568,7 @@ private void getIdentityWithLinkURL(final String email, final Map resp = map( + "access_token", authtoken, + "name", name, + "orcid", orcID + ); + if (idToken != null) { + resp.put("id_token", idToken); + } mockClientAndServer.when( new HttpRequest() .withMethod("POST") @@ -515,11 +664,7 @@ private void setUpCallAuthToken( new HttpResponse() .withStatusCode(200) .withHeader(CONTENT_TYPE, APP_JSON) - .withBody(MAPPER.writeValueAsString(map( - "access_token", authtoken, - "name", name, - "orcid", orcID - ))) + .withBody(MAPPER.writeValueAsString(resp)) ); } @@ -576,6 +721,18 @@ private void setupCallID( ); } + private String jwt(final String orcID, final Map payload) throws Exception { + final String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; + payload.put("sub", orcID); + final String paystr = MAPPER.writeValueAsString(payload); + + final String encodedHeader = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(header.getBytes()); + final String encodedPayload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(paystr.getBytes()); + return encodedHeader + "." + encodedPayload + ".signature"; + } + private Map map(final Object... entries) { if (entries.length % 2 != 0) { throw new IllegalArgumentException();