1 /* 2 * Copyright 2015, Google Inc. All rights reserved. 3 * 4 * Redistribution and use in source and binary forms, with or without 5 * modification, are permitted provided that the following conditions are 6 * met: 7 * 8 * * Redistributions of source code must retain the above copyright 9 * notice, this list of conditions and the following disclaimer. 10 * * Redistributions in binary form must reproduce the above 11 * copyright notice, this list of conditions and the following disclaimer 12 * in the documentation and/or other materials provided with the 13 * distribution. 14 * 15 * * Neither the name of Google Inc. nor the names of its 16 * contributors may be used to endorse or promote products derived from 17 * this software without specific prior written permission. 18 * 19 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 */ 31 32 package com.google.auth.oauth2; 33 34 import com.google.api.client.http.GenericUrl; 35 import com.google.api.client.http.HttpRequest; 36 import com.google.api.client.http.HttpRequestFactory; 37 import com.google.api.client.http.HttpResponse; 38 import com.google.api.client.http.UrlEncodedContent; 39 import com.google.api.client.json.GenericJson; 40 import com.google.api.client.json.JsonObjectParser; 41 import com.google.api.client.util.GenericData; 42 import com.google.api.client.util.Joiner; 43 import com.google.api.client.util.Preconditions; 44 import com.google.auth.http.HttpTransportFactory; 45 import com.google.common.collect.ImmutableList; 46 import com.google.errorprone.annotations.CanIgnoreReturnValue; 47 import java.io.IOException; 48 import java.net.URI; 49 import java.net.URL; 50 import java.util.ArrayList; 51 import java.util.Collection; 52 import java.util.Date; 53 import java.util.List; 54 import java.util.Map; 55 56 /** Handles an interactive 3-Legged-OAuth2 (3LO) user consent authorization. */ 57 public class UserAuthorizer { 58 59 static final URI DEFAULT_CALLBACK_URI = URI.create("/oauth2callback"); 60 61 private final String TOKEN_STORE_ERROR = "Error parsing stored token data."; 62 private final String FETCH_TOKEN_ERROR = "Error reading result of Token API:"; 63 64 private final ClientId clientId; 65 private final Collection<String> scopes; 66 private final TokenStore tokenStore; 67 private final URI callbackUri; 68 69 private final HttpTransportFactory transportFactory; 70 private final URI tokenServerUri; 71 private final URI userAuthUri; 72 private final PKCEProvider pkce; 73 74 /** 75 * Constructor with all parameters. 76 * 77 * @param clientId Client ID to identify the OAuth2 consent prompt 78 * @param scopes OAuth2 scopes defining the user consent 79 * @param tokenStore Implementation of a component for long term storage of tokens 80 * @param callbackUri URI for implementation of the OAuth2 web callback 81 * @param transportFactory HTTP transport factory, creates the transport used to get access 82 * tokens. 83 * @param tokenServerUri URI of the end point that provides tokens 84 * @param userAuthUri URI of the Web UI for user consent 85 * @param pkce PKCE implementation 86 */ UserAuthorizer( ClientId clientId, Collection<String> scopes, TokenStore tokenStore, URI callbackUri, HttpTransportFactory transportFactory, URI tokenServerUri, URI userAuthUri, PKCEProvider pkce)87 private UserAuthorizer( 88 ClientId clientId, 89 Collection<String> scopes, 90 TokenStore tokenStore, 91 URI callbackUri, 92 HttpTransportFactory transportFactory, 93 URI tokenServerUri, 94 URI userAuthUri, 95 PKCEProvider pkce) { 96 this.clientId = Preconditions.checkNotNull(clientId); 97 this.scopes = ImmutableList.copyOf(Preconditions.checkNotNull(scopes)); 98 this.callbackUri = (callbackUri == null) ? DEFAULT_CALLBACK_URI : callbackUri; 99 this.transportFactory = 100 (transportFactory == null) ? OAuth2Utils.HTTP_TRANSPORT_FACTORY : transportFactory; 101 this.tokenServerUri = (tokenServerUri == null) ? OAuth2Utils.TOKEN_SERVER_URI : tokenServerUri; 102 this.userAuthUri = (userAuthUri == null) ? OAuth2Utils.USER_AUTH_URI : userAuthUri; 103 this.tokenStore = (tokenStore == null) ? new MemoryTokensStorage() : tokenStore; 104 this.pkce = pkce; 105 } 106 107 /** 108 * Returns the Client ID user to identify the OAuth2 consent prompt. 109 * 110 * @return The Client ID. 111 */ getClientId()112 public ClientId getClientId() { 113 return clientId; 114 } 115 116 /** 117 * Returns the scopes defining the user consent. 118 * 119 * @return The collection of scopes defining the user consent. 120 */ getScopes()121 public Collection<String> getScopes() { 122 return scopes; 123 } 124 125 /** 126 * Returns the URI for implementation of the OAuth2 web callback. 127 * 128 * @return The URI for the OAuth2 web callback. 129 */ getCallbackUri()130 public URI getCallbackUri() { 131 return callbackUri; 132 } 133 134 /** 135 * Returns the URI for implementation of the OAuth2 web callback, optionally relative to the 136 * specified URI. 137 * 138 * <p>The callback URI is often relative to enable an application to be tested from more than one 139 * place so this can be used to resolve it relative to another URI. 140 * 141 * @param baseUri The URI to resolve the callback URI relative to. 142 * @return The resolved URI. 143 */ getCallbackUri(URI baseUri)144 public URI getCallbackUri(URI baseUri) { 145 if (callbackUri.isAbsolute()) { 146 return callbackUri; 147 } 148 if (baseUri == null || !baseUri.isAbsolute()) { 149 throw new IllegalStateException( 150 "If the callback URI is relative, the baseUri passed must" + " be an absolute URI"); 151 } 152 return baseUri.resolve(callbackUri); 153 } 154 155 /** 156 * Returns the implementation of a component for long term storage of tokens. 157 * 158 * @return The token storage implementation for long term storage of tokens. 159 */ getTokenStore()160 public TokenStore getTokenStore() { 161 return tokenStore; 162 } 163 164 /** 165 * Return an URL that performs the authorization consent prompt web UI. 166 * 167 * @param userId Application's identifier for the end user. 168 * @param state State that is passed on to the OAuth2 callback URI after the consent. 169 * @param baseUri The URI to resolve the OAuth2 callback URI relative to. 170 * @return The URL that can be navigated or redirected to. 171 */ getAuthorizationUrl(String userId, String state, URI baseUri)172 public URL getAuthorizationUrl(String userId, String state, URI baseUri) { 173 return this.getAuthorizationUrl(userId, state, baseUri, null); 174 } 175 176 /** 177 * Return an URL that performs the authorization consent prompt web UI. 178 * 179 * @param userId Application's identifier for the end user. 180 * @param state State that is passed on to the OAuth2 callback URI after the consent. 181 * @param baseUri The URI to resolve the OAuth2 callback URI relative to. 182 * @param additionalParameters Additional query parameters to be added to the authorization URL. 183 * @return The URL that can be navigated or redirected to. 184 */ getAuthorizationUrl( String userId, String state, URI baseUri, Map<String, String> additionalParameters)185 public URL getAuthorizationUrl( 186 String userId, String state, URI baseUri, Map<String, String> additionalParameters) { 187 URI resolvedCallbackUri = getCallbackUri(baseUri); 188 String scopesString = Joiner.on(' ').join(scopes); 189 190 GenericUrl url = new GenericUrl(userAuthUri); 191 url.put("response_type", "code"); 192 url.put("client_id", clientId.getClientId()); 193 url.put("redirect_uri", resolvedCallbackUri); 194 url.put("scope", scopesString); 195 if (state != null) { 196 url.put("state", state); 197 } 198 url.put("access_type", "offline"); 199 url.put("approval_prompt", "force"); 200 if (userId != null) { 201 url.put("login_hint", userId); 202 } 203 url.put("include_granted_scopes", true); 204 205 if (additionalParameters != null) { 206 for (Map.Entry<String, String> entry : additionalParameters.entrySet()) { 207 url.put(entry.getKey(), entry.getValue()); 208 } 209 } 210 211 if (pkce != null) { 212 url.put("code_challenge", pkce.getCodeChallenge()); 213 url.put("code_challenge_method", pkce.getCodeChallengeMethod()); 214 } 215 return url.toURL(); 216 } 217 218 /** 219 * Attempts to retrieve credentials for the approved end user consent. 220 * 221 * @param userId Application's identifier for the end user. 222 * @return The loaded credentials or null if there are no valid approved credentials. 223 * @throws IOException If there is error retrieving or loading the credentials. 224 */ getCredentials(String userId)225 public UserCredentials getCredentials(String userId) throws IOException { 226 Preconditions.checkNotNull(userId); 227 if (tokenStore == null) { 228 throw new IllegalStateException("Method cannot be called if token store is not specified."); 229 } 230 String tokenData = tokenStore.load(userId); 231 if (tokenData == null) { 232 return null; 233 } 234 GenericJson tokenJson = OAuth2Utils.parseJson(tokenData); 235 String accessTokenValue = 236 OAuth2Utils.validateString(tokenJson, "access_token", TOKEN_STORE_ERROR); 237 Long expirationMillis = 238 OAuth2Utils.validateLong(tokenJson, "expiration_time_millis", TOKEN_STORE_ERROR); 239 Date expirationTime = new Date(expirationMillis); 240 List<String> scopes = 241 OAuth2Utils.validateOptionalListString( 242 tokenJson, OAuth2Utils.TOKEN_RESPONSE_SCOPE, FETCH_TOKEN_ERROR); 243 AccessToken accessToken = 244 AccessToken.newBuilder() 245 .setExpirationTime(expirationTime) 246 .setTokenValue(accessTokenValue) 247 .setScopes(scopes) 248 .build(); 249 String refreshToken = 250 OAuth2Utils.validateOptionalString(tokenJson, "refresh_token", TOKEN_STORE_ERROR); 251 UserCredentials credentials = 252 UserCredentials.newBuilder() 253 .setClientId(clientId.getClientId()) 254 .setClientSecret(clientId.getClientSecret()) 255 .setRefreshToken(refreshToken) 256 .setAccessToken(accessToken) 257 .setHttpTransportFactory(transportFactory) 258 .setTokenServerUri(tokenServerUri) 259 .build(); 260 monitorCredentials(userId, credentials); 261 return credentials; 262 } 263 264 /** 265 * Returns a UserCredentials instance by exchanging an OAuth2 authorization code for tokens. 266 * 267 * @param code Code returned from OAuth2 consent prompt. 268 * @param baseUri The URI to resolve the OAuth2 callback URI relative to. 269 * @return the UserCredentials instance created from the authorization code. 270 * @throws IOException An error from the server API call to get the tokens. 271 */ getCredentialsFromCode(String code, URI baseUri)272 public UserCredentials getCredentialsFromCode(String code, URI baseUri) throws IOException { 273 return getCredentialsFromCode(code, baseUri, null); 274 } 275 276 /** 277 * Returns a UserCredentials instance by exchanging an OAuth2 authorization code for tokens. 278 * 279 * @param code Code returned from OAuth2 consent prompt. 280 * @param baseUri The URI to resolve the OAuth2 callback URI relative to. 281 * @param additionalParameters Additional parameters to be added to the post body of token 282 * endpoint request. 283 * @return the UserCredentials instance created from the authorization code. 284 * @throws IOException An error from the server API call to get the tokens. 285 */ getCredentialsFromCode( String code, URI baseUri, Map<String, String> additionalParameters)286 public UserCredentials getCredentialsFromCode( 287 String code, URI baseUri, Map<String, String> additionalParameters) throws IOException { 288 Preconditions.checkNotNull(code); 289 URI resolvedCallbackUri = getCallbackUri(baseUri); 290 291 GenericData tokenData = new GenericData(); 292 tokenData.put("code", code); 293 tokenData.put("client_id", clientId.getClientId()); 294 tokenData.put("client_secret", clientId.getClientSecret()); 295 tokenData.put("redirect_uri", resolvedCallbackUri); 296 tokenData.put("grant_type", "authorization_code"); 297 298 if (additionalParameters != null) { 299 for (Map.Entry<String, String> entry : additionalParameters.entrySet()) { 300 tokenData.put(entry.getKey(), entry.getValue()); 301 } 302 } 303 304 if (pkce != null) { 305 tokenData.put("code_verifier", pkce.getCodeVerifier()); 306 } 307 308 UrlEncodedContent tokenContent = new UrlEncodedContent(tokenData); 309 HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory(); 310 HttpRequest tokenRequest = 311 requestFactory.buildPostRequest(new GenericUrl(tokenServerUri), tokenContent); 312 tokenRequest.setParser(new JsonObjectParser(OAuth2Utils.JSON_FACTORY)); 313 314 HttpResponse tokenResponse = tokenRequest.execute(); 315 316 GenericJson parsedTokens = tokenResponse.parseAs(GenericJson.class); 317 String accessTokenValue = 318 OAuth2Utils.validateString(parsedTokens, "access_token", FETCH_TOKEN_ERROR); 319 int expiresInSecs = OAuth2Utils.validateInt32(parsedTokens, "expires_in", FETCH_TOKEN_ERROR); 320 Date expirationTime = new Date(new Date().getTime() + expiresInSecs * 1000); 321 String scopes = 322 OAuth2Utils.validateOptionalString( 323 parsedTokens, OAuth2Utils.TOKEN_RESPONSE_SCOPE, FETCH_TOKEN_ERROR); 324 AccessToken accessToken = 325 AccessToken.newBuilder() 326 .setExpirationTime(expirationTime) 327 .setTokenValue(accessTokenValue) 328 .setScopes(scopes) 329 .build(); 330 String refreshToken = 331 OAuth2Utils.validateOptionalString(parsedTokens, "refresh_token", FETCH_TOKEN_ERROR); 332 333 return UserCredentials.newBuilder() 334 .setClientId(clientId.getClientId()) 335 .setClientSecret(clientId.getClientSecret()) 336 .setRefreshToken(refreshToken) 337 .setAccessToken(accessToken) 338 .setHttpTransportFactory(transportFactory) 339 .setTokenServerUri(tokenServerUri) 340 .build(); 341 } 342 343 /** 344 * Exchanges an authorization code for tokens and stores them. 345 * 346 * @param userId Application's identifier for the end user. 347 * @param code Code returned from OAuth2 consent prompt. 348 * @param baseUri The URI to resolve the OAuth2 callback URI relative to. 349 * @return UserCredentials instance created from the authorization code. 350 * @throws IOException An error from the server API call to get the tokens or store the tokens. 351 */ getAndStoreCredentialsFromCode(String userId, String code, URI baseUri)352 public UserCredentials getAndStoreCredentialsFromCode(String userId, String code, URI baseUri) 353 throws IOException { 354 Preconditions.checkNotNull(userId); 355 Preconditions.checkNotNull(code); 356 UserCredentials credentials = getCredentialsFromCode(code, baseUri); 357 storeCredentials(userId, credentials); 358 monitorCredentials(userId, credentials); 359 return credentials; 360 } 361 362 /** 363 * Revokes the authorization for tokens stored for the user. 364 * 365 * @param userId Application's identifier for the end user. 366 * @throws IOException An error calling the revoke API or deleting the state. 367 */ revokeAuthorization(String userId)368 public void revokeAuthorization(String userId) throws IOException { 369 Preconditions.checkNotNull(userId); 370 if (tokenStore == null) { 371 throw new IllegalStateException("Method cannot be called if token store is not specified."); 372 } 373 String tokenData = tokenStore.load(userId); 374 if (tokenData == null) { 375 return; 376 } 377 IOException deleteTokenException = null; 378 try { 379 // Delete the stored version first. If token reversion fails it is less harmful to have an 380 // non revoked token to hold on to a potentially revoked token. 381 tokenStore.delete(userId); 382 } catch (IOException e) { 383 deleteTokenException = e; 384 } 385 386 GenericJson tokenJson = OAuth2Utils.parseJson(tokenData); 387 String accessTokenValue = 388 OAuth2Utils.validateOptionalString(tokenJson, "access_token", TOKEN_STORE_ERROR); 389 String refreshToken = 390 OAuth2Utils.validateOptionalString(tokenJson, "refresh_token", TOKEN_STORE_ERROR); 391 // If both tokens are present, either can be used 392 String revokeToken = (refreshToken != null) ? refreshToken : accessTokenValue; 393 394 GenericUrl revokeUrl = new GenericUrl(OAuth2Utils.TOKEN_REVOKE_URI); 395 GenericData genericData = new GenericData(); 396 genericData.put("token", revokeToken); 397 UrlEncodedContent content = new UrlEncodedContent(genericData); 398 399 HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory(); 400 HttpRequest tokenRequest = requestFactory.buildPostRequest(revokeUrl, content); 401 tokenRequest.execute(); 402 403 if (deleteTokenException != null) { 404 throw deleteTokenException; 405 } 406 } 407 408 /** 409 * Puts the end user credentials in long term storage. 410 * 411 * @param userId Application's identifier for the end user. 412 * @param credentials UserCredentials instance for the authorized consent. 413 * @throws IOException An error storing the credentials. 414 */ storeCredentials(String userId, UserCredentials credentials)415 public void storeCredentials(String userId, UserCredentials credentials) throws IOException { 416 if (tokenStore == null) { 417 throw new IllegalStateException("Cannot store tokens if tokenStore is not specified."); 418 } 419 AccessToken accessToken = credentials.getAccessToken(); 420 String acessTokenValue = null; 421 String scopes = null; 422 Date expiresBy = null; 423 List<String> grantedScopes = new ArrayList<>(); 424 425 if (accessToken != null) { 426 acessTokenValue = accessToken.getTokenValue(); 427 expiresBy = accessToken.getExpirationTime(); 428 grantedScopes = accessToken.getScopes(); 429 } 430 String refreshToken = credentials.getRefreshToken(); 431 GenericJson tokenStateJson = new GenericJson(); 432 tokenStateJson.setFactory(OAuth2Utils.JSON_FACTORY); 433 tokenStateJson.put("access_token", acessTokenValue); 434 tokenStateJson.put(OAuth2Utils.TOKEN_RESPONSE_SCOPE, grantedScopes); 435 tokenStateJson.put("expiration_time_millis", expiresBy.getTime()); 436 if (refreshToken != null) { 437 tokenStateJson.put("refresh_token", refreshToken); 438 } 439 String tokenState = tokenStateJson.toString(); 440 tokenStore.store(userId, tokenState); 441 } 442 443 /** 444 * Adds a listen to rewrite the credentials when the tokens are refreshed. 445 * 446 * @param userId Application's identifier for the end user. 447 * @param credentials UserCredentials instance to listen to. 448 */ monitorCredentials(String userId, UserCredentials credentials)449 protected void monitorCredentials(String userId, UserCredentials credentials) { 450 credentials.addChangeListener(new UserCredentialsListener(userId)); 451 } 452 453 /** 454 * Implementation of listener used by monitorCredentials to rewrite the credentials when the 455 * tokens are refreshed. 456 */ 457 private class UserCredentialsListener implements OAuth2Credentials.CredentialsChangedListener { 458 private final String userId; 459 460 /** Construct new listener. */ UserCredentialsListener(String userId)461 public UserCredentialsListener(String userId) { 462 this.userId = userId; 463 } 464 465 /** Handle change event by rewriting to token store. */ 466 @Override onChanged(OAuth2Credentials credentials)467 public void onChanged(OAuth2Credentials credentials) throws IOException { 468 UserCredentials userCredentials = (UserCredentials) credentials; 469 storeCredentials(userId, userCredentials); 470 } 471 } 472 newBuilder()473 public static Builder newBuilder() { 474 return new Builder(); 475 } 476 toBuilder()477 public Builder toBuilder() { 478 return new Builder(this); 479 } 480 481 public static class Builder { 482 483 private ClientId clientId; 484 private TokenStore tokenStore; 485 private URI callbackUri; 486 private URI tokenServerUri; 487 private URI userAuthUri; 488 private Collection<String> scopes; 489 private HttpTransportFactory transportFactory; 490 private PKCEProvider pkce; 491 Builder()492 protected Builder() {} 493 Builder(UserAuthorizer authorizer)494 protected Builder(UserAuthorizer authorizer) { 495 this.clientId = authorizer.clientId; 496 this.scopes = authorizer.scopes; 497 this.transportFactory = authorizer.transportFactory; 498 this.tokenServerUri = authorizer.tokenServerUri; 499 this.tokenStore = authorizer.tokenStore; 500 this.callbackUri = authorizer.callbackUri; 501 this.userAuthUri = authorizer.userAuthUri; 502 this.pkce = new DefaultPKCEProvider(); 503 } 504 505 @CanIgnoreReturnValue setClientId(ClientId clientId)506 public Builder setClientId(ClientId clientId) { 507 this.clientId = clientId; 508 return this; 509 } 510 511 @CanIgnoreReturnValue setTokenStore(TokenStore tokenStore)512 public Builder setTokenStore(TokenStore tokenStore) { 513 this.tokenStore = tokenStore; 514 return this; 515 } 516 517 @CanIgnoreReturnValue setScopes(Collection<String> scopes)518 public Builder setScopes(Collection<String> scopes) { 519 this.scopes = scopes; 520 return this; 521 } 522 523 @CanIgnoreReturnValue setTokenServerUri(URI tokenServerUri)524 public Builder setTokenServerUri(URI tokenServerUri) { 525 this.tokenServerUri = tokenServerUri; 526 return this; 527 } 528 529 @CanIgnoreReturnValue setCallbackUri(URI callbackUri)530 public Builder setCallbackUri(URI callbackUri) { 531 this.callbackUri = callbackUri; 532 return this; 533 } 534 535 @CanIgnoreReturnValue setUserAuthUri(URI userAuthUri)536 public Builder setUserAuthUri(URI userAuthUri) { 537 this.userAuthUri = userAuthUri; 538 return this; 539 } 540 541 @CanIgnoreReturnValue setHttpTransportFactory(HttpTransportFactory transportFactory)542 public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { 543 this.transportFactory = transportFactory; 544 return this; 545 } 546 547 @CanIgnoreReturnValue setPKCEProvider(PKCEProvider pkce)548 public Builder setPKCEProvider(PKCEProvider pkce) { 549 if (pkce != null) { 550 if (pkce.getCodeChallenge() == null 551 || pkce.getCodeVerifier() == null 552 || pkce.getCodeChallengeMethod() == null) { 553 554 throw new IllegalArgumentException( 555 "PKCE provider contained null implementations. PKCE object must implement all PKCEProvider methods."); 556 } 557 } 558 this.pkce = pkce; 559 return this; 560 } 561 getClientId()562 public ClientId getClientId() { 563 return clientId; 564 } 565 getTokenStore()566 public TokenStore getTokenStore() { 567 return tokenStore; 568 } 569 getScopes()570 public Collection<String> getScopes() { 571 return scopes; 572 } 573 getTokenServerUri()574 public URI getTokenServerUri() { 575 return tokenServerUri; 576 } 577 getCallbackUri()578 public URI getCallbackUri() { 579 return callbackUri; 580 } 581 getUserAuthUri()582 public URI getUserAuthUri() { 583 return userAuthUri; 584 } 585 getHttpTransportFactory()586 public HttpTransportFactory getHttpTransportFactory() { 587 return transportFactory; 588 } 589 getPKCEProvider()590 public PKCEProvider getPKCEProvider() { 591 return pkce; 592 } 593 build()594 public UserAuthorizer build() { 595 return new UserAuthorizer( 596 clientId, 597 scopes, 598 tokenStore, 599 callbackUri, 600 transportFactory, 601 tokenServerUri, 602 userAuthUri, 603 pkce); 604 } 605 } 606 } 607