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
- A KeenAuth environment. Its issuer looks like
https://your-environment.eu.keenauth.app. - A web application in the environment (Create an application) with a client secret (
client_secret_basic). In adevelopmentenvironment, register the redirect URIhttp://localhost/login/oauth2/code/keenauthand the post-logout redirect URIhttp://localhost/; a loopback URI is registered without a port and matches any port, such as8080. - An API registered in the environment (Create an API) with the
messages:readscope. Itsidentifier, for examplehttps://api.example.com, is the audience of its access tokens. The Express quickstart builds an API that accepts these tokens. - JDK 21 and Maven 3.9.
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
- Sign-in starts with a
GETto/oauth2/authorization/keenauth, as Spring Security does by default. Thestatecheck and PKCE protect the callback. - Spring Security does not check the
issauthorization response parameter (RFC 9207). - Spring Security accepts RS256 ID tokens by default. If your environment signs with another algorithm, configure an
OidcIdTokenDecoderFactorybean with that algorithm. - No refresh token and no silent sign-in.
Next steps
- Protect an Express API: the API this app calls.
- Verifying tokens: the verifier under the middleware of your API.