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.auth.oauth.flow; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020import static org.apache.juneau.commons.utils.UriUtils.*; 021 022import java.net.*; 023import java.time.*; 024import java.util.*; 025import java.util.function.*; 026 027import com.nimbusds.oauth2.sdk.*; 028import com.nimbusds.oauth2.sdk.auth.*; 029import com.nimbusds.oauth2.sdk.http.*; 030import com.nimbusds.oauth2.sdk.id.*; 031import com.nimbusds.oauth2.sdk.pkce.*; 032import com.nimbusds.openid.connect.sdk.*; 033 034/** 035 * Authorization-code grant (RFC 6749 §4.1) flow helper with mandatory PKCE per RFC 7636. 036 * 037 * <p> 038 * Wraps Nimbus's {@link AuthorizationRequest} (for URL construction) + {@link AuthorizationCodeGrant} + 039 * {@link TokenRequest} (for the exchange) + {@link CodeChallenge#compute(CodeChallengeMethod, CodeVerifier) 040 * PKCE S256 challenge generation}. {@link #buildAuthorizationUrl(String, CodeChallenge)} emits a plain OAuth 041 * 2.0 authorization request; {@link #buildAuthenticationUrl(String, CodeChallenge, String, Consumer)} emits an 042 * OpenID Connect authentication request carrying a {@code nonce}. Supports RFC 8707 resource-indicator binding 043 * via {@link Builder#resource(URI)}. 044 * 045 * <h5 class='topic'>Usage</h5> 046 * 047 * <p class='bjava'> 048 * <jk>var</jk> flow = OAuthAuthorizationCodeFlow.<jsm>create</jsm>() 049 * .authorizationEndpoint(URI.<jsm>create</jsm>(<js>"https://idp.example.com/oauth2/authorize"</js>)) 050 * .tokenEndpoint(URI.<jsm>create</jsm>(<js>"https://idp.example.com/oauth2/token"</js>)) 051 * .clientId(<js>"web-app"</js>) 052 * .redirectUri(URI.<jsm>create</jsm>(<js>"https://app.example.com/callback"</js>)) 053 * .scope(<js>"openid"</js>, <js>"profile"</js>) 054 * .build(); 055 * 056 * <jk>var</jk> verifier = <jk>new</jk> CodeVerifier(); 057 * <jk>var</jk> challenge = CodeChallenge.<jsm>compute</jsm>(CodeChallengeMethod.<jsf>S256</jsf>, verifier); 058 * <jk>var</jk> authUrl = flow.buildAuthorizationUrl(<js>"some-state"</js>, challenge); 059 * 060 * <jc>// After the IdP redirects back with ?code=...</jc> 061 * <jk>var</jk> token = flow.exchange(<jv>code</jv>, verifier); 062 * </p> 063 * 064 * <p> 065 * The flow does NOT manage state / PKCE-verifier storage; that's the caller's responsibility. 066 * 067 * @since 10.0.0 068 */ 069@SuppressWarnings({ 070 "deprecation", // Nimbus CodeChallenge overload is deprecated; no simpler alternative yet. 071 "java:S1192", // Duplicate string literals are OAuth protocol parameter names (e.g. "code", "grant_type"); intentional 072 "java:S9358" // Ternary chooses a different Scope constructor arity; moving it inside one call is not equivalent. 073}) 074public class OAuthAuthorizationCodeFlow { 075 076 /** 077 * Static creator. 078 * 079 * @return A new builder. 080 */ 081 public static Builder create() { 082 return new Builder(); 083 } 084 085 /** 086 * Builder. 087 */ 088 public static class Builder { 089 private URI authorizationEndpoint; 090 private URI tokenEndpoint; 091 private String clientId; 092 private Supplier<String> clientSecretSupplier; 093 private URI redirectUri; 094 private final Set<String> scopes = st(); 095 private URI resource; 096 private Duration httpTimeout = Flows.DEFAULT_HTTP_TIMEOUT; 097 private Consumer<HTTPRequest> httpRequestConfigurator; 098 099 /** Constructor. */ 100 protected Builder() {} 101 102 /** 103 * Sets the authorization endpoint URL. Required. 104 * 105 * @param value The endpoint URL. 106 * @return This object. 107 */ 108 public Builder authorizationEndpoint(URI value) { 109 authorizationEndpoint = assertSecureOrLoopback(reqnn("value", value)); 110 return this; 111 } 112 113 /** 114 * Sets the token endpoint URL. Required. 115 * 116 * @param value The endpoint URL. 117 * @return This object. 118 */ 119 public Builder tokenEndpoint(URI value) { 120 tokenEndpoint = assertSecureOrLoopback(reqnn("value", value)); 121 return this; 122 } 123 124 /** 125 * Sets the OAuth client ID. Required. 126 * 127 * @param value The client ID. 128 * @return This object. 129 */ 130 public Builder clientId(String value) { 131 clientId = reqnb("value", value); 132 return this; 133 } 134 135 /** 136 * Sets the client secret. May be omitted for public clients (PKCE-only). 137 * 138 * @param value The client secret. 139 * @return This object. 140 */ 141 public Builder clientSecret(String value) { 142 reqnb("value", value); 143 clientSecretSupplier = () -> value; 144 return this; 145 } 146 147 /** 148 * Sets the client secret via a supplier. 149 * 150 * @param value The supplier. 151 * @return This object. 152 */ 153 public Builder clientSecretSupplier(Supplier<String> value) { 154 clientSecretSupplier = reqnn("value", value); 155 return this; 156 } 157 158 /** 159 * Sets the redirect URI. Required. 160 * 161 * @param value The redirect URI. 162 * @return This object. 163 */ 164 public Builder redirectUri(URI value) { 165 redirectUri = reqnn("value", value); 166 return this; 167 } 168 169 /** 170 * Adds requested scopes. 171 * 172 * @param values The scopes. 173 * @return This object. 174 */ 175 public Builder scope(String... values) { 176 reqnn("values", values); 177 for (var v : values) { 178 reqnb("scope", v); 179 scopes.add(v); 180 } 181 return this; 182 } 183 184 /** 185 * Sets the RFC 8707 {@code resource} indicator sent on both the authorization request and the token exchange. 186 * 187 * @param value The canonical resource URI. Must not be <jk>null</jk>. 188 * @return This object. 189 */ 190 public Builder resource(URI value) { 191 resource = reqnn("value", value); 192 return this; 193 } 194 195 /** 196 * Sets the connect/read timeout applied to the token-exchange request. Default 10 seconds. 197 * 198 * @param value The timeout. Must not be <jk>null</jk> and must be positive. 199 * @return This object. 200 */ 201 public Builder httpTimeout(Duration value) { 202 reqnn("value", value); 203 req(!value.isZero() && !value.isNegative(), "httpTimeout must be positive (was %s)", value); 204 httpTimeout = value; 205 return this; 206 } 207 208 /** 209 * Sets the HTTP request configurator (applied to the token-exchange request). 210 * 211 * @param value The callback. 212 * @return This object. 213 */ 214 public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) { 215 httpRequestConfigurator = reqnn("value", value); 216 return this; 217 } 218 219 /** 220 * Builds the flow. 221 * 222 * @return A new {@link OAuthAuthorizationCodeFlow}. 223 */ 224 public OAuthAuthorizationCodeFlow build() { 225 if (authorizationEndpoint == null) 226 throw new IllegalStateException("OAuthAuthorizationCodeFlow requires authorizationEndpoint(...)"); 227 if (tokenEndpoint == null) 228 throw new IllegalStateException("OAuthAuthorizationCodeFlow requires tokenEndpoint(...)"); 229 if (clientId == null) 230 throw new IllegalStateException("OAuthAuthorizationCodeFlow requires clientId(...)"); 231 if (redirectUri == null) 232 throw new IllegalStateException("OAuthAuthorizationCodeFlow requires redirectUri(...)"); 233 return new OAuthAuthorizationCodeFlow(this); 234 } 235 } 236 237 private final URI authorizationEndpoint; 238 private final URI tokenEndpoint; 239 private final String clientId; 240 private final Supplier<String> clientSecretSupplier; 241 private final URI redirectUri; 242 private final Set<String> scopes; 243 private final URI resource; 244 private final Duration httpTimeout; 245 private final Consumer<HTTPRequest> httpRequestConfigurator; 246 247 /** 248 * Constructor. 249 * 250 * @param b The builder. 251 */ 252 protected OAuthAuthorizationCodeFlow(Builder b) { 253 this.authorizationEndpoint = b.authorizationEndpoint; 254 this.tokenEndpoint = b.tokenEndpoint; 255 this.clientId = b.clientId; 256 this.clientSecretSupplier = b.clientSecretSupplier; 257 this.redirectUri = b.redirectUri; 258 this.scopes = u(cp(b.scopes)); 259 this.resource = b.resource; 260 this.httpTimeout = b.httpTimeout; 261 this.httpRequestConfigurator = b.httpRequestConfigurator; 262 } 263 264 /** 265 * Builds the {@code authorize?...} URL the user-agent should be redirected to. 266 * 267 * @param state The opaque {@code state} parameter (CSRF protection). Must not be <jk>null</jk> or 268 * blank. 269 * @param codeChallenge The PKCE code challenge. Must not be <jk>null</jk>. Always uses S256. 270 * @return The authorization URL. 271 */ 272 public URI buildAuthorizationUrl(String state, CodeChallenge codeChallenge) { 273 reqnb("state", state); 274 reqnn("codeChallenge", codeChallenge); 275 Scope nimbusScope = scopes.isEmpty() ? null : new Scope(scopes.toArray(new String[0])); 276 var requestBuilder = new AuthorizationRequest.Builder(new ResponseType(ResponseType.Value.CODE), new ClientID(clientId)) 277 .endpointURI(authorizationEndpoint) 278 .redirectionURI(redirectUri) 279 .scope(nimbusScope) 280 .state(new State(state)) 281 .codeChallenge(codeChallenge, CodeChallengeMethod.S256); 282 if (resource != null) 283 requestBuilder.resource(resource); 284 return requestBuilder.build().toURI(); 285 } 286 287 /** 288 * Builds an OpenID Connect {@code authorize?...} URL carrying an OIDC {@code nonce} parameter. 289 * 290 * <p> 291 * Unlike {@link #buildAuthorizationUrl(String, CodeChallenge)} (which emits a plain OAuth 2.0 292 * {@link AuthorizationRequest} with no {@code nonce}), this method emits an OIDC 293 * {@link AuthenticationRequest} so the IdP echoes the supplied {@code nonce} back inside the issued 294 * ID token. An OpenID Connect relying party stores the {@code nonce} before the redirect and 295 * verifies the {@code nonce} claim on the returned ID token to defeat token replay. 296 * 297 * <p> 298 * The configured {@code scope(...)} set is used as-is; OIDC requires it to contain {@code openid}. 299 * The optional {@code customizer} receives the underlying {@link com.nimbusds.openid.connect.sdk.AuthenticationRequest.Builder} so 300 * callers can set OIDC-specific parameters (e.g. {@code prompt}, {@code max_age}, {@code acr_values}) 301 * without this module needing to surface each one. 302 * 303 * @param state The opaque {@code state} parameter (CSRF protection). Must not be <jk>null</jk> or 304 * blank. 305 * @param codeChallenge The PKCE code challenge. Must not be <jk>null</jk>. Always uses S256. 306 * @param nonce The OIDC {@code nonce} value. Must not be <jk>null</jk> or blank. 307 * @param customizer Optional hook on the {@link com.nimbusds.openid.connect.sdk.AuthenticationRequest.Builder} for OIDC-specific 308 * parameters. May be <jk>null</jk>. 309 * @return The authorization URL. 310 */ 311 public URI buildAuthenticationUrl(String state, CodeChallenge codeChallenge, String nonce, Consumer<AuthenticationRequest.Builder> customizer) { 312 reqnb("state", state); 313 reqnn("codeChallenge", codeChallenge); 314 reqnb("nonce", nonce); 315 Scope nimbusScope = scopes.isEmpty() ? new Scope("openid") : new Scope(scopes.toArray(new String[0])); 316 var builder = new AuthenticationRequest.Builder(new ResponseType(ResponseType.Value.CODE), nimbusScope, new ClientID(clientId), redirectUri) 317 .endpointURI(authorizationEndpoint) 318 .state(new State(state)) 319 .nonce(new Nonce(nonce)) 320 .codeChallenge(codeChallenge, CodeChallengeMethod.S256); 321 if (customizer != null) 322 customizer.accept(builder); 323 return builder.build().toURI(); 324 } 325 326 /** 327 * Exchanges the authorization code returned by the IdP for an access token. 328 * 329 * @param code The {@code code} query parameter from the IdP redirect. Must not be <jk>null</jk> or 330 * blank. 331 * @param codeVerifier The PKCE verifier matching the challenge passed to 332 * {@link #buildAuthorizationUrl(String, CodeChallenge)}. Must not be <jk>null</jk>. 333 * @return The acquired token. 334 */ 335 public OAuthToken exchange(String code, CodeVerifier codeVerifier) { 336 reqnb("code", code); 337 reqnn("codeVerifier", codeVerifier); 338 var grant = new AuthorizationCodeGrant(new AuthorizationCode(code), redirectUri, codeVerifier); 339 TokenRequest.Builder reqBuilder; 340 if (clientSecretSupplier != null) { 341 var clientAuth = new ClientSecretBasic(new ClientID(clientId), new Secret(clientSecretSupplier.get())); 342 reqBuilder = new TokenRequest.Builder(tokenEndpoint, clientAuth, grant); 343 } else { 344 reqBuilder = new TokenRequest.Builder(tokenEndpoint, new ClientID(clientId), grant); 345 } 346 if (resource != null) 347 reqBuilder.resource(resource); 348 return Flows.send(reqBuilder.build(), httpTimeout, httpRequestConfigurator); 349 } 350 351 /** 352 * Returns the authorization endpoint URL. 353 * 354 * @return The endpoint. 355 */ 356 public URI getAuthorizationEndpoint() { 357 return authorizationEndpoint; 358 } 359 360 /** 361 * Returns the token endpoint URL. 362 * 363 * @return The endpoint. 364 */ 365 public URI getTokenEndpoint() { 366 return tokenEndpoint; 367 } 368 369 /** 370 * Returns the client ID. 371 * 372 * @return The client ID. 373 */ 374 public String getClientId() { 375 return clientId; 376 } 377 378 /** 379 * Returns the redirect URI. 380 * 381 * @return The redirect URI. 382 */ 383 public URI getRedirectUri() { 384 return redirectUri; 385 } 386 387 /** 388 * Returns the requested scopes. 389 * 390 * @return An unmodifiable view. 391 */ 392 public Set<String> getScopes() { 393 return scopes; 394 } 395}