Add sign-in to a Spring Boot app

This quickstart adds KeenAuth sign-in to a Spring Boot 4.1 app with Spring Security's OAuth 2.0 client, the standard OpenID Connect client of the Spring ecosystem. KeenAuth has no Java SDK yet, and none is needed: KeenAuth is a standard OpenID Connect provider. Your app is the OAuth client and keeps every token in the server-side session; the browser gets only a session id cookie. You configure sign-in, protect a page, call a protected API with the user's access token, sign out and test the app offline.

The app has these routes:

Route What it does
GET / Public home page: a sign-in link, or the user's name and a sign-out button
GET /dashboard Needs a session. Shows the user and the messages that your API returns
GET /oauth2/authorization/keenauth Served by Spring Security: starts sign-in
GET /login/oauth2/code/keenauth Served by Spring Security: the callback
POST /logout Served by Spring Security: ends the session here and at KeenAuth. Needs the CSRF token

Before you begin

1. Add the dependencies

The Spring Boot parent (spring-boot-starter-parent 4.1.2) pins every version. Add the web, OAuth 2.0 client and Thymeleaf starters:

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc</artifactId>
      <exclusions>
        <!-- Logback is EPL-1.0 OR LGPL-2.1, outside the licence allow list: Log4j 2 (Apache-2.0) logs instead. -->
        <exclusion>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
        <!-- EPL-2.0 OR GPL-2.0 with Classpath Exception, outside the allow list; this app uses none of its annotations. -->
        <exclusion>
          <groupId>jakarta.annotation</groupId>
          <artifactId>jakarta.annotation-api</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-log4j2</artifactId>
    </dependency>
    <!-- Spring Security's OAuth 2.0 client: OpenID Connect sign-in with discovery, PKCE and ID token checks. -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>

The example replaces two dependencies whose licences are outside the licence allow list of our examples: Logback (EPL-1.0 or LGPL-2.1) with Log4j 2, and jakarta.annotation-api (EPL-2.0 or GPL-2.0 with Classpath Exception), which the app does not use. For the same reason its tests use TestNG instead of JUnit (EPL-2.0). Your own app does not need to do this.

2. Configure

The app reads its configuration from environment variables only. Never commit their values.

Variable Required Meaning
KEENAUTH_ISSUER yes Your environment's issuer, https
KEENAUTH_CLIENT_ID yes The client_id of the web application
KEENAUTH_CLIENT_SECRET yes Its client secret
APP_BASE_URL yes The public URL of the app. The redirect URI is built from it. http only for localhost, 127.0.0.1 or [::1]
API_URL yes The API the app calls, for example http://localhost:3001
KEENAUTH_API_AUDIENCE yes The identifier of that API in KeenAuth
SESSION_SECURE_COOKIE no false only for plain http://localhost

KeenAuthSettings.from reads and checks these values at start-up. A missing or unusable value stops the app, and no error message contains a value.

	public static KeenAuthSettings from(Function<String, String> env) {
		return new KeenAuthSettings(url(need(env, "KEENAUTH_ISSUER"), "KEENAUTH_ISSUER", true, false),
				need(env, "KEENAUTH_CLIENT_ID"), need(env, "KEENAUTH_CLIENT_SECRET"),
				url(need(env, "APP_BASE_URL"), "APP_BASE_URL", false, false),
				url(need(env, "API_URL"), "API_URL", false, false),
				// RFC 8707: the access token is requested for your API, which then accepts it as its audience.
				url(need(env, "KEENAUTH_API_AUDIENCE"), "KEENAUTH_API_AUDIENCE", false, true));
	}

3. Register the client

The client registration comes from discovery (<issuer>/.well-known/openid-configuration), which Spring Security fetches at start-up. The example sets the client credentials, the redirect URI and the scopes, and turns on PKCE for this confidential client:

	/** The endpoints and the signing keys come from discovery: <issuer>/.well-known/openid-configuration. */
	@Bean
	ClientRegistrationRepository clientRegistrationRepository(KeenAuthSettings settings) {
		return new InMemoryClientRegistrationRepository(
				registration(ClientRegistrations.fromIssuerLocation(settings.issuer()), settings));
	}

	static ClientRegistration registration(ClientRegistration.Builder discovered, KeenAuthSettings settings) {
		return discovered.registrationId("keenauth")
			.clientId(settings.clientId())
			.clientSecret(settings.clientSecret())
			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
			.redirectUri(settings.redirectUri())
			// messages:read is a scope of your API; the example API requires it on /api/messages.
			.scope("openid", "profile", "email", "messages:read")
			// PKCE (RFC 7636) with S256 for this confidential client too; state and nonce are always on.
			.clientSettings(ClientRegistration.ClientSettings.builder().requireProofKey(true).build())
			.build();
	}

4. Configure sign-in, the API audience and sign-out

The authorization request resolver adds your API's identifier as resource (RFC 8707). Without it, the access token is not meant for your API and the API rejects it. Sign-out is Spring Security's POST /logout with the CSRF token, followed by OpenID Connect RP-Initiated Logout with id_token_hint and post_logout_redirect_uri:

		DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver(clients,
				"/oauth2/authorization");
		// RFC 8707: an access token for your API.
		resolver.setAuthorizationRequestCustomizer(
				(request) -> request.additionalParameters((params) -> params.put("resource", settings.apiAudience())));

		OidcClientInitiatedLogoutSuccessHandler logout = new OidcClientInitiatedLogoutSuccessHandler(clients);
		logout.setPostLogoutRedirectUri(settings.postLogoutRedirectUri());

		http.authorizeHttpRequests((authorize) -> authorize.requestMatchers("/", "/error").permitAll()
			.anyRequest()
			.authenticated())
			.oauth2Login((login) -> login.authorizationEndpoint((endpoint) -> endpoint.authorizationRequestResolver(resolver)))
			// POST /logout with the CSRF token; then OpenID Connect RP-Initiated Logout with id_token_hint.
			.logout((out) -> out.logoutSuccessHandler(logout));
		return http.build();

Spring Security checks state on the callback, exchanges the code with the PKCE verifier and checks the ID token (signature with your environment's keys, RS256, iss, aud, exp, iat and nonce). After sign-in it gives the user a new session id. The session lives on the server; with several servers, use Spring Session with a shared store.

The home page links to sign-in and posts to sign-out; th:action adds the CSRF token:

      <!-- th:action adds the CSRF token. -->
      <form th:action="@{/logout}" method="post"><button type="submit">Sign out</button></form>
    </div>
    <p th:unless="${user}"><a href="/oauth2/authorization/keenauth">Sign in</a></p>

5. Call your API

The dashboard needs a session. It takes the access token from the authorized client that Spring Security keeps in the session and calls your API with it. The token stays on the server; the browser receives only the HTML.

	/** Needs a session (SecurityConfig). The access token stays on the server; the browser receives only the HTML. */
	@GetMapping("/dashboard")
	public String dashboard(@AuthenticationPrincipal OidcUser user,
			@RegisteredOAuth2AuthorizedClient("keenauth") OAuth2AuthorizedClient client, Model model) {
		ResponseEntity<String> response = this.messages.fetch(client.getAccessToken().getTokenValue());
		model.addAttribute("user", user);
		model.addAttribute("status", response.getStatusCode().value());
		model.addAttribute("messages", response.getStatusCode().is2xxSuccessful() ? response.getBody() : null);
		return "dashboard";
	}
	public ResponseEntity<String> fetch(String accessToken) {
		return this.rest.get()
			.uri("/api/messages")
			.headers((headers) -> headers.setBearerAuth(accessToken))
			.retrieve()
			.onStatus((status) -> true, (request, response) -> {
			})
			.toEntity(String.class);
	}

The example has no refresh token: when the access token expires, the user signs in again.

6. Run the app

Start the API from the Express quickstart (or any API that accepts the tokens) and run the app with the variables exported:

mvn spring-boot:run

Open http://localhost:8080, choose Sign in, sign in at KeenAuth and open the dashboard.

7. Test without a network

The tests need no network and no KeenAuth account. A small HTTP server on a loopback port plays your environment (discovery, a key set made for the test run, the token endpoint and userinfo) and the API. The test replaces the client registration with one discovered from that server, through the same registration method:

	/** The registration of SecurityConfig, discovered from the fake environment instead of the issuer above. */
	@TestConfiguration
	static class FakeDiscovery {

		@Bean
		ClientRegistrationRepository clientRegistrationRepository(KeenAuthSettings settings) {
			return new InMemoryClientRegistrationRepository(
					SecurityConfig.registration(ClientRegistrations.fromIssuerLocation(KEENAUTH.issuer()), settings));
		}

	}
	@Test
	public void callbackWithAnUnknownStateIsRefusedBeforeAnyTokenRequest() throws Exception {
		MockHttpSession session = new MockHttpSession();
		startSignIn(session);
		MvcResult result = returnFromKeenAuth(session, "not-the-state");
		assertEquals(result.getResponse().getRedirectedUrl(), "/login?error");
		assertTrue(KEENAUTH.requestsTo("/oauth2/token").isEmpty());
mvn dependency:list -DoutputFile=target/dependencies.txt
python3 ../scripts/maven_deps.py target/dependencies.txt    # SHA-256 lock and licences
mvn test

The tests check that the configuration is validated at start-up (and that no error contains a secret); that sign-in sends PKCE S256, state, nonce and resource; that the code is exchanged with the matching verifier and client authentication; that the access token goes to the API in the Authorization header and never into a cookie; that an unknown state, an ID token for another client and an ID token with another nonce are refused without a session; that a page without a session starts sign-in; and that sign-out needs the CSRF token and goes to the logout endpoint with id_token_hint.

The full sign-in with a real browser needs a KeenAuth environment. It is not part of the offline tests.

Behind a proxy or load balancer

Set APP_BASE_URL to the public URL of the app; the redirect URI is built from it. If a proxy ends TLS, set server.forward-headers-strategy=framework only when the proxy is the only way to reach the app.

Current limits

Next steps