001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.server.auth.oidc.rp;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.net.*;
022import java.text.*;
023import java.util.*;
024
025import org.apache.juneau.commons.utils.*;
026import org.apache.juneau.rest.server.auth.*;
027
028import com.nimbusds.jose.*;
029import com.nimbusds.jose.jwk.*;
030import com.nimbusds.jose.jwk.source.*;
031import com.nimbusds.jose.proc.*;
032import com.nimbusds.jwt.*;
033import com.nimbusds.oauth2.sdk.id.*;
034import com.nimbusds.openid.connect.sdk.*;
035import com.nimbusds.openid.connect.sdk.claims.*;
036import com.nimbusds.openid.connect.sdk.validators.*;
037
038/**
039 * Wraps Nimbus's {@link IDTokenValidator} to validate an OpenID Connect ID token's signature and claims
040 * and project the verified claims onto a Juneau {@link ClaimsPrincipal}.
041 *
042 * <p>
043 * Nimbus's {@code IDTokenValidator} performs the full OIDC ID-token validation in one call: signature
044 * verification against the IdP JWKS, exact {@code iss} match, {@code aud} contains the client id,
045 * {@code azp} when there are multiple audiences, {@code exp} (with clock skew), required {@code iat},
046 * and &mdash; when supplied &mdash; {@code nonce} match.  This adapter owns only the configuration and
047 * the projection to {@link ClaimsPrincipal}; no bespoke crypto.
048 *
049 * <p>
050 * The signing-algorithm allowlist defaults to {@code [RS256, ES256]} (both SHA-256 based).  The unsafe
051 * {@code "none"} algorithm is permanently rejected (a token signed with {@code none} has no key and so
052 * fails the key selector), and SHA-1-family algorithms are excluded by virtue of not being on the
053 * allowlist &mdash; consistent with the {@code juneau-rest-server-auth-jwt} strict-default stance.
054 *
055 * <p>
056 * For production, configure {@link Builder#jwksUri(URI)} (Nimbus builds a cached remote key source).
057 * For tests / HSM-backed signers / multi-issuer setups, inject a {@link Builder#jwkSet(JWKSet)} or
058 * {@link Builder#jwkSource(JWKSource)} directly.
059 *
060 * @since 10.0.0
061 */
062@SuppressWarnings({
063   "java:S1192" // Duplicate string literals are OIDC/JWT claim names (e.g. "iss", "aud", "exp"); intentional
064})
065public class IdTokenValidatorAdapter {
066
067   /**
068    * Static creator.
069    *
070    * @return A new builder.
071    */
072   public static Builder create() {
073      return new Builder();
074   }
075
076   /**
077    * Builder.
078    */
079   public static class Builder {
080      private String issuer;
081      private String clientId;
082      private URI jwksUri;
083      private JWKSet jwkSet;
084      private JWKSource<SecurityContext> jwkSource;
085      private Set<JWSAlgorithm> algorithms = new LinkedHashSet<>(Arrays.asList(JWSAlgorithm.RS256, JWSAlgorithm.ES256));
086      private int maxClockSkewSeconds = 60;
087
088      /** Constructor. */
089      protected Builder() {}
090
091      /**
092       * Sets the expected {@code iss} (issuer).  Required.
093       *
094       * @param value The issuer.  Must not be <jk>null</jk> or blank.
095       * @return This object.
096       */
097      public Builder issuer(String value) {
098         issuer = reqnb("value", value);
099         return this;
100      }
101
102      /**
103       * Sets the OAuth client id (expected {@code aud}).  Required.
104       *
105       * @param value The client id.  Must not be <jk>null</jk> or blank.
106       * @return This object.
107       */
108      public Builder clientId(String value) {
109         clientId = reqnb("value", value);
110         return this;
111      }
112
113      /**
114       * Sets the JWKS endpoint URI the validator fetches signing keys from.  Mutually exclusive with
115       * {@link #jwkSet(JWKSet)} / {@link #jwkSource(JWKSource)}.
116       *
117       * <p>
118       * The URI must use <js>"https"</js> or target a loopback host; the signing-key material fetched from
119       * this endpoint is the trust anchor for every ID token, so a plaintext transport to a non-loopback host
120       * is rejected.
121       *
122       * @param value The JWKS URI.  Must not be <jk>null</jk> and must use HTTPS or target a loopback host.
123       * @return This object.
124       */
125      public Builder jwksUri(URI value) {
126         jwksUri = UriUtils.assertSecureOrLoopback(reqnn("value", value));
127         return this;
128      }
129
130      /**
131       * Sets a fixed JWK set (useful for tests / static keys).  Mutually exclusive with
132       * {@link #jwksUri(URI)} / {@link #jwkSource(JWKSource)}.
133       *
134       * @param value The JWK set.  Must not be <jk>null</jk>.
135       * @return This object.
136       */
137      public Builder jwkSet(JWKSet value) {
138         jwkSet = reqnn("value", value);
139         return this;
140      }
141
142      /**
143       * Sets a custom {@link JWKSource}.  Mutually exclusive with {@link #jwksUri(URI)} /
144       * {@link #jwkSet(JWKSet)}.
145       *
146       * @param value The JWK source.  Must not be <jk>null</jk>.
147       * @return This object.
148       */
149      public Builder jwkSource(JWKSource<SecurityContext> value) {
150         jwkSource = reqnn("value", value);
151         return this;
152      }
153
154      /**
155       * Sets the signing-algorithm allowlist.  Replaces the default {@code [RS256, ES256]}.
156       *
157       * @param values The allowed algorithms.  Must be non-empty and must not contain
158       *    {@link Algorithm#NONE}.
159       * @return This object.
160       */
161      public Builder algorithms(JWSAlgorithm...values) {
162         reqnn("values", values);
163         req(values.length > 0, "algorithms allowlist must be non-empty");
164         var next = new LinkedHashSet<JWSAlgorithm>();
165         for (var a : values) {
166            reqnn("algorithm", a);
167            req(! Algorithm.NONE.equals(a), "\"none\" algorithm is permanently rejected");
168            next.add(a);
169         }
170         algorithms = next;
171         return this;
172      }
173
174      /**
175       * Sets the clock-skew tolerance (in seconds) for {@code exp} / {@code iat} validation.
176       *
177       * @param value The tolerance in seconds.  Must be non-negative.
178       * @return This object.
179       */
180      public Builder maxClockSkewSeconds(int value) {
181         req(value >= 0, "maxClockSkewSeconds must be non-negative (was %s)", value);
182         maxClockSkewSeconds = value;
183         return this;
184      }
185
186      /**
187       * Builds the adapter.
188       *
189       * @return A new {@link IdTokenValidatorAdapter}.
190       */
191      public IdTokenValidatorAdapter build() {
192         if (issuer == null)
193            throw new IllegalStateException("IdTokenValidatorAdapter requires issuer(...)");
194         if (clientId == null)
195            throw new IllegalStateException("IdTokenValidatorAdapter requires clientId(...)");
196         var sources = 0;
197         if (jwksUri != null) sources++;
198         if (jwkSet != null) sources++;
199         if (jwkSource != null) sources++;
200         if (sources != 1)
201            throw new IllegalStateException("IdTokenValidatorAdapter requires exactly one of jwksUri(...), jwkSet(...), or jwkSource(...)");
202         return new IdTokenValidatorAdapter(this);
203      }
204   }
205
206   private final IDTokenValidator validator;
207
208   /**
209    * Constructor.
210    *
211    * @param b The builder.
212    */
213   protected IdTokenValidatorAdapter(Builder b) {
214      JWKSource<SecurityContext> source = resolveSource(b);
215      var keySelector = new JWSVerificationKeySelector<SecurityContext>(b.algorithms, source);
216      this.validator = new IDTokenValidator(new Issuer(b.issuer), new ClientID(b.clientId), keySelector, null);
217      this.validator.setMaxClockSkew(b.maxClockSkewSeconds);
218   }
219
220   private static JWKSource<SecurityContext> resolveSource(Builder b) {
221      if (b.jwkSource != null)
222         return b.jwkSource;
223      if (b.jwkSet != null)
224         return new ImmutableJWKSet<>(b.jwkSet);
225      try {
226         return JWKSourceBuilder.create(b.jwksUri.toURL()).build();
227      } catch (MalformedURLException e) {
228         throw iaex(e, "Invalid JWKS URI: %s", b.jwksUri);
229      }
230   }
231
232   /**
233    * Validates an ID token and projects the verified claims onto a {@link ClaimsPrincipal}.
234    *
235    * @param idToken The raw (compact) ID token JWT.  Must not be <jk>null</jk> or blank.
236    * @param expectedNonce The {@code nonce} the relying party stored before the redirect; the ID
237    *    token's {@code nonce} claim must match.  May be <jk>null</jk> only if the flow did not use a
238    *    nonce (the RP always supplies one).
239    * @return The verified claims as a {@link ClaimsPrincipal} whose name is the {@code sub} claim.
240    * @throws AuthenticationException If parsing, signature verification, or any claim check fails.
241    */
242   public ClaimsPrincipal validate(String idToken, String expectedNonce) throws AuthenticationException {
243      reqnb("idToken", idToken);
244      JWT jwt;
245      try {
246         jwt = JWTParser.parse(idToken);
247      } catch (ParseException e) {
248         throw new AuthenticationException(e, "ID token could not be parsed");
249      }
250      IDTokenClaimsSet claims;
251      try {
252         claims = validator.validate(jwt, expectedNonce == null ? null : new Nonce(expectedNonce));
253      } catch (BadJOSEException e) {
254         throw new AuthenticationException(e, "ID token validation failed: %s", e.getMessage());
255      } catch (JOSEException e) {
256         throw new AuthenticationException(e, "ID token signature verification failed");
257      }
258      var sub = claims.getSubject().getValue();
259      return new ClaimsPrincipal(sub, claims.toJSONObject());
260   }
261}