diff --git a/cadc-gms/src/main/java/org/opencadc/auth/OIDCProviderPubKey.java b/cadc-gms/src/main/java/org/opencadc/auth/OIDCProviderPubKey.java
new file mode 100644
index 00000000..b351db20
--- /dev/null
+++ b/cadc-gms/src/main/java/org/opencadc/auth/OIDCProviderPubKey.java
@@ -0,0 +1,135 @@
+/*
+ ************************************************************************
+ ******************* CANADIAN ASTRONOMY DATA CENTRE *******************
+ ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES **************
+ *
+ * (c) 2024. (c) 2024.
+ * Government of Canada Gouvernement du Canada
+ * National Research Council Conseil national de recherches
+ * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6
+ * All rights reserved Tous droits réservés
+ *
+ * NRC disclaims any warranties, Le CNRC dénie toute garantie
+ * expressed, implied, or énoncée, implicite ou légale,
+ * statutory, of any kind with de quelque nature que ce
+ * respect to the software, soit, concernant le logiciel,
+ * including without limitation y compris sans restriction
+ * any warranty of merchantability toute garantie de valeur
+ * or fitness for a particular marchande ou de pertinence
+ * purpose. NRC shall not be pour un usage particulier.
+ * liable in any event for any Le CNRC ne pourra en aucun cas
+ * damages, whether direct or être tenu responsable de tout
+ * indirect, special or general, dommage, direct ou indirect,
+ * consequential or incidental, particulier ou général,
+ * arising from the use of the accessoire ou fortuit, résultant
+ * software. Neither the name de l'utilisation du logiciel. Ni
+ * of the National Research le nom du Conseil National de
+ * Council of Canada nor the Recherches du Canada ni les noms
+ * names of its contributors may de ses participants ne peuvent
+ * be used to endorse or promote être utilisés pour approuver ou
+ * products derived from this promouvoir les produits dérivés
+ * software without specific prior de ce logiciel sans autorisation
+ * written permission. préalable et particulière
+ * par écrit.
+ *
+ * This file is part of the Ce fichier fait partie du projet
+ * OpenCADC project. OpenCADC.
+ *
+ * OpenCADC is free software: OpenCADC est un logiciel libre ;
+ * you can redistribute it and/or vous pouvez le redistribuer ou le
+ * modify it under the terms of modifier suivant les termes de
+ * the GNU Affero General Public la “GNU Affero General Public
+ * License as published by the License” telle que publiée
+ * Free Software Foundation, par la Free Software Foundation
+ * either version 3 of the : soit la version 3 de cette
+ * License, or (at your option) licence, soit (à votre gré)
+ * any later version. toute version ultérieure.
+ *
+ * OpenCADC is distributed in the OpenCADC est distribué
+ * hope that it will be useful, dans l’espoir qu’il vous
+ * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE
+ * without even the implied GARANTIE : sans même la garantie
+ * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ
+ * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF
+ * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence
+ * General Public License for Générale Publique GNU Affero
+ * more details. pour plus de détails.
+ *
+ * You should have received Vous devriez avoir reçu une
+ * a copy of the GNU Affero copie de la Licence Générale
+ * General Public License along Publique GNU Affero avec
+ * with OpenCADC. If not, see OpenCADC ; si ce n’est
+ * . pas le cas, consultez :
+ * .
+ *
+ ************************************************************************
+ */
+
+package org.opencadc.auth;
+
+import ca.nrc.cadc.reg.client.CachingFile;
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URL;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.apache.log4j.Logger;
+
+
+/**
+ * Cache object that manages the cache file for the public keys of the OIDC Provider.
+ */
+public class OIDCProviderPubKey {
+ private static final Logger log = Logger.getLogger(OIDCProviderPubKey.class);
+ private static final String CACHE_DIRECTORY_NAME = "cadc-gms-1.0";
+ private URL jwksUrl;
+
+ final URI issuer;
+
+ public OIDCProviderPubKey(URI issuer, URL jwksUrl) {
+ if (issuer == null) {
+ throw new IllegalStateException("OIDC Issuer URI is required.");
+ }
+ if (jwksUrl == null) {
+ throw new IllegalStateException("JWKS URI is required.");
+ }
+ this.issuer = issuer;
+ this.jwksUrl = jwksUrl;
+ }
+
+ public CachingFile getCachingFile() {
+ return new CachingFile(getCachedFile(), jwksUrl);
+ }
+
+ private File getCachedFile() {
+ final Path baseCacheDir = getBaseCacheDirectory();
+ final String issuerAuthority = this.issuer.getAuthority();
+ final Path resourceCacheDir = Paths.get(baseCacheDir.toString(), issuerAuthority);
+
+ // Create a path to the cache file itself
+ final Path path = Paths.get(resourceCacheDir.toString(), this.issuer.getPath(), "jwks.json");
+ log.debug("Caching file [" + path + "] in dir [" + resourceCacheDir + "]");
+
+ return path.toFile();
+ }
+
+ private Path getBaseCacheDirectory() {
+ final String tmpDir = System.getProperty("java.io.tmpdir");
+ final String userName = System.getProperty("user.name");
+
+ if (tmpDir == null) {
+ throw new RuntimeException("No tmp system dir defined.");
+ }
+
+ final Path baseCacheDir;
+ if (userName == null) {
+ baseCacheDir = Paths.get(tmpDir, OIDCProviderPubKey.CACHE_DIRECTORY_NAME);
+ } else {
+ baseCacheDir = Paths.get(tmpDir, userName, OIDCProviderPubKey.CACHE_DIRECTORY_NAME);
+ }
+
+ log.debug("Base cache dir: " + baseCacheDir);
+ return baseCacheDir;
+ }
+}
diff --git a/cadc-gms/src/main/java/org/opencadc/auth/StandardIdentityManager.java b/cadc-gms/src/main/java/org/opencadc/auth/StandardIdentityManager.java
index 3b6c8431..1a698c60 100644
--- a/cadc-gms/src/main/java/org/opencadc/auth/StandardIdentityManager.java
+++ b/cadc-gms/src/main/java/org/opencadc/auth/StandardIdentityManager.java
@@ -76,13 +76,19 @@
import ca.nrc.cadc.auth.NotAuthenticatedException;
import ca.nrc.cadc.auth.OpenIdPrincipal;
import ca.nrc.cadc.auth.PosixPrincipal;
+import ca.nrc.cadc.net.HttpGet;
+import ca.nrc.cadc.net.ResourceAlreadyExistsException;
+import ca.nrc.cadc.net.ResourceNotFoundException;
import ca.nrc.cadc.reg.Standards;
import ca.nrc.cadc.reg.client.LocalAuthority;
import ca.nrc.cadc.reg.client.RegistryClient;
+import ca.nrc.cadc.util.StringUtil;
import java.io.IOException;
+import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
+import java.security.Key;
import java.security.Principal;
import java.security.PrivilegedExceptionAction;
import java.util.ArrayList;
@@ -95,16 +101,21 @@
import javax.security.auth.Subject;
import org.apache.log4j.Logger;
import org.jose4j.jwa.AlgorithmConstraints;
-import org.jose4j.jwk.HttpsJwks;
+import org.jose4j.jwk.JsonWebKey;
+import org.jose4j.jwk.JsonWebKeySet;
+import org.jose4j.jwk.VerificationJwkSelector;
import org.jose4j.jws.AlgorithmIdentifiers;
+import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.MalformedClaimException;
-import org.jose4j.jwt.consumer.ErrorCodes;
import org.jose4j.jwt.consumer.InvalidJwtException;
import org.jose4j.jwt.consumer.JwtConsumer;
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import org.jose4j.jwt.consumer.JwtContext;
-import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver;
+import org.jose4j.jwx.JsonWebStructure;
+import org.jose4j.keys.resolvers.VerificationKeyResolver;
+import org.jose4j.lang.JoseException;
+import org.jose4j.lang.UnresolvableKeyException;
import org.json.JSONObject;
/**
@@ -168,7 +179,7 @@ private String getProviderHostname(LocalAuthority loc, URI standardID) {
@Override
public Subject validate(Subject subject) throws NotAuthenticatedException {
- validateOidcAccessToken(subject);
+ validateJwtAccessToken(subject);
return subject;
}
@@ -316,114 +327,133 @@ private URI getJwtIssuer(String jwtToken) throws InvalidJwtException, MalformedC
return URI.create(jwtContext.getJwtClaims().getIssuer());
}
- private void validateOidcAccessToken(Subject s) {
+ private void validateJwtAccessToken(Subject s) {
log.debug("validateOidcAccessToken - START");
Set rawTokens = s.getPrincipals(AuthorizationTokenPrincipal.class);
-
- if (rawTokens.isEmpty()) {
- log.debug("validateOidcAccessToken - no tokens to validate");
- return;
- }
-
+ boolean validated = false;
for (AuthorizationTokenPrincipal raw : rawTokens) {
- String credentials;
- String challengeType;
- // parse header
- log.debug("header key: " + raw.getHeaderKey());
- log.debug("header val: " + raw.getHeaderValue());
-
if (AuthenticationUtil.AUTHORIZATION_HEADER.equalsIgnoreCase(raw.getHeaderKey())) {
String[] tval = raw.getHeaderValue().split(" ");
if (tval.length == 2) {
- challengeType = tval[0];
- credentials = tval[1];
+ if ("Bearer".equalsIgnoreCase(tval[0])) {
+ validate(s, raw);
+ if (!validated) {
+ validated = true;
+ } else {
+ throw new NotAuthenticatedException(raw.getHeaderValue(), NotAuthenticatedException.AuthError.INVALID_REQUEST,
+ "Multiple bearer token authorization headers not supported");
+ }
+ } // else not a bearer token
} else {
throw new NotAuthenticatedException(raw.getHeaderValue(), NotAuthenticatedException.AuthError.INVALID_REQUEST,
- "invalid authorization");
+ "BUG: invalid authorization " + raw.getHeaderValue());
}
- } else {
- throw new NotAuthenticatedException("Unsupported authorization header: " + raw.getHeaderKey());
- }
- log.debug("challenge type: " + challengeType);
- log.debug("credentials: " + credentials);
+ } // else other challenge
+ }
+ }
+
+ private void validate(Subject s, AuthorizationTokenPrincipal raw) {
+ // this method validates a single token using either the issuer public key (preferred because the key is
+ // cached locally) or by calling the user info endpoint and updates the subject accordingly
+ String[] tval = raw.getHeaderValue().split(" ");
+ String challengeType = tval[0];
+ String credentials = tval[1];
+ log.debug("challenge type: " + challengeType);
+ log.debug("credentials: " + credentials);
+
+ URI jwtIssuer;
+ try {
+ jwtIssuer = getJwtIssuer(credentials);
+ } catch (MalformedClaimException | InvalidJwtException | MalformedURLException e) {
+ throw new NotAuthenticatedException(challengeType, NotAuthenticatedException.AuthError.INVALID_TOKEN,
+ "Cannot determine issuer from token", e);
+ }
+ List validatedPrincipals = null;
+ try {
+ validatedPrincipals = validateWithPubKey(raw, jwtIssuer, challengeType, credentials);
+ } catch (MalformedURLException | MalformedClaimException | InvalidJwtException e) {
+ log.debug("Cannot validate token with issuer public key", e);
+ }
+ if (validatedPrincipals == null) {
+ // public key validation did not work. Try the user info endpoint
try {
- URI jwtIssuer = getJwtIssuer(credentials);
-
- HttpsJwksVerificationKeyResolver httpsJwksKeyResolver = getHttpsJwksVerificationKeyResolver(jwtIssuer, challengeType);
- JwtConsumer jwtConsumer = new JwtConsumerBuilder()
- .setRequireExpirationTime()
- .setExpectedIssuers(true, jwtIssuer.toString())
- .setVerificationKeyResolver(httpsJwksKeyResolver)
- .setJwsAlgorithmConstraints(new AlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, // whitelist
- AlgorithmIdentifiers.RSA_USING_SHA256)) // RS256 only
- .build(); // create the JwtConsumer instance;
-
- // Validate the JWT and process it to the Claims
- JwtClaims jwtClaims = jwtConsumer.processToClaims(credentials);
- log.debug("JWT validation succeeded! " + jwtClaims);
-
- String sub = jwtClaims.getClaimValue("sub", String.class);
- OpenIdPrincipal oip = new OpenIdPrincipal(jwtIssuer.toURL(), sub);
-
- s.getPrincipals().remove(raw);
- s.getPrincipals().add(oip);
-
- if (jwtClaims.getClaimValueAsString("preferred_username") != null) {
- HttpPrincipal hp = new HttpPrincipal(jwtClaims.getClaimValueAsString("preferred_username"));
- s.getPrincipals().add(hp);
- }
+ validatedPrincipals = validateWithUserInfo(raw, jwtIssuer);
+ } catch (ResourceAlreadyExistsException | ResourceNotFoundException | IOException | InterruptedException e) {
+ throw new NotAuthenticatedException(challengeType, NotAuthenticatedException.AuthError.INVALID_TOKEN,
+ "Cannot validate token using user info endpoint", e);
+ }
+ }
- AuthorizationToken authToken = new AuthorizationToken(challengeType, credentials, oidcDomains, oidcScope);
- s.getPublicCredentials().add(authToken);
- log.debug("Validated user: " + oip);
- } catch (InvalidJwtException e) {
- String msg = "Invalid token";
-
- // Whether the JWT has expired being one common reason for invalidity
- if (e.hasExpired()) {
- try {
- msg = "Token expired at " + e.getJwtContext().getJwtClaims().getExpirationTime();
- } catch (MalformedClaimException ex) {
- msg = "BUG: Malformed JWT expiration time in token";
- }
- }
+ s.getPrincipals().remove(raw);
+ for (Principal p : validatedPrincipals) {
+ s.getPrincipals().add(p);
+ }
- // Or maybe the audience was invalid
- if (e.hasErrorCode(ErrorCodes.AUDIENCE_INVALID)) {
- try {
- msg = "JWT had wrong audience: " + e.getJwtContext().getJwtClaims().getAudience();
- } catch (MalformedClaimException ex) {
- msg = "BUG: Malformed Audience claim in token";
- }
- }
+ // TODO - oidcDomains and oidcScope are not assigned yet
+ AuthorizationToken authToken = new AuthorizationToken(challengeType, credentials, oidcDomains, oidcScope);
+ s.getPublicCredentials().add(authToken);
+ }
- throw new NotAuthenticatedException(challengeType, NotAuthenticatedException.AuthError.INVALID_TOKEN, msg, e);
+ private List validateWithPubKey(AuthorizationTokenPrincipal raw, URI jwtIssuer, String challengeType, String credentials) throws MalformedURLException, InvalidJwtException, MalformedClaimException {
+ VerificationKeyResolver httpsJwksKeyResolver = getHttpsJwksVerificationKeyResolver(jwtIssuer, challengeType);
+ JwtConsumer jwtConsumer = new JwtConsumerBuilder()
+ .setRequireExpirationTime()
+ .setExpectedIssuers(true, jwtIssuer.toString())
+ .setVerificationKeyResolver(httpsJwksKeyResolver)
+ .setJwsAlgorithmConstraints(new AlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, // whitelist
+ AlgorithmIdentifiers.RSA_USING_SHA256)) // RS256 only
+ .build(); // create the JwtConsumer instance;
- } catch (MalformedClaimException e) {
- throw new NotAuthenticatedException(challengeType, NotAuthenticatedException.AuthError.INVALID_TOKEN,
- "BUG: Malformed claim", e);
- } catch (MalformedURLException e) {
- throw new NotAuthenticatedException(challengeType, NotAuthenticatedException.AuthError.INVALID_TOKEN,
- "Invalid URL for token iss", e);
- }
+ // Validate the JWT and process it to the Claims
+ JwtClaims jwtClaims = jwtConsumer.processToClaims(credentials);
+ log.debug("JWT validation succeeded! " + jwtClaims);
+
+ String sub = jwtClaims.getClaimValue("sub", String.class);
+
+ List result = new ArrayList<>();
+ OpenIdPrincipal oip = new OpenIdPrincipal(jwtIssuer.toURL(), sub);
+ result.add(oip);
+
+ if (jwtClaims.getClaimValueAsString("preferred_username") != null) {
+ result.add(new HttpPrincipal(jwtClaims.getClaimValueAsString("preferred_username")));
}
+ log.debug("Validated user via issuer pub key: " + oip);
+ return result;
}
- private static HttpsJwksVerificationKeyResolver getHttpsJwksVerificationKeyResolver(URI jwtIssuer, String challengeType) {
+ private static List validateWithUserInfo(AuthorizationTokenPrincipal raw, URI jwtIssuer) throws ResourceAlreadyExistsException, ResourceNotFoundException, IOException, InterruptedException {
+ OIDCClient oidcClient = new OIDCClient(jwtIssuer);
+ URL userInfoEndpoint = oidcClient.getUserInfoEndpoint();
+ log.debug("User info endpoint: " + userInfoEndpoint);
+ HttpGet get = new HttpGet(userInfoEndpoint, true);
+ get.setRequestProperty("authorization", raw.getHeaderValue());
+ get.prepare();
+
+ InputStream istream = get.getInputStream();
+ String str = StringUtil.readFromInputStream(istream, "UTF-8");
+ JSONObject json = new JSONObject(str);
+ String sub = json.getString("sub");
+ String username = json.getString("preferred_username");
+ List result = new ArrayList<>();
+ OpenIdPrincipal oip = new OpenIdPrincipal(jwtIssuer.toURL(), sub);
+ result.add(oip);
+ if (username != null) {
+ result.add(new HttpPrincipal(username));
+ }
+ log.debug("Validated user via user info endpoint: " + oip);
+ return result;
+ }
+
+ private VerificationKeyResolver getHttpsJwksVerificationKeyResolver(URI jwtIssuer, String challengeType) throws
+ MalformedURLException{
JSONObject oidcConfig = getJsonObject(jwtIssuer, challengeType);
if (oidcConfig.getString("jwks_uri") == null) {
throw new NotAuthenticatedException(challengeType, NotAuthenticatedException.AuthError.INVALID_TOKEN,
"BUG: Missing jwks_uri in OIDC .well-known/openid-configuration", null);
}
-
- // TODO: HttpsJwks has a built-in cache. To use registry cache, we need to implement SimpleGet and SimpleResponse
- // interfaces and use the HttpsJwks.simpleGet() method to plug them in.
- // Alternatively, override HttpsJwks class especially the getJsonWebKeys() method to use the registry cache.
- HttpsJwks httpsJwks = new HttpsJwks(oidcConfig.getString("jwks_uri"));
- HttpsJwksVerificationKeyResolver httpsJwksKeyResolver =
- new HttpsJwksVerificationKeyResolver(httpsJwks);
- return httpsJwksKeyResolver;
+ URL jwksUrl = URI.create(oidcConfig.getString("jwks_uri")).toURL();
+ return new CacheVerificationKeyResolver(jwtIssuer, jwksUrl);
}
private static JSONObject getJsonObject(URI jwtIssuer, String challengeType) {
@@ -432,13 +462,49 @@ private static JSONObject getJsonObject(URI jwtIssuer, String challengeType) {
"Unsupported issuer: " + jwtIssuer, null);
}
OIDCClient oidcClient = new OIDCClient(jwtIssuer);
- JSONObject oidcConfig = null;
try {
- oidcConfig = oidcClient.getWellKnownJSON();
+ return oidcClient.getWellKnownJSON();
} catch (IOException e) {
throw new NotAuthenticatedException(challengeType, NotAuthenticatedException.AuthError.INVALID_TOKEN,
"BUG: Cannot access OIDC .well-known/openid-configuration end point", e);
}
- return oidcConfig;
+ }
+
+ static class CacheVerificationKeyResolver implements VerificationKeyResolver {
+ // uses a local cached copy of the public keys from the OIDC provider
+ private final VerificationJwkSelector verificationJwkSelector = new VerificationJwkSelector();
+ private boolean disambiguateWithVerifySignature;
+ private final OIDCProviderPubKey oidcProviderPubKey;
+
+ public CacheVerificationKeyResolver(URI jwtIssuer, URL jwksUrl) {
+ this.oidcProviderPubKey = new OIDCProviderPubKey(jwtIssuer, jwksUrl);
+ }
+
+ @Override
+ public Key resolveKey(JsonWebSignature jsonWebSignature, List list) throws UnresolvableKeyException {
+ JsonWebKeySet jwks = null;
+ try {
+ jwks = new JsonWebKeySet(this.oidcProviderPubKey.getCachingFile().getContent());
+ List keys = jwks.getJsonWebKeys();
+ JsonWebKey jwk = select(jsonWebSignature, keys);
+ if (jwk == null) {
+ throw new UnresolvableKeyException("No key found for signature");
+ } else {
+ return jwk.getKey();
+ }
+ } catch (JoseException | IOException e) {
+ throw new UnresolvableKeyException("Bug: Error selecting key", e);
+ }
+ }
+
+ protected JsonWebKey select(JsonWebSignature jws, List jsonWebKeys) throws JoseException {
+ return this.disambiguateWithVerifySignature ?
+ this.verificationJwkSelector.selectWithVerifySignatureDisambiguate(jws, jsonWebKeys) :
+ this.verificationJwkSelector.select(jws, jsonWebKeys);
+ }
+
+ public void setDisambiguateWithVerifySignature(boolean disambiguateWithVerifySignature) {
+ this.disambiguateWithVerifySignature = disambiguateWithVerifySignature;
+ }
}
}