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 — when supplied — {@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 — 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}