WebOidcClient.java

/*
 * Copyright 2022 The Sigstore Authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package dev.sigstore.oidc.client;

import com.google.api.client.auth.oauth2.AuthorizationCodeFlow;
import com.google.api.client.auth.oauth2.BearerToken;
import com.google.api.client.auth.oauth2.ClientParametersAuthentication;
import com.google.api.client.auth.openidconnect.IdToken;
import com.google.api.client.auth.openidconnect.IdTokenVerifier;
import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp;
import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.GenericJson;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.Key;
import com.google.api.client.util.Preconditions;
import com.google.api.client.util.store.DataStoreFactory;
import com.google.api.client.util.store.MemoryDataStoreFactory;
import dev.sigstore.http.HttpClients;
import dev.sigstore.http.HttpParams;
import dev.sigstore.trustroot.Service;
import java.io.IOException;
import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.logging.Logger;

/**
 * A client to obtain oidc tokens from an oauth provider via web workflow for use with sigstore. By
 * default this client is configued to use the public sigstore dex instance.
 */
public class WebOidcClient implements OidcClient {
  private static final Logger log = Logger.getLogger(WebOidcClient.class.getName());

  private static final String ID_TOKEN_KEY = "id_token";
  private static final String DEFAULT_CLIENT_ID = "sigstore";
  private static final String WELL_KNOWN_CONFIG = "/.well-known/openid-configuration";

  private final HttpParams httpParams;
  private final String clientId;
  private final String issuer;
  private final BrowserHandler browserHandler;

  private WebOidcClient(
      HttpParams httpParams, String issuer, String clientId, BrowserHandler browserHandler) {
    this.httpParams = httpParams;
    this.clientId = clientId;
    this.issuer = issuer;
    this.browserHandler = browserHandler;
  }

  public static WebOidcClient.Builder builder() {
    return new Builder();
  }

  public static class Builder {
    private HttpParams httpParams = HttpParams.builder().build();
    private String clientId = DEFAULT_CLIENT_ID;
    private Service issuer;
    private BrowserHandler browserHandler = null;

    private Builder() {}

    /** Configure the http properties, see {@link HttpParams} */
    public Builder setHttpParams(HttpParams httpParams) {
      this.httpParams = httpParams;
      return this;
    }

    /** The client id used in the oidc request, defaults to {@value DEFAULT_CLIENT_ID}. */
    public Builder setClientId(String clientId) {
      this.clientId = clientId;
      return this;
    }

    /** The issuer of the oidc tokens (the oidc service). */
    public Builder setIssuer(Service issuer) {
      this.issuer = issuer;
      return this;
    }

    /**
     * Alternative to default browser behavior, only use if you truly need to open with some sort of
     * custom browser, like in test or headless environments.
     */
    public Builder setBrowser(BrowserHandler browserHandler) {
      this.browserHandler = browserHandler;
      return this;
    }

    public WebOidcClient build() {
      Preconditions.checkNotNull(issuer);
      BrowserHandler bh =
          browserHandler != null
              ? browserHandler
              : new AuthorizationCodeInstalledApp.DefaultBrowser()::browse;
      return new WebOidcClient(httpParams, issuer.getUrl().toString(), clientId, bh);
    }
  }

  /** This provider is usually enabled unless we're in CI. */
  @Override
  public boolean isEnabled(Map<String, String> env) {
    if ("true".equalsIgnoreCase(env.get("CI"))) {
      log.info("Skipping browser based oidc provider because CI detected");
      return false;
    }
    return true;
  }

  /**
   * Get an id token from the oidc provider with openid and email scopes
   *
   * @return an openid token with additional email scopes
   * @throws OidcException if an error occurs doing the authorization flow
   */
  @Override
  public OidcToken getIDToken(Map<String, String> env) throws OidcException {
    JsonFactory jsonFactory = new GsonFactory();
    HttpTransport httpTransport = HttpClients.newHttpTransport(httpParams);
    DataStoreFactory memStoreFactory = new MemoryDataStoreFactory();
    OIDCEndpoints endpoints;
    try {
      endpoints = parseDiscoveryDocument(jsonFactory, httpTransport);
    } catch (IOException e) {
      // TODO: maybe a more descriptive exception message
      throw new OidcException(
          "ioexception obtaining and parsing oidc configuration for " + issuer, e);
    }
    AuthorizationCodeFlow.Builder flowBuilder =
        new AuthorizationCodeFlow.Builder(
                BearerToken.authorizationHeaderAccessMethod(),
                httpTransport,
                jsonFactory,
                new GenericUrl(endpoints.getTokenEndpoint()),
                new ClientParametersAuthentication(clientId, null),
                clientId,
                endpoints.getAuthEndpoint())
            .enablePKCE()
            .setScopes(Arrays.asList("openid", "email"))
            .setCredentialCreatedListener(
                (credential, tokenResponse) ->
                    memStoreFactory
                        .getDataStore("user")
                        .set(ID_TOKEN_KEY, tokenResponse.get(ID_TOKEN_KEY).toString()));
    AuthorizationCodeInstalledApp app =
        new AuthorizationCodeInstalledApp(
            flowBuilder.build(), new LocalServerReceiver(), browserHandler::openBrowser);

    String idTokenString = null;
    IdToken parsedIdToken = null;
    try {
      app.authorize("user");
      idTokenString = (String) memStoreFactory.getDataStore("user").get(ID_TOKEN_KEY);
      parsedIdToken = IdToken.parse(jsonFactory, idTokenString);
      IdTokenVerifier idTokenVerifier =
          new IdTokenVerifier.Builder()
              .setIssuer(issuer)
              .setCertificatesLocation(endpoints.getJwksUri())
              .build();
      if (!idTokenVerifier.verifyOrThrow(parsedIdToken)) {
        throw new OidcException("id token could not be verified");
      }
    } catch (IOException e) {
      // TODO: maybe a more descriptive exception message
      throw new OidcException("ioexception during oidc handshake", e);
    }

    String emailFromIDToken = (String) parsedIdToken.getPayload().get("email");
    boolean emailVerified = (boolean) parsedIdToken.getPayload().get("email_verified");
    if (Boolean.FALSE.equals(emailVerified)) {
      throw new OidcException(
          String.format(
              Locale.ROOT,
              "identity provider '%s' reports email address '%s' has not been verified",
              parsedIdToken.getPayload().getIssuer(),
              emailFromIDToken));
    }

    return ImmutableOidcToken.builder()
        .subjectAlternativeName(emailFromIDToken)
        .idToken(idTokenString)
        .issuer(issuer)
        .build();
  }

  // Parses a oidc discovery document to discover other endpoints. This method does not
  // parse all the values, only the endpoints we care about.
  OIDCEndpoints parseDiscoveryDocument(JsonFactory jsonFactory, HttpTransport httpTransport)
      throws IOException {
    HttpRequestFactory requestFactory =
        httpTransport.createRequestFactory(
            request -> {
              request.setParser(jsonFactory.createJsonObjectParser());
            });
    GenericUrl wellKnownConfig = new GenericUrl(issuer);
    wellKnownConfig.appendRawPath(WELL_KNOWN_CONFIG);
    HttpRequest request = requestFactory.buildGetRequest(wellKnownConfig);
    return request.execute().parseAs(OIDCEndpoints.class);
  }

  /** Internal. */
  public static class OIDCEndpoints extends GenericJson {
    @Key("authorization_endpoint")
    private String authEndpoint;

    @Key("token_endpoint")
    private String tokenEndpoint;

    @Key("jwks_uri")
    private String jwksUri;

    public String getAuthEndpoint() {
      return authEndpoint;
    }

    public String getTokenEndpoint() {
      return tokenEndpoint;
    }

    public String getJwksUri() {
      return jwksUri;
    }
  }

  /** Interface for allowing custom browser handlers for OauthClients. */
  @FunctionalInterface
  public interface BrowserHandler {
    /** Opens a browser to allow a user to complete the oauth browser workflow. */
    void openBrowser(String url) throws IOException;
  }
}