
# 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](/docs/api/management/applications/create)) with a client secret (`client_secret_basic`). In a `development` environment, register the redirect URI `http://localhost/login/oauth2/code/keenauth` and the post-logout redirect URI `http://localhost/`; a loopback URI is registered without a port and matches any port, such as `8080`.
- An API registered in the environment ([Create an API](/docs/api/management/apis/create)) with the `messages:read` scope. Its `identifier`, for example `https://api.example.com`, is the audience of its access tokens. The [Express quickstart](/docs/quickstarts/express) 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:

```xml
    <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.

```java
	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:

```java
	/** 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`:

```java
		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:

```html
      <!-- 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.

```java
	/** 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";
	}
```

```java
	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:

```sh
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:

```java
	/** 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));
		}

	}
```

```java
	@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());
```

```sh
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 `GET` to `/oauth2/authorization/keenauth`, as Spring Security does by default. The `state` check and PKCE protect the callback.
- Spring Security does not check the `iss` authorization response parameter (RFC 9207).
- Spring Security accepts RS256 ID tokens by default. If your environment signs with another algorithm, configure an `OidcIdTokenDecoderFactory` bean with that algorithm.
- No refresh token and no silent sign-in.

## Next steps

- [Protect an Express API](/docs/quickstarts/express): the API this app calls.
- [Verifying tokens](/docs/sdk/token-verification): the verifier under the middleware of your API.
