Skip to content

Single Sign-On with Google — A Step-by-Step Walkthrough

Overview and Motivation

Authentication describes what the framework offers: a client-side CredentialsProvider that obtains a proof of identity, a server-side chain of AuthenticationProviders that verifies it, and a UserResolver that maps the proven identity onto the application's user entity. This document works one concrete case out end to end — signing in with a Google account — because the interesting part is not the SPI but everything around it: the OAuth 2.0 flow a desktop application is allowed to use, the loopback redirect it needs, and how an ID token is verified without calling the identity provider on every login.

Google is used as the example. Nothing below is specific to it except four endpoint constants and a handful of claim names; the last section shows how to point the same code at Microsoft Entra ID, Keycloak or any other OpenID Connect provider.

The starting point is an application generated by the project archetype, called MyApp here. Nothing the archetype generated is edited. The password provider it ships stays where it is and keeps working, which is what a mix of human users and service accounts needs. Single sign-on is added by putting new classes next to the generated ones.

 CLIENT (myapp-client)                                    SERVER (myapp-server)
 ─────────────────────                                    ─────────────────────
 Login view
   │  backend.properties: authentication=google
 GoogleCredentialsProvider ──browser──► accounts.google.com
   │      ▲                                   │
   │      └──── redirect to 127.0.0.1:<port> ─┘  (authorization code)
   │    LoopbackReceiver
   │  code + code_verifier ──► oauth2.googleapis.com/token
 TokenCredentials (OIDC ID token)
   └── SessionInfo.setCredentials(..) ──TRIP──► RemoteDbSessionImpl.verifySessionInfo()
                                                  AuthenticationManager
                                                  GoogleIdTokenAuthenticationProvider
                                                            │  verify against the JWKS, offline
                                                            ▼  Authentication(name = e-mail)
                                                  MyAppUserResolver
                                                            │  → userId / userClassId
                                                  the ordinary session machinery

Eight new classes, spread over three modules:

Module Class Role
myapp-common Json A minimal JSON reader, shared by both tiers.
myapp-client LoopbackReceiver Catches the browser's redirect on 127.0.0.1.
myapp-client GoogleOAuth The endpoints and the PKCE/token plumbing.
myapp-client GoogleCredentialsStore Remembers a sign-in in the local preferences.
myapp-client GoogleCredentialsProvider The client SPI: runs the flow, returns TokenCredentials.
myapp-client ClientBundle The translations the flow needs.
myapp-server GoogleKeySource Google's public signing keys, cached.
myapp-server GoogleIdTokenAuthenticationProvider The server SPI: verifies the ID token.
myapp-server MyAppUserResolver Maps the verified e-mail onto a User.

Why an ID token and not an access token. An access token is a key to Google's APIs and says nothing verifiable about who is holding it. An OpenID Connect ID token is a JWT signed by Google, issued for this application, carrying the user's identity in its claims. The server can check it offline against Google's published keys, so no call leaves the middle tier per login.


What You Need Beforehand

  1. An OAuth 2.0 client of type "Desktop app" in the Google Cloud Console (APIs & Services → Credentials → Create credentials → OAuth client ID). Note its client ID and client secret.
  2. The OAuth consent screen configured for the scopes openid, email and profile. While it is in Testing, only the accounts listed as test users can sign in.
  3. Nothing else. A desktop client needs no registered redirect URI: Google accepts any http://127.0.0.1:<port>/... callback for this client type, which is exactly what makes the loopback receiver of step 4 possible.

A desktop client's "secret" is not a secret. It ships inside the application and anyone can extract it — RFC 8252 says as much. What actually protects the flow is PKCE: the authorization code is worthless without the code_verifier, which never leaves the client process. Store the secret ~-encrypted anyway (step 2) so it is not lying around in plain text, but do not treat leaking it as a breach.


Step 1 — Give the User Entity an E-Mail Address

Google proves an e-mail address. The application has to be able to find its user from one, so the User entity gets an indexed column for it.

Edit the model block in myapp-pdo/src/main/java/com/example/myapp/pdo/md/User.java:

 * ## attributes
 * [cached]
 * String(64)   password                      password        hashed password [MUTE]
 * String(128)  email                         email           e-mail address used for single sign-on
 * boolean      loginAllowed                  login_allowed   true if login is allowed
 * ...
 *
 * ## indexes
 * unique index email := email

Declare the lookup in UserPersistence:

/**
 * Selects the user by its e-mail address.
 * <p>
 * E-mail addresses are stored in lowercase, see the single sign-on user resolver.
 *
 * @param email the lowercase e-mail address
 * @return the user, null if no such user
 */
User selectByEmail(String email);

and let a wurblet implement it in UserPersistenceImpl:

// @wurblet selectByEmail PdoSelectUnique email

mvn install then generates the accessors, the column plumbing, the SELECT and — because the persistence layer is remoting-capable — the matching selectByEmail(DomainContext, String) in UserRemoteDelegate and UserRemoteDelegateImpl. Nothing has to be written by hand.

Finally bring the schema up to date with tentackle-sql:migrate, and give the existing users an address — the editor field follows in step 11.

The unique index matters. Two users sharing an e-mail address would make the identity ambiguous, and the resolver of step 10 would have no defensible way to pick one. Let the database refuse it.


Step 2 — Configure the OAuth Client

Four properties drive everything, and they are deliberately split between the tiers: only the client runs the flow, only the server verifies its result.

Key Client backend.properties Server backend.properties / server.properties
authentication google — selects the provider
google.clientId required required — the accepted aud claim
google.clientSecret required, ~-encrypted — (the server never talks to Google's token endpoint)
google.hostedDomain optional — pre-selects the domain optional — enforces the domain

Put the values in the root pom.xml, where the build filters them into the property files:

<!-- single sign-on with Google, see the authentication chapter of the Tentackle documentation.
     Create an OAuth 2.0 client of type "desktop app" in the Google Cloud Console and enter its
     credentials here. Leave googleHostedDomain empty to allow accounts of any Google domain. -->
<googleClientId>000000000000-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com</googleClientId>
<googleClientSecret>GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxx</googleClientSecret>
<googleHostedDomain></googleHostedDomain>

The client's backend.properties:

# Single sign-on with Google.
# Enter the credentials of your OAuth client in the root pom, then uncomment "authentication"
# to replace the username/password fields with a sign-in button.
authentication=google
google.clientId=${googleClientId}
google.clientSecret=~${encryptedGoogleSecret}
# google.hostedDomain=${googleHostedDomain}

The server's:

# the OAuth client the Google ID tokens must be issued for (see the client's backend.properties).
# Without it the server refuses token credentials and only username/password logins are possible.
google.clientId=${googleClientId}
# google.hostedDomain=${googleHostedDomain}

The leading ~ marks the value as encrypted; see Cryptor and EncryptedProperties. The jlink plugin produces the cipher text at build time — add one descriptor next to the ones the archetype already generates in jlink/pom.xml:

<propertyDescriptor>
  <input>${googleClientSecret}</input>
  <converter>@org.tentackle.common.Cryptor</converter>
  <property>encryptedGoogleSecret</property>
</propertyDescriptor>

Every key is documented in Backend Properties; authentication defaults to password.

Roll it out in two moves. With google.clientId unset the server-side provider abstains entirely (step 9) and the tier behaves as if single sign-on had never been installed. Deploy the server first, verify that password logins are untouched, then switch the clients over.


Step 3 — A Minimal JSON Reader

Three small JSON documents have to be read: the token response, Google's key set, and the claims inside the ID token. Tentackle ships no JSON parser, and pulling one in drags a dependency tree through a modularized build for a few hundred bytes of parsing. A hand-written reader in myapp-common — some 340 lines of ordinary recursive descent — keeps both tiers dependency-free.

Only its API matters here:

package com.example.myapp.common.json;

public final class Json {
  public static Map<String, Object> parseObject(String text);
  public static Object parse(String text);

  public static String  getString (Map<String, Object> object, String key);
  public static long    getLong   (Map<String, Object> object, String key, long defaultValue);
  public static boolean getBoolean(Map<String, Object> object, String key);
  public static List<Object> getList(Map<String, Object> object, String key);
}

with the usual value mapping:

JSON Java
object Map<String,Object>, insertion ordered
array List<Object>
string String
number Long if integral, else Double
true/false Boolean
null null

Malformed input throws TentackleRuntimeException; the callers below turn that into an AuthenticationException. Export the package so both tiers see it:

module com.example.myapp.common {
  exports com.example.myapp.common;
  exports com.example.myapp.common.json;
  ...
}

If the application already has Jackson or jakarta.json on its module path, use that instead — the rest of this walkthrough only calls the six methods above.


Step 4 — The Client: Catching the Redirect

OAuth 2.0 ends by redirecting the browser to the application. A desktop application has no web server to be redirected to, so RFC 8252 prescribes a loopback redirect: the application listens on an ephemeral port of 127.0.0.1 for exactly as long as the sign-in lasts.

Two details make this class small. It binds the port in the constructor, so the redirect URI is known before the browser is opened — the URI has to go into the authorization request. And it hands its result over through an ArrayBlockingQueue of capacity one, which serves as both the handoff and the "only the first response counts" rule.

myapp-client/src/main/java/com/example/myapp/client/auth/LoopbackReceiver.java:

package com.example.myapp.client.auth;

import com.example.myapp.client.ClientBundle;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import org.tentackle.common.TentackleRuntimeException;
import org.tentackle.log.Logger;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

/**
 * Receives the authorization response of the browser.
 * <p>
 * Desktop applications cannot keep a client secret and have no web server to redirect to, so
 * OAuth 2.0 sends the authorization code to a loopback address instead. The receiver listens on
 * an ephemeral port of {@code 127.0.0.1}, hands the browser a short "you can close this window"
 * page and passes the query parameters on to the credentials provider.
 * <p>
 * The receiver binds the port in the constructor, so the redirect URI is known before the browser
 * is opened. It must always be closed again, preferably with try-with-resources.
 */
public class LoopbackReceiver implements AutoCloseable {

  private static final Logger LOGGER = Logger.get();

  private static final String CALLBACK_PATH = "/callback";
  private static final int STOP_DELAY = 1;      // seconds granted to finish pending exchanges


  private final HttpServer httpServer;
  private final BlockingQueue<Map<String, String>> responses;


  /**
   * Creates the receiver and starts listening on a free loopback port.
   *
   * @throws TentackleRuntimeException if the loopback server could not be started
   */
  public LoopbackReceiver() {
    responses = new ArrayBlockingQueue<>(1);
    try {
      httpServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
    }
    catch (IOException iox) {
      throw new TentackleRuntimeException("could not listen for the authorization response", iox);
    }
    httpServer.createContext(CALLBACK_PATH, this::handle);
    httpServer.start();
    LOGGER.info("waiting for the authorization response at {0}", getRedirectUri());
  }


  /**
   * Gets the redirect URI the authorization server must send the browser to.
   *
   * @return the redirect URI
   */
  public String getRedirectUri() {
    return "http://" + InetAddress.getLoopbackAddress().getHostAddress() + ":" +
           httpServer.getAddress().getPort() + CALLBACK_PATH;
  }

  /**
   * Waits for the browser to deliver the authorization response.
   *
   * @param timeout the timeout in milliseconds
   * @return the query parameters, null if the timeout expired
   * @throws InterruptedException if waiting was interrupted
   */
  public Map<String, String> awaitResponse(long timeout) throws InterruptedException {
    return responses.poll(timeout, TimeUnit.MILLISECONDS);
  }

  @Override
  public void close() {
    httpServer.stop(STOP_DELAY);
  }


  /**
   * Handles the redirect of the browser.
   *
   * @param exchange the HTTP exchange
   * @throws IOException if the response could not be written
   */
  private void handle(HttpExchange exchange) throws IOException {
    Map<String, String> parameters = parseQuery(exchange.getRequestURI());
    boolean accepted = responses.offer(parameters);    // ignore all but the first response
    String message = ClientBundle.getString(parameters.containsKey("code") && accepted
                                            ? "you are signed in" : "sign-in failed");
    byte[] page = createPage(message).getBytes(StandardCharsets.UTF_8);
    exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
    exchange.sendResponseHeaders(200, page.length);
    try (OutputStream out = exchange.getResponseBody()) {
      out.write(page);
    }
  }

  /**
   * Splits the query string of the redirect into its parameters.
   *
   * @param uri the request URI
   * @return the parameters, never null
   */
  private Map<String, String> parseQuery(URI uri) {
    Map<String, String> parameters = new HashMap<>();
    String query = uri.getRawQuery();
    if (query != null) {
      for (String parameter : query.split("&")) {
        int assign = parameter.indexOf('=');
        if (assign > 0) {
          parameters.put(URLDecoder.decode(parameter.substring(0, assign), StandardCharsets.UTF_8),
                         URLDecoder.decode(parameter.substring(assign + 1), StandardCharsets.UTF_8));
        }
      }
    }
    return parameters;
  }

  /**
   * Creates the page shown in the browser once the authorization server redirected back.
   *
   * @param message the message to show
   * @return the HTML page
   */
  private String createPage(String message) {
    return """
           <!DOCTYPE html>
           <html lang="en">
           <head><meta charset="utf-8"><title>MyApp</title></head>
           <body style="font-family:sans-serif;text-align:center;padding-top:4em">
           <p>%s</p>
           </body>
           </html>
           """.formatted(escape(message));
  }

  /**
   * Escapes the characters that are markup in HTML.
   *
   * @param text the text
   * @return the escaped text
   */
  private String escape(String text) {
    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
  }

}

The receiver comes from the JDK's own jdk.httpserver module — no servlet container, no dependency. It must always be closed again, which createCredentials does with try-with-resources.


Step 5 — The Client: The Google Endpoints

Everything that is pure protocol goes into one final class, so that the credentials provider of step 7 reads as a description of the flow rather than a pile of HTTP calls.

Note createCodeChallenge: PKCE means the client invents a random code_verifier, sends only its SHA-256 hash (the challenge) with the authorization request, and reveals the verifier when it redeems the code. An attacker who intercepts the authorization code cannot use it.

myapp-client/src/main/java/com/example/myapp/client/auth/GoogleOAuth.java:

package com.example.myapp.client.auth;

import com.example.myapp.common.json.Json;

import org.tentackle.common.TentackleRuntimeException;
import org.tentackle.session.auth.AuthenticationException;
import org.tentackle.session.auth.TokenCredentials;

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * The Google endpoints and the plumbing to talk to them.
 * <p>
 * Everything that is pure protocol lives here so that the credentials provider only has to
 * describe the flow.
 */
public final class GoogleOAuth {

  /** Authorization endpoint the browser is sent to. */
  public static final String AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";

  /** Token endpoint exchanging the authorization code for tokens. */
  public static final String TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";

  /** The issuer minting the ID tokens. */
  public static final String ISSUER = "https://accounts.google.com";

  /** The scopes needed to identify the user. */
  public static final String SCOPE = "openid email profile";


  private static final Duration TIMEOUT = Duration.ofSeconds(30);
  private static final SecureRandom RANDOM = new SecureRandom();
  private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();


  /**
   * Creates a random URL-safe value for a PKCE code verifier, a state or a nonce.
   *
   * @return the random value, 43 characters long
   */
  public static String createRandomValue() {
    byte[] bytes = new byte[32];
    RANDOM.nextBytes(bytes);
    return ENCODER.encodeToString(bytes);
  }

  /**
   * Derives the PKCE code challenge from a code verifier.
   *
   * @param codeVerifier the code verifier
   * @return the S256 code challenge
   */
  public static String createCodeChallenge(String codeVerifier) {
    try {
      MessageDigest digest = MessageDigest.getInstance("SHA-256");
      return ENCODER.encodeToString(digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)));
    }
    catch (NoSuchAlgorithmException nax) {
      throw new TentackleRuntimeException("SHA-256 is not available", nax);
    }
  }

  /**
   * Builds a URI from an endpoint and its query parameters.
   *
   * @param endpoint the endpoint URL
   * @param parameters the query parameters
   * @return the URI
   */
  public static URI createUri(String endpoint, Map<String, String> parameters) {
    return URI.create(endpoint + '?' + urlEncode(parameters));
  }

  /**
   * Posts a form to the token endpoint and turns the answer into credentials.
   *
   * @param parameters the form parameters
   * @return the credentials holding the ID token
   * @throws AuthenticationException if the token could not be obtained
   */
  public static TokenCredentials requestToken(Map<String, String> parameters) {
    HttpRequest request = HttpRequest.newBuilder(URI.create(TOKEN_ENDPOINT))
                                     .timeout(TIMEOUT)
                                     .header("Content-Type", "application/x-www-form-urlencoded")
                                     .header("Accept", "application/json")
                                     .POST(HttpRequest.BodyPublishers.ofString(urlEncode(parameters)))
                                     .build();
    HttpResponse<String> response;
    try (HttpClient httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build()) {
      response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
    }
    catch (InterruptedException ix) {
      Thread.currentThread().interrupt();
      throw new AuthenticationException("interrupted while requesting the token", ix);
    }
    catch (IOException iox) {
      throw new AuthenticationException("could not reach " + TOKEN_ENDPOINT, iox);
    }

    Map<String, Object> answer;
    try {
      answer = Json.parseObject(response.body());
    }
    catch (RuntimeException rex) {
      throw new AuthenticationException(TOKEN_ENDPOINT + " answered with status " + response.statusCode(), rex);
    }

    if (response.statusCode() != 200) {
      // the error description may name the misconfigured parameter, but never contains a secret
      throw new AuthenticationException("the token request was rejected: " +
                                        Json.getString(answer, "error") + " (" +
                                        Json.getString(answer, "error_description") + ")");
    }

    String idToken = Json.getString(answer, "id_token");
    if (idToken == null) {
      throw new AuthenticationException("the token response carries no ID token");
    }

    TokenCredentials credentials = new TokenCredentials(Json.getString(answer, "token_type"),
                                                        idToken.toCharArray());
    credentials.setIssuer(ISSUER);
    long expiresIn = Json.getLong(answer, "expires_in", 0);
    if (expiresIn > 0) {
      credentials.setExpiresAt(System.currentTimeMillis() + expiresIn * 1000);
    }
    String refreshToken = Json.getString(answer, "refresh_token");
    if (refreshToken != null) {
      credentials.setRefreshToken(refreshToken.toCharArray());
    }
    return credentials;
  }


  /**
   * Encodes parameters as an {@code application/x-www-form-urlencoded} string.
   *
   * @param parameters the parameters
   * @return the encoded string
   */
  private static String urlEncode(Map<String, String> parameters) {
    StringBuilder buf = new StringBuilder();
    for (Map.Entry<String, String> entry : parameters.entrySet()) {
      if (!buf.isEmpty()) {
        buf.append('&');
      }
      buf.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8))
         .append('=')
         .append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8));
    }
    return buf.toString();
  }

  /**
   * Creates an ordered map for the parameters of a request.
   *
   * @return the empty map
   */
  static Map<String, String> createParameters() {
    return new LinkedHashMap<>();
  }

  private GoogleOAuth() {
  }

}

requestToken is used for both grant types — the initial authorization_code exchange and the later refresh_token renewal — because Google answers both with the same document. It fills a TokenCredentials with the ID token, the issuer, the absolute expiry and, if present, the refresh token. That object is what travels to the middle tier.


Step 6 — The Client: Remembering the Sign-In

A user who has to visit the browser on every application start will not thank you for single sign-on. The tokens are therefore kept on the local machine, so the browser is only needed when a remembered sign-in cannot carry the login anymore.

Where they are kept matters. Tentackle's own preferences are database-backed and roam with the user across machines — which is exactly wrong for a token bound to one device. This uses the plain Java Preferences of the application's package instead, next to the theme settings ThemeUtilities stores there.

Both tokens are encrypted with the application's Cryptor before being written. That does not turn a preferences file into a safe, and it is not meant to: it is the same treatment the backend configuration gives its passwords, and it keeps the tokens from being picked up by simply looking at the file.

myapp-client/src/main/java/com/example/myapp/client/auth/GoogleCredentialsStore.java:

package com.example.myapp.client.auth;

import org.tentackle.common.Cryptor;
import org.tentackle.log.Logger;
import org.tentackle.session.auth.TokenCredentials;

import java.util.Arrays;
import java.util.prefs.BackingStoreException;
import java.util.prefs.Preferences;

/**
 * Remembers the Google sign-in on the local machine.
 * <p>
 * The tokens are kept in the java preferences of the application's package, next to the theme
 * settings {@link org.tentackle.fx.ThemeUtilities} stores there, since a sign-in applies to a
 * machine and a user only and thus has no business in the preferences replicated by the database.
 * <p>
 * Both the ID token and the refresh token are encrypted with the application's {@link Cryptor}
 * before they are written. That is the same treatment the backend configuration gives to its
 * passwords: it does not turn the preferences file into a safe place, but it keeps the tokens
 * from being picked up by simply looking at it.
 * <p>
 * Nothing here ever breaks a login: whatever goes wrong, the sign-in is forgotten and the caller
 * falls back to the browser.
 */
public final class GoogleCredentialsStore {

  /** Preferences key of the OAuth client the tokens belong to. */
  public static final String CLIENT_ID = "google.clientId";

  /** Preferences key of the token type. */
  public static final String TOKEN_TYPE = "google.tokenType";

  /** Preferences key of the epochal milliseconds the ID token expires. */
  public static final String EXPIRES_AT = "google.expiresAt";

  /** Preferences key of the encrypted ID token. */
  public static final String TOKEN = "google.token";

  /** Preferences key of the encrypted refresh token. */
  public static final String REFRESH_TOKEN = "google.refreshToken";


  private static final Logger LOGGER = Logger.get();

  private static final String[] KEYS = { CLIENT_ID, TOKEN_TYPE, EXPIRES_AT, TOKEN, REFRESH_TOKEN };


  /**
   * Remembers the credentials of a sign-in.
   *
   * @param clazz the application class determining the preferences node
   * @param clientId the OAuth client id the credentials belong to
   * @param credentials the credentials to remember
   */
  public static void save(Class<?> clazz, String clientId, TokenCredentials credentials) {
    Cryptor cryptor = Cryptor.getInstance();
    if (cryptor == null) {
      LOGGER.warning("there is no cryptor: the sign-in is not remembered");
      forget(clazz);
      return;
    }
    Preferences userPrefs = Preferences.userNodeForPackage(clazz);
    try {
      userPrefs.put(CLIENT_ID, clientId);
      userPrefs.put(TOKEN_TYPE, credentials.getTokenType());
      userPrefs.putLong(EXPIRES_AT, credentials.getExpiresAt());
      putSecret(userPrefs, TOKEN, cryptor, credentials.getToken());
      putSecret(userPrefs, REFRESH_TOKEN, cryptor, credentials.getRefreshToken());
      userPrefs.flush();
      LOGGER.info("the sign-in has been remembered");
    }
    catch (BackingStoreException | RuntimeException ex) {
      LOGGER.warning("the sign-in could not be remembered", ex);
      forget(clazz);
    }
  }

  /**
   * Loads the credentials of a remembered sign-in.
   * <p>
   * The credentials may well be expired: it is up to the caller to decide whether to use them
   * as they are, to renew them or to sign in again.
   *
   * @param clazz the application class determining the preferences node
   * @param clientId the OAuth client id the credentials must belong to
   * @return the credentials, null if there is no usable sign-in remembered
   */
  public static TokenCredentials load(Class<?> clazz, String clientId) {
    Cryptor cryptor = Cryptor.getInstance();
    if (cryptor == null) {
      return null;
    }
    Preferences userPrefs = Preferences.userNodeForPackage(clazz);
    if (!clientId.equals(userPrefs.get(CLIENT_ID, null))) {
      // nothing remembered at all, or remembered for another OAuth client
      return null;
    }

    char[] token = null;
    char[] refreshToken = null;
    try {
      token = getSecret(userPrefs, TOKEN, cryptor);
      if (token == null) {
        return null;
      }
      TokenCredentials credentials = new TokenCredentials(userPrefs.get(TOKEN_TYPE, null), token);
      credentials.setIssuer(GoogleOAuth.ISSUER);
      credentials.setExpiresAt(userPrefs.getLong(EXPIRES_AT, 0));
      refreshToken = getSecret(userPrefs, REFRESH_TOKEN, cryptor);
      if (refreshToken != null) {
        credentials.setRefreshToken(refreshToken);
      }
      return credentials;
    }
    catch (RuntimeException rex) {
      // a changed cryptor or a hand-edited preferences file, for example
      LOGGER.warning("the remembered sign-in is unreadable and has been discarded", rex);
      forget(clazz);
      return null;
    }
    finally {
      // the credentials keep copies of their own
      wipe(token);
      wipe(refreshToken);
    }
  }

  /**
   * Forgets a remembered sign-in.
   *
   * @param clazz the application class determining the preferences node
   */
  public static void forget(Class<?> clazz) {
    Preferences userPrefs = Preferences.userNodeForPackage(clazz);
    try {
      for (String key : KEYS) {
        userPrefs.remove(key);
      }
      userPrefs.flush();
    }
    catch (BackingStoreException | RuntimeException ex) {
      LOGGER.warning("the remembered sign-in could not be removed", ex);
    }
  }


  /**
   * Writes a secret encrypted, or removes the key if there is no secret to write.
   * <p>
   * The encryption wipes the given array, which is fine since it is a copy handed out by the
   * credentials.
   *
   * @param userPrefs the preferences node
   * @param key the preferences key
   * @param cryptor the cryptor
   * @param secret the secret, null if none
   */
  private static void putSecret(Preferences userPrefs, String key, Cryptor cryptor, char[] secret) {
    if (secret == null || secret.length == 0) {
      userPrefs.remove(key);
      return;
    }
    String encrypted = cryptor.encrypt64(secret);
    if (encrypted.length() > Preferences.MAX_VALUE_LENGTH) {
      LOGGER.warning("the encrypted {0} is too large for the preferences", key);
      userPrefs.remove(key);
      return;
    }
    userPrefs.put(key, encrypted);
  }

  /**
   * Reads a secret and decrypts it.
   *
   * @param userPrefs the preferences node
   * @param key the preferences key
   * @param cryptor the cryptor
   * @return the secret, null if there is none
   */
  private static char[] getSecret(Preferences userPrefs, String key, Cryptor cryptor) {
    String encrypted = userPrefs.get(key, null);
    return encrypted == null ? null : cryptor.decrypt64ToChars(encrypted);
  }

  /**
   * Wipes a secret.
   *
   * @param secret the secret, null if none
   */
  private static void wipe(char[] secret) {
    if (secret != null) {
      Arrays.fill(secret, '\0');
    }
  }

  private GoogleCredentialsStore() {
  }

}

Two rules run through the class. The tokens are keyed by the client id they were issued for, so reconfiguring the OAuth client silently invalidates them. And nothing here ever breaks a login: whatever goes wrong — no cryptor, a changed key, a hand-edited file, a value beyond Preferences.MAX_VALUE_LENGTH — the sign-in is forgotten and the caller falls back to the browser.


Step 7 — The Client: The Credentials Provider

This is the class Tentackle actually calls. It is registered by the name of the authentication method, which is what authentication=google in backend.properties selects:

@CredentialsProviderService(GoogleCredentialsProvider.METHOD)
public class GoogleCredentialsProvider implements CredentialsProvider {
  public static final String METHOD = "google";

Declaring isInteractive() as true is all the user interface needs to know: the RDC login view hides the username and password fields and shows a single sign-in button instead, and runs createCredentials on a background thread. Blocking here — and the flow blocks for as long as the user takes — never freezes the FX thread.

createCredentials walks down a ladder, cheapest rung first:

Situation What happens
a remembered ID token, still valid for over a minute used as it is — no network, no browser
a remembered but expired token with a refresh token renewed silently at the token endpoint
nothing remembered, or the renewal failed the full browser flow

myapp-client/src/main/java/com/example/myapp/client/auth/GoogleCredentialsProvider.java:

package com.example.myapp.client.auth;

import com.example.myapp.client.ClientBundle;

import org.tentackle.app.Application;
import org.tentackle.common.EncryptedProperties;
import org.tentackle.log.Logger;
import org.tentackle.session.SessionInfo;
import org.tentackle.session.auth.AuthenticationException;
import org.tentackle.session.auth.Credentials;
import org.tentackle.session.auth.CredentialsPrompt;
import org.tentackle.session.auth.CredentialsProvider;
import org.tentackle.session.auth.CredentialsProviderService;
import org.tentackle.session.auth.TokenCredentials;

import java.net.URI;
import java.util.Arrays;
import java.util.Map;

/**
 * Signs the user in with its Google account.
 * <p>
 * Implements the OAuth 2.0 authorization code flow with PKCE, which is what Google prescribes for
 * desktop applications: the browser is sent to Google, Google redirects back to a loopback port
 * this provider listens on, and the authorization code is then exchanged for an OpenID Connect ID
 * token. That ID token travels to the middle tier as {@link TokenCredentials} and is verified
 * there (see the server's {@code GoogleIdTokenAuthenticationProvider}).
 * <p>
 * The provider is selected by {@code authentication=google} in {@code backend.properties}, which
 * also holds {@code google.clientId}, {@code google.clientSecret} and the optional
 * {@code google.hostedDomain}. The client secret of a "desktop app" OAuth client is not really a
 * secret, but it should still be stored {@code ~}-encrypted.
 * <p>
 * A sign-in is remembered in the java preferences of the local machine (see
 * {@link GoogleCredentialsStore}), so the browser is only needed when the remembered sign-in
 * cannot carry the login anymore: as long as the ID token is valid it is used as it is, and once
 * it expired the refresh token renews it silently. Only if that fails as well &ndash; because
 * nothing is remembered, the tokens were revoked or the server refused them &ndash; does the user
 * see the browser again.
 * <p>
 * Because the provider is interactive, the login view hides the username and password fields and
 * shows a sign-in button instead. The Rich Desktop Client runs the provider on a background
 * thread, so blocking here does not freeze the FX thread.
 */
@CredentialsProviderService(GoogleCredentialsProvider.METHOD)
public class GoogleCredentialsProvider implements CredentialsProvider {

  /** The name of the authentication method, i.e. the value of the {@code authentication} property. */
  public static final String METHOD = "google";

  /** Property key of the OAuth client id. */
  public static final String CLIENT_ID_PROPERTY = "google.clientId";

  /** Property key of the OAuth client secret. */
  public static final String CLIENT_SECRET_PROPERTY = "google.clientSecret";

  /** Property key of the optional Google Workspace domain to restrict the sign-in to. */
  public static final String HOSTED_DOMAIN_PROPERTY = "google.hostedDomain";


  private static final Logger LOGGER = Logger.get();

  private static final long TIMEOUT = 180000;     // milliseconds granted to the user to sign in
  private static final long POLL_INTERVAL = 250;  // milliseconds between two cancel checks
  private static final long EXPIRY_MARGIN = 60000;  // don't use a token expiring within a minute


  /**
   * The backend properties of the last sign-in.
   * <p>
   * Kept so that {@link #refresh} can read the client credentials again. The properties encrypt
   * the secret in memory, which is why the secret itself is not cached here.
   */
  private volatile EncryptedProperties properties;

  /**
   * True if a remembered sign-in has been offered in this run of the application.
   * <p>
   * The server may still refuse it, for example because the account is no longer known. The user
   * would then press the sign-in button again, and offering the same tokens once more could only
   * fail once more. The flag makes the second attempt forget them and ask the browser instead.
   */
  private volatile boolean rememberedCredentialsUsed;


  /**
   * Creates the provider.
   */
  public GoogleCredentialsProvider() {
    // services need a public no-arg constructor
  }


  @Override
  public boolean isInteractive() {
    return true;
  }

  @Override
  public Credentials createCredentials(SessionInfo sessionInfo, CredentialsPrompt prompt) {
    EncryptedProperties backendProperties = sessionInfo.getProperties();
    String clientId = getProperty(backendProperties, CLIENT_ID_PROPERTY);
    if (clientId == null) {
      throw new AuthenticationException(CLIENT_ID_PROPERTY + " is not configured");
    }
    String hostedDomain = getProperty(backendProperties, HOSTED_DOMAIN_PROPERTY);
    properties = backendProperties;

    TokenCredentials remembered = loadRememberedCredentials(clientId);
    if (remembered != null) {
      if (isUsable(remembered)) {
        LOGGER.info("reusing the remembered sign-in");
        rememberedCredentialsUsed = true;
        return remembered;
      }
      prompt.showMessage(ClientBundle.getString("renewing the sign-in"));
      TokenCredentials renewed = renew(remembered, backendProperties);
      if (renewed != null) {
        LOGGER.info("the remembered sign-in has been renewed");
        return renewed;
      }
      // nothing left but to ask the user again
    }

    String codeVerifier = GoogleOAuth.createRandomValue();
    String state = GoogleOAuth.createRandomValue();

    try (LoopbackReceiver receiver = new LoopbackReceiver()) {
      String redirectUri = receiver.getRedirectUri();

      Map<String, String> parameters = GoogleOAuth.createParameters();
      parameters.put("client_id", clientId);
      parameters.put("redirect_uri", redirectUri);
      parameters.put("response_type", "code");
      parameters.put("scope", GoogleOAuth.SCOPE);
      parameters.put("code_challenge", GoogleOAuth.createCodeChallenge(codeVerifier));
      parameters.put("code_challenge_method", "S256");
      parameters.put("state", state);
      parameters.put("nonce", GoogleOAuth.createRandomValue());
      parameters.put("access_type", "offline");
      parameters.put("prompt", "consent");
      if (hostedDomain != null) {
        parameters.put("hd", hostedDomain);
      }

      URI authorizationUri = GoogleOAuth.createUri(GoogleOAuth.AUTHORIZATION_ENDPOINT, parameters);
      prompt.browse(authorizationUri);
      prompt.showMessage(ClientBundle.getString("waiting for the browser"));

      Map<String, String> response = awaitResponse(receiver, prompt);
      if (!state.equals(response.get("state"))) {
        throw new AuthenticationException("the authorization response does not belong to this request");
      }
      String code = response.get("code");
      if (code == null) {
        throw new AuthenticationException("the authorization was denied: " + response.get("error"));
      }

      prompt.showMessage(ClientBundle.getString("signing in"));

      Map<String, String> tokenParameters = GoogleOAuth.createParameters();
      tokenParameters.put("grant_type", "authorization_code");
      tokenParameters.put("code", code);
      tokenParameters.put("code_verifier", codeVerifier);
      tokenParameters.put("redirect_uri", redirectUri);
      addClientCredentials(tokenParameters, backendProperties);

      TokenCredentials credentials = GoogleOAuth.requestToken(tokenParameters);
      LOGGER.info("signed in with Google");
      rememberedCredentialsUsed = false;
      GoogleCredentialsStore.save(getPreferencesClass(), clientId, credentials);
      return credentials;
    }
  }

  @Override
  public Credentials refresh(Credentials credentials) {
    if (!(credentials instanceof TokenCredentials tokenCredentials)) {
      return null;
    }

    EncryptedProperties backendProperties = properties;
    if (backendProperties == null) {
      LOGGER.warning("no sign-in has happened yet, cannot refresh");
      return null;
    }

    TokenCredentials refreshed = renew(tokenCredentials, backendProperties);
    if (refreshed != null) {
      LOGGER.info("the Google ID token was renewed");
    }
    return refreshed;
  }


  /**
   * Renews credentials with their refresh token.
   * <p>
   * The renewed credentials are remembered, so that the next start of the application benefits
   * from them as well. If the refresh token is gone or refused &ndash; the user revoked the
   * access, for example &ndash; the remembered sign-in is forgotten and null is returned, which
   * tells the caller to run the whole flow again.
   *
   * @param credentials the credentials to renew
   * @param backendProperties the backend properties holding the client credentials
   * @return the renewed credentials, null if they cannot be renewed
   */
  private TokenCredentials renew(TokenCredentials credentials, EncryptedProperties backendProperties) {
    char[] refreshToken = credentials.getRefreshToken();
    if (refreshToken == null || refreshToken.length == 0) {
      LOGGER.info("no refresh token available, a new sign-in is required");
      return null;
    }

    Map<String, String> parameters = GoogleOAuth.createParameters();
    parameters.put("grant_type", "refresh_token");
    parameters.put("refresh_token", new String(refreshToken));
    addClientCredentials(parameters, backendProperties);

    TokenCredentials refreshed;
    try {
      refreshed = GoogleOAuth.requestToken(parameters);
    }
    catch (AuthenticationException ax) {
      LOGGER.warning("the refresh token is no longer valid, a new sign-in is required", ax);
      GoogleCredentialsStore.forget(getPreferencesClass());
      return null;
    }

    char[] renewedRefreshToken = refreshed.getRefreshToken();
    if (renewedRefreshToken == null || renewedRefreshToken.length == 0) {
      // Google does not repeat the refresh token, so keep the one we already have
      refreshed.setRefreshToken(refreshToken);
    }
    GoogleCredentialsStore.save(getPreferencesClass(),
                                getProperty(backendProperties, CLIENT_ID_PROPERTY), refreshed);
    rememberedCredentialsUsed = true;    // the user did not see a browser for these
    return refreshed;
  }

  /**
   * Loads the remembered sign-in, if there is one to be used.
   *
   * @param clientId the configured OAuth client id
   * @return the remembered credentials, null if there are none or they must not be used again
   */
  private TokenCredentials loadRememberedCredentials(String clientId) {
    if (rememberedCredentialsUsed) {
      // offered before and we are asked again: they didn't get us in
      LOGGER.info("the remembered sign-in was not accepted and has been forgotten");
      GoogleCredentialsStore.forget(getPreferencesClass());
      return null;
    }
    return GoogleCredentialsStore.load(getPreferencesClass(), clientId);
  }

  /**
   * Returns whether credentials are worth being sent to the server.
   *
   * @param credentials the credentials
   * @return true if the token does not expire within the next moments
   */
  private boolean isUsable(TokenCredentials credentials) {
    long expiresAt = credentials.getExpiresAt();
    return expiresAt > System.currentTimeMillis() + EXPIRY_MARGIN;
  }

  /**
   * Gets the class determining the preferences node the sign-in is remembered in.
   * <p>
   * The application class, so that the sign-in ends up next to the theme settings, which
   * {@link org.tentackle.fx.ThemeUtilities} stores for the same class.
   *
   * @return the application class, this provider's class if there is no application at all
   */
  private Class<?> getPreferencesClass() {
    Application application = Application.getInstance();
    return application == null ? getClass() : application.getClass();
  }


  /**
   * Adds the client credentials to the parameters of a token request.
   * <p>
   * The secret is decrypted as late as possible and the character array wiped right away, so it
   * only exists in the clear for the few statements it takes to encode it.
   *
   * @param parameters the parameters of the token request
   * @param backendProperties the backend properties
   */
  private void addClientCredentials(Map<String, String> parameters, EncryptedProperties backendProperties) {
    parameters.put("client_id", getProperty(backendProperties, CLIENT_ID_PROPERTY));
    char[] clientSecret = backendProperties.getPropertyAsChars(CLIENT_SECRET_PROPERTY);
    if (clientSecret == null) {
      throw new AuthenticationException(CLIENT_SECRET_PROPERTY + " is not configured");
    }
    try {
      parameters.put("client_secret", new String(clientSecret));
    }
    finally {
      Arrays.fill(clientSecret, '\0');
    }
  }


  /**
   * Waits for the browser to come back, while watching out for a cancel in the login view.
   *
   * @param receiver the loopback receiver
   * @param prompt the prompt
   * @return the parameters of the authorization response
   * @throws AuthenticationException if the user canceled or the timeout expired
   */
  private Map<String, String> awaitResponse(LoopbackReceiver receiver, CredentialsPrompt prompt) {
    long deadline = System.currentTimeMillis() + TIMEOUT;
    try {
      while (System.currentTimeMillis() < deadline) {
        if (prompt.isCanceled()) {
          throw new AuthenticationException(ClientBundle.getString("sign-in was canceled"));
        }
        prompt.setProgress(1.0 - (deadline - System.currentTimeMillis()) / (double) TIMEOUT);
        Map<String, String> response = receiver.awaitResponse(POLL_INTERVAL);
        if (response != null) {
          return response;
        }
      }
    }
    catch (InterruptedException ix) {
      Thread.currentThread().interrupt();
      throw new AuthenticationException(ClientBundle.getString("sign-in was canceled"), ix);
    }
    throw new AuthenticationException(ClientBundle.getString("sign-in timed out"));
  }

  /**
   * Gets a configured property.
   *
   * @param properties the backend properties
   * @param key the property key
   * @return the value, null if unset or blank
   */
  private String getProperty(EncryptedProperties properties, String key) {
    String value = properties.getProperty(key);
    return value == null || value.isBlank() ? null : value.trim();
  }

}

A few things deserve a second look.

The authorization request parameters.

Parameter Why
response_type=code the authorization code flow — the only one a desktop app may use
scope=openid email profile openid is what makes Google return an ID token at all
code_challenge + code_challenge_method=S256 PKCE (step 5)
state checked on return: ties the response to this request
nonce replay protection, carried through into the ID token's claims
access_type=offline + prompt=consent ask for a refresh token, which is what makes step 6 worth having
hd optional: pre-selects the Google Workspace domain in the account picker

CredentialsPrompt keeps the provider UI-agnostic. browse(uri) opens the system browser in the desktop client and merely logs the URL in a console application or a test; showMessage and setProgress drive the login view; isCanceled() is polled every 250 ms so that pressing Cancel ends the wait promptly instead of after the three-minute deadline.

refresh(Credentials) is the answer to a CredentialsExpiredException. When the server rejects a token as expired, LoginFailedHandler asks the provider to renew it and retries the login once — without consuming one of the three login attempts and without bothering the user. Note that Google does not repeat the refresh token in a renewal answer, so renew carries the existing one over; losing it would force a browser round-trip on the next start.

rememberedCredentialsUsed guards against a useless second attempt. If the server refused a remembered sign-in — the account was disabled, say — offering the very same tokens again could only fail again. The flag makes the next attempt forget them and go to the browser.

The client secret is decrypted as late as possible. addClientCredentials asks EncryptedProperties for the plain characters, encodes them, and wipes the array in a finally. The secret exists in the clear for the few statements it takes to build the form.

Finally, the module descriptor. The provider is instantiated reflectively through the service SPI, so its package must be exported — see Tentackle Modules:

module com.example.myapp.client {
  exports com.example.myapp.client;
  exports com.example.myapp.client.auth;   // the credentials provider is instantiated reflectively

  requires transitive com.example.myapp.gui;
  ...
  requires java.net.http;
  requires jdk.httpserver;

  provides org.tentackle.common.ModuleHook with com.example.myapp.client.service.Hook;
}

The META-INF service entry itself is written at build time by the tentackle-maven-plugin; there is nothing to register by hand.


Step 8 — The Server: Google's Signing Keys

The rest happens in the middle tier. An ID token is a JWT signed with one of a handful of RSA keys Google publishes at a JWKS endpoint and rotates every few days, announcing the successor before switching. Caching them by key id turns verification into a local operation: no call to Google per login, only the occasional refetch.

myapp-server/src/main/java/com/example/myapp/server/auth/GoogleKeySource.java:

package com.example.myapp.server.auth;

import com.example.myapp.common.json.Json;

import org.tentackle.log.Logger;
import org.tentackle.session.auth.AuthenticationException;

import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPublicKeySpec;
import java.time.Duration;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * The public keys Google signs its ID tokens with.
 * <p>
 * The keys are fetched from Google's JWKS endpoint and cached by their key id. Google rotates them
 * every few days and announces the successor before switching, so an unknown key id simply
 * triggers a refetch. A minimum interval between refetches keeps a flood of tokens with bogus key
 * ids from hammering the endpoint.
 */
public class GoogleKeySource {

  /** The JWKS endpoint of Google's OpenID provider. */
  public static final String JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs";

  private static final Logger LOGGER = Logger.get();

  private static final long MIN_REFRESH_INTERVAL = 60000;   // milliseconds between two refetches
  private static final Duration TIMEOUT = Duration.ofSeconds(10);


  private final HttpClient httpClient;
  private Map<String, RSAPublicKey> keys;     // the keys by their key id
  private long lastFetched;                   // epochal millis of the last successful fetch


  /**
   * Creates the key source.
   */
  public GoogleKeySource() {
    httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
    keys = Map.of();
  }


  /**
   * Gets the key for a given key id.
   *
   * @param kid the key id taken from the token's header
   * @return the public key, never null
   * @throws AuthenticationException if there is no such key
   */
  public synchronized RSAPublicKey getKey(String kid) {
    RSAPublicKey key = keys.get(kid);
    if (key == null && System.currentTimeMillis() - lastFetched >= MIN_REFRESH_INTERVAL) {
      // unknown key id: Google may have rotated the keys
      refresh();
      key = keys.get(kid);
    }
    if (key == null) {
      throw new AuthenticationException("no Google signing key for key id '" + kid + "'");
    }
    return key;
  }


  /**
   * Fetches the JWKS document and replaces the cached keys.
   *
   * @throws AuthenticationException if the keys could not be retrieved
   */
  private void refresh() {
    HttpRequest request = HttpRequest.newBuilder(URI.create(JWKS_URI))
                                     .timeout(TIMEOUT)
                                     .header("Accept", "application/json")
                                     .GET()
                                     .build();
    HttpResponse<String> response;
    try {
      response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
    }
    catch (InterruptedException ix) {
      Thread.currentThread().interrupt();
      throw new AuthenticationException("interrupted while fetching " + JWKS_URI, ix);
    }
    catch (IOException iox) {
      throw new AuthenticationException("could not fetch " + JWKS_URI, iox);
    }

    if (response.statusCode() != 200) {
      throw new AuthenticationException(JWKS_URI + " answered with status " + response.statusCode());
    }

    Map<String, RSAPublicKey> fetchedKeys = new HashMap<>();
    Base64.Decoder decoder = Base64.getUrlDecoder();
    List<Object> jwks = Json.getList(Json.parseObject(response.body()), "keys");
    for (Object entry : jwks) {
      if (entry instanceof Map<?, ?> map) {
        @SuppressWarnings("unchecked")
        Map<String, Object> jwk = (Map<String, Object>) map;
        String kid = Json.getString(jwk, "kid");
        String modulus = Json.getString(jwk, "n");
        String exponent = Json.getString(jwk, "e");
        if (kid == null || modulus == null || exponent == null || !"RSA".equals(Json.getString(jwk, "kty"))) {
          continue;   // not an RSA key we could use
        }
        try {
          RSAPublicKeySpec spec = new RSAPublicKeySpec(new BigInteger(1, decoder.decode(modulus)),
                                                       new BigInteger(1, decoder.decode(exponent)));
          fetchedKeys.put(kid, (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec));
        }
        catch (IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException ex) {
          LOGGER.warning("skipping unusable JWK " + kid, ex);
        }
      }
    }

    if (fetchedKeys.isEmpty()) {
      throw new AuthenticationException("no usable RSA keys in " + JWKS_URI);
    }

    keys = Map.copyOf(fetchedKeys);
    lastFetched = System.currentTimeMillis();
    LOGGER.info("{0} Google signing keys loaded", keys.size());
  }

}

An unknown key id triggers a refetch, but no more than once a minute — otherwise a stream of tokens carrying bogus key ids would turn the server into a client hammering Google.


Step 9 — The Server: Verifying the ID Token

The AuthenticationProvider is an ordinary service with a public no-arg constructor. Two declarations place it in the chain:

@Service(AuthenticationProvider.class)
public class GoogleIdTokenAuthenticationProvider implements AuthenticationProvider {

  @Override
  public boolean supports(Credentials credentials) {
    return credentials instanceof TokenCredentials && getClientId() != null;
  }

  @Override
  public int getPriority() {
    return -10;   // asked before the password provider
  }

supports() returning false when google.clientId is unset is the clean abstention promised in step 2 — an unconfigured server simply never sees this provider. The negative priority puts it ahead of the generated password provider, which is only a matter of ordering: the two answer different credential types anyway.

Everything the token claims is then checked, and what a failed check means is as important as the check itself:

Check On failure
alg is RS256, kid present, signature verifies veto — AuthenticationException
iss is https://accounts.google.com or accounts.google.com veto
aud equals the configured client id (a string or an array) veto — the token was minted for another application
email present and email_verified true veto
hd equals google.hostedDomain, if configured veto
exp not older than 60 s of clock skew CredentialsExpiredExceptionthe client may retry

The expiry check comes last, deliberately. It is the only failure the client is allowed to do something about: a CredentialsExpiredException tells LoginFailedHandler that a refresh() and one retry are worth trying. Checking it before the signature would let an unsigned, expired token provoke a retry loop.

myapp-server/src/main/java/com/example/myapp/server/auth/GoogleIdTokenAuthenticationProvider.java:

package com.example.myapp.server.auth;

import com.example.myapp.common.json.Json;

import org.tentackle.app.Application;
import org.tentackle.common.Service;
import org.tentackle.dbms.trip.RemoteDbSessionImpl;
import org.tentackle.log.Logger;
import org.tentackle.session.auth.Authentication;
import org.tentackle.session.auth.AuthenticationException;
import org.tentackle.session.auth.AuthenticationProvider;
import org.tentackle.session.auth.Credentials;
import org.tentackle.session.auth.CredentialsExpiredException;
import org.tentackle.session.auth.DefaultAuthentication;
import org.tentackle.session.auth.TokenCredentials;

import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.Signature;
import java.security.interfaces.RSAPublicKey;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

/**
 * Authenticates a user by a Google OpenID Connect ID token.
 * <p>
 * The client obtains the token in the browser (see the credentials provider of the FX client) and
 * presents it as {@link TokenCredentials}. The token is a JWT signed by Google, so it is verified
 * offline against Google's published keys: no call to Google is needed per login, only the
 * occasional refresh of the key set.
 * <p>
 * The provider deliberately leaves the user id at zero. Mapping the proven Google identity onto a
 * MyApp user is the job of the {@link MyAppUserResolver}, which keeps this class free of any
 * knowledge about the application's user entity.
 * <p>
 * If {@code google.clientId} is not configured, the provider abstains and the server behaves as if
 * single sign-on had never been installed.
 * <p>
 * Note that tokens are not renewed during a running session. Only the login path retries once via
 * {@code CredentialsProvider.refresh} when a {@link CredentialsExpiredException} is thrown.
 */
@Service(AuthenticationProvider.class)
public class GoogleIdTokenAuthenticationProvider implements AuthenticationProvider {

  /** Property key of the OAuth client id the tokens must be issued for. */
  public static final String CLIENT_ID_PROPERTY = "google.clientId";

  /** Property key of the optional Google Workspace domain the accounts must belong to. */
  public static final String HOSTED_DOMAIN_PROPERTY = "google.hostedDomain";

  /** Issuers accepted in the {@code iss} claim. */
  private static final Set<String> ISSUERS = Set.of("https://accounts.google.com", "accounts.google.com");

  /** Tolerated clock difference between this server and Google, in seconds. */
  private static final long CLOCK_SKEW = 60;

  private static final Logger LOGGER = Logger.get();


  private final GoogleKeySource keySource;


  /**
   * Creates the provider.
   */
  public GoogleIdTokenAuthenticationProvider() {
    keySource = new GoogleKeySource();
  }


  @Override
  public boolean supports(Credentials credentials) {
    return credentials instanceof TokenCredentials && getClientId() != null;
  }

  @Override
  public int getPriority() {
    return -10;   // asked before the password provider
  }

  @Override
  public Authentication authenticate(Credentials credentials) {
    char[] token = ((TokenCredentials) credentials).getToken();
    if (token == null || token.length == 0) {
      throw new AuthenticationException("no token presented");
    }

    Map<String, Object> claims = verify(new String(token));

    String email = Json.getString(claims, "email");
    if (email == null || email.isBlank()) {
      throw new AuthenticationException("the ID token carries no email claim");
    }
    if (!Json.getBoolean(claims, "email_verified")) {
      throw new AuthenticationException("the email of the ID token is not verified");
    }

    String hostedDomain = getProperty(HOSTED_DOMAIN_PROPERTY);
    if (hostedDomain != null && !hostedDomain.equals(Json.getString(claims, "hd"))) {
      throw new AuthenticationException("the ID token does not belong to the hosted domain");
    }

    // the resolver looks the user up by its lowercase email
    DefaultAuthentication authentication =
        new DefaultAuthentication(email.trim().toLowerCase(Locale.ROOT), credentials);
    authentication.setExpiresAt(Json.getLong(claims, "exp", 0) * 1000);
    putAttribute(authentication, claims, "sub");
    putAttribute(authentication, claims, "email");
    putAttribute(authentication, claims, "name");
    putAttribute(authentication, claims, "hd");

    LOGGER.fine("Google ID token verified for subject {0}", Json.getString(claims, "sub"));
    return authentication;
  }


  /**
   * Verifies the signature and the standard claims of an ID token.
   *
   * @param token the encoded JWT
   * @return the verified claims
   * @throws AuthenticationException if the token is not a valid ID token for this application
   */
  private Map<String, Object> verify(String token) {
    int firstDot = token.indexOf('.');
    int lastDot = token.lastIndexOf('.');
    if (firstDot <= 0 || lastDot <= firstDot) {
      throw new AuthenticationException("the token is not a JWT");
    }

    Base64.Decoder decoder = Base64.getUrlDecoder();
    Map<String, Object> header;
    Map<String, Object> claims;
    byte[] signature;
    try {
      header = Json.parseObject(new String(decoder.decode(token.substring(0, firstDot)), StandardCharsets.UTF_8));
      claims = Json.parseObject(new String(decoder.decode(token.substring(firstDot + 1, lastDot)), StandardCharsets.UTF_8));
      signature = decoder.decode(token.substring(lastDot + 1));
    }
    catch (RuntimeException rex) {
      throw new AuthenticationException("the token is not a readable JWT", rex);
    }

    if (!"RS256".equals(Json.getString(header, "alg"))) {
      throw new AuthenticationException("unsupported token signature algorithm");
    }
    String kid = Json.getString(header, "kid");
    if (kid == null) {
      throw new AuthenticationException("the token header carries no key id");
    }

    RSAPublicKey key = keySource.getKey(kid);
    try {
      Signature verifier = Signature.getInstance("SHA256withRSA");
      verifier.initVerify(key);
      verifier.update(token.substring(0, lastDot).getBytes(StandardCharsets.US_ASCII));
      if (!verifier.verify(signature)) {
        throw new AuthenticationException("the token signature is invalid");
      }
    }
    catch (GeneralSecurityException gsx) {
      throw new AuthenticationException("the token signature could not be verified", gsx);
    }

    if (!ISSUERS.contains(Json.getString(claims, "iss"))) {
      throw new AuthenticationException("the token was not issued by Google");
    }
    if (!isAudienceAccepted(claims)) {
      throw new AuthenticationException("the token was not issued for this application");
    }

    // check the expiry last: it is the only failure the client is allowed to retry
    long now = System.currentTimeMillis() / 1000;
    if (Json.getLong(claims, "exp", 0) + CLOCK_SKEW < now) {
      // if the application uses RdcUtilitiesWithBackgroundPool, sessions are created and closed
      // while the client application is running. If in the meantime the token expires, opening new sessions
      // would fail, and the user would have to terminate the application and restart it.
      // To prevent this, we check whether another (main-)session with the same user and token
      // is still open. If so, we do not throw an exception.
      char[] tokenCharArray = token.toCharArray();
      String email = Json.getString(claims, "email");
      if (email != null) {
        email = email.toUpperCase(Locale.ROOT); // sessionInfo.getUserName() is always upper case, even if it is a mail address
        for (RemoteDbSessionImpl openSession : RemoteDbSessionImpl.getOpenSessions()) {
          if (!openSession.getSession().isCloned()) {   // main session
            Credentials credentials = openSession.getClientSessionInfo().getCredentials();
            if (credentials instanceof TokenCredentials tokenCredentials) {
              if (email.equals(openSession.getClientSessionInfo().getUserName())) { // same user
                char[] verifiedToken = tokenCredentials.getToken();
                if (Arrays.equals(tokenCharArray, verifiedToken)) { // same token
                  LOGGER.info("token expired but user {0} still has a valid main session with that token -> granted", email);
                  return claims;
                }
              }
            }
          }
        }
      }
      throw new CredentialsExpiredException("the token has expired");
    }

    return claims;
  }


  /**
   * Checks the {@code aud} claim, which may be a single string or an array of strings.
   *
   * @param claims the token's claims
   * @return true if the token was issued for the configured client id
   */
  private boolean isAudienceAccepted(Map<String, Object> claims) {
    String clientId = getClientId();
    Object audience = claims.get("aud");
    if (audience instanceof List<?> list) {
      return list.contains(clientId);
    }
    return audience instanceof String str && str.equals(clientId);
  }

  /**
   * Copies a claim into the authentication's attributes, if present.
   *
   * @param authentication the authentication
   * @param claims the token's claims
   * @param claim the name of the claim
   */
  private void putAttribute(Authentication authentication, Map<String, Object> claims, String claim) {
    Object value = claims.get(claim);
    if (value != null) {
      authentication.getAttributes().put(claim, value);
    }
  }

  /**
   * Gets the configured OAuth client id.
   *
   * @return the client id, null if single sign-on is not configured
   */
  private String getClientId() {
    return getProperty(CLIENT_ID_PROPERTY);
  }

  /**
   * Gets a property from the server's backend properties.
   *
   * @param key the property key
   * @return the value, null if unset or blank
   */
  private String getProperty(String key) {
    String value = Application.getInstance().getProperty(key);
    return value == null || value.isBlank() ? null : value.trim();
  }

}

Two aspects are worth spelling out.

The provider leaves the user id at zero. It proves an identity — a verified Google e-mail address, plus the sub, name and hd claims kept as attributes — and stops there. Which user of this application that is, is the resolver's business in step 10. That split is what keeps the class reusable: nothing in it knows the application's user entity.

The tolerance for pooled sessions. An application using RdcUtilitiesWithBackgroundPool opens and closes sessions while the client is running. If the ID token expires in the meantime, a background session would fail to open and the user would have to restart the application. The provider therefore grants an expired token if the same user still holds an open, non-cloned main session presenting the identical token — the login that established the session was verified, and this is the same client continuing it. Applications that do not pool sessions can drop that block and simply throw.


Step 10 — The Server: From an Identity to a User

The last step is the UserResolver. The AuthenticationManager consults it only when the chain left Authentication.getUserId() at zero, so the password provider — which knows the user already — never reaches it.

myapp-server/src/main/java/com/example/myapp/server/auth/MyAppUserResolver.java:

package com.example.myapp.server.auth;

import com.example.myapp.pdo.md.User;

import org.tentackle.common.Service;
import org.tentackle.log.Logger;
import org.tentackle.pdo.DomainContext;
import org.tentackle.pdo.Pdo;
import org.tentackle.session.Session;
import org.tentackle.session.auth.Authentication;
import org.tentackle.session.auth.AuthenticationException;
import org.tentackle.session.auth.UserResolver;

import java.util.Locale;

/**
 * Maps a proven external identity onto a MyApp user.
 * <p>
 * The authentication manager consults the resolver only if the authenticating provider left the
 * user id at zero, which is what the token based providers do. The password provider knows the
 * user already and never gets here.
 * <p>
 * Users are not created on the fly: an administrator has to create the user and enter its e-mail
 * address before it can sign in. This keeps the security grants under the administrator's control.
 */
@Service(UserResolver.class)
public class MyAppUserResolver implements UserResolver {

  private static final Logger LOGGER = Logger.get();


  /**
   * Creates the resolver.
   */
  public MyAppUserResolver() {
    // services need a public no-arg constructor
  }


  @Override
  public void resolveUser(Authentication authentication) {
    String email = authentication.getName();
    if (email == null || email.isBlank()) {
      throw new AuthenticationException("the authenticated principal has no name");
    }

    // the session is already current, so the persistence layer is available
    DomainContext context = Pdo.createDomainContext(Session.getSession());
    User user = Pdo.create(User.class, context).selectByEmail(email.trim().toLowerCase(Locale.ROOT));

    if (user == null) {
      // don't log the email address of an unknown principal
      LOGGER.warning("attempt to login with an e-mail address belonging to no user, authenticated by {0}",
                     authentication.getProviderName());
      throw new AuthenticationException("login refused");
    }
    if (!user.isLoginAllowed()) {
      LOGGER.warning("attempt to login for disabled user {0}", user.getName());
      throw new AuthenticationException("login refused");
    }

    authentication.setUserId(user.getId());
    authentication.setUserClassId(user.getClassId());
  }

}

The session is already current when the resolver runs, so the persistence layer is available and selectByEmail from step 1 does the lookup.

Users are not created on the fly. A verified Google account is not by itself an authorization to use the application: an administrator has to create the user and enter its address first. That keeps the security grants under the administrator's control. Applications that do want auto-provisioning create the User here, in a transaction, with whatever default group the policy calls for.

Note the logging: an unknown principal's e-mail address is not written to the log, and the caller is told nothing but "login refused". A login failure must not become an oracle for which addresses are known.

From here on nothing is specific to single sign-on anymore. verifySessionInfo copies the userId/userClassId pair and the authenticated name onto the client's session info, and the ordinary machinery takes over: DomainContext, the SecurityManager resolving grantees, the modification log. getAuthentication() on the remote session keeps the full identity — claims included — available, which is where a SecurityManager would map identity-provider groups onto Tentackle grantees.

Add java.net.http to the server's module descriptor for the key source:

module com.example.myapp.server {
  ...
  requires java.net.http;
}

Step 11 — The User Interface

Less changes here than one would expect.

The login view needs no code at all. isInteractive() returning true is what makes Login.setSessionInfo(...) hide the username and password fields and show the sign-in button, and what wires FxCredentialsPrompt — which opens the system browser via Desktop.getDesktop() and routes messages and progress through Platform.runLater — into the provider.

The user editor gets an e-mail row. In UserEditor.fxml, a field and its label (shifting the rows below it down by one):

<Label text="%email" GridPane.halignment="RIGHT" GridPane.rowIndex="1" />
<TextField fx:id="userEmailField" GridPane.columnIndex="1" GridPane.rowIndex="1" />

the matching member in UserEditor.java — the binding is by name, so there is nothing else to do:

@FXML
private FxTextField userEmailField;

and the label in UserEditor.properties, the column header in UserGuiProvider.properties, each with its _de companion:

email=E-Mail:

The flow needs a handful of translations — the two messages shown in the login view and the two pages the browser lands on. If the client module has no bundle yet, this is the moment to add one:

package com.example.myapp.client;

import org.tentackle.common.Bundle;
import org.tentackle.common.BundleFactory;
import org.tentackle.common.LocaleProvider;

import java.util.ResourceBundle;

/**
 * Bundle for translations of the client module.
 */
@Bundle
public class ClientBundle {

  /**
   * Gets the bundle.
   *
   * @return the resource bundle
   */
  public static ResourceBundle getBundle() {
    return BundleFactory.getBundle(ClientBundle.class.getName(), LocaleProvider.getInstance().getLocale());
  }

  /**
   * Gets a string for the given key.
   *
   * @param key the key
   * @return the string from the bundle
   */
  public static String getString(String key) {
    return getBundle().getString(key);
  }

  private ClientBundle() {
  }

}

ClientBundle.properties:

waiting\ for\ the\ browser=waiting for the browser...
signing\ in=signing in...
renewing\ the\ sign-in=renewing the sign-in...
sign-in\ was\ canceled=sign-in was canceled
sign-in\ timed\ out=sign-in timed out
you\ are\ signed\ in=You are signed in. You can close this window and return to MyApp.
sign-in\ failed=Sign-in failed. Please return to MyApp and try again.

and ClientBundle_de.properties:

waiting\ for\ the\ browser=warte auf den Browser...
signing\ in=melde an...
renewing\ the\ sign-in=erneuere die Anmeldung...
sign-in\ was\ canceled=Anmeldung abgebrochen
sign-in\ timed\ out=Zeitüberschreitung bei der Anmeldung
you\ are\ signed\ in=Sie sind angemeldet. Sie können dieses Fenster schließen und zu MyApp zurückkehren.
sign-in\ failed=Anmeldung fehlgeschlagen. Bitte kehren Sie zu MyApp zurück und versuchen Sie es erneut.

The check plugin verifies at build time that every key exists in every locale.


What the User Sees

Situation What happens
First start The sign-in button opens the browser; after consent the page says the window can be closed, and the application logs in.
Every following start Straight in. No browser, no network call to Google.
The ID token expired since the last start "renewing the sign-in..." for a moment, then in. Still no browser.
The token expires mid-login The server answers CredentialsExpiredException, the client refreshes and retries once, invisibly.
The user revoked the application's access The refresh fails, the remembered sign-in is dropped, the browser opens again.
A Google account belonging to no user The browser flow succeeds, the server refuses with "login refused".
The user presses Cancel The wait ends within 250 ms and the loopback port is released.

Adapting to Another Identity Provider

Everything above is generic OpenID Connect except four constants and two claim names. Any compliant provider publishes them at https://<issuer>/.well-known/openid-configuration:

In GoogleOAuth / GoogleKeySource Field of the discovery document
AUTHORIZATION_ENDPOINT authorization_endpoint
TOKEN_ENDPOINT token_endpoint
ISSUER issuer
JWKS_URI jwks_uri

A robust implementation fetches that document once at startup instead of hard-coding the four.

Provider What differs
Microsoft Entra ID The tenant is part of the issuer and must be checked as such; the identifying claim is usually preferred_username, and email may be absent.
Keycloak Issuer and endpoints are realm-specific; a public client needs no secret at all — drop addClientCredentials.
Okta, Auth0 Endpoints under the tenant domain; some ID tokens are signed with ES256, which verify() must then accept alongside RS256.
Any provider hd is Google's; restrict by the e-mail domain or a group claim instead.

The loopback receiver, PKCE, the credentials store, the resolver and the whole Tentackle wiring are untouched by the choice.

More than one method can coexist: a second @CredentialsProviderService("entra") alongside this one, a second @Service(AuthenticationProvider.class) on the server, and authentication=<method> picks per installation.


Known Gaps

  • Mid-session renewal. Credentials are renewed on the login path only. The tolerance in step 9 keeps pooled background sessions alive for an already-authenticated client, but a token that expires while a long-running session is open is not proactively refreshed.
  • No auto-provisioning. A user must exist before its Google account can sign in (step 10).
  • Direct (two-tier) sessions. A jdbc: or jndi: URL never reaches verifySessionInfo, so the provider chain is not involved at all. Single sign-on is a property of the application server tier.
  • RS256 only. GoogleKeySource reads RSA keys and verify() accepts RS256, which is what Google issues. Providers using ES256 need an EC branch in both.
  • The consent screen. While the OAuth client is in Testing, only the accounts listed as test users can sign in. Publishing it is a Google-side step this document does not cover.

See Also