Aggiungi l'accesso a un'app Spring Boot

Questa quickstart aggiunge l'accesso KeenAuth a un'app Spring Boot 4.1 con il client OAuth 2.0 di Spring Security, il client OpenID Connect standard dell'ecosistema Spring. KeenAuth non ha ancora un SDK Java e non ne serve uno: KeenAuth è un provider OpenID Connect standard. La tua app è il client OAuth e conserva ogni token nella sessione lato server; il browser riceve solo un cookie con l'id di sessione. Configuri l'accesso, proteggi una pagina, chiami un'API protetta con l'access token dell'utente, ti disconnetti e testi l'app offline.

L'app ha queste route:

Route Cosa fa
GET / Home page pubblica: un link per accedere, oppure il nome dell'utente e un pulsante per disconnettersi
GET /dashboard Richiede una sessione. Mostra l'utente e i messaggi restituiti dalla tua API
GET /oauth2/authorization/keenauth Servita da Spring Security: avvia l'accesso
GET /login/oauth2/code/keenauth Servita da Spring Security: la callback
POST /logout Servita da Spring Security: termina la sessione qui e su KeenAuth. Richiede il token CSRF

Prima di iniziare

1. Aggiungi le dipendenze

Il parent di Spring Boot (spring-boot-starter-parent 4.1.2) fissa tutte le versioni. Aggiungi gli starter per web, client OAuth 2.0 e Thymeleaf:

    <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>

L'esempio sostituisce due dipendenze le cui licenze sono fuori dalla lista di licenze ammesse dai nostri esempi: Logback (EPL-1.0 o LGPL-2.1) con Log4j 2, e jakarta.annotation-api (EPL-2.0 o GPL-2.0 con Classpath Exception), che l'app non usa. Per lo stesso motivo i suoi test usano TestNG invece di JUnit (EPL-2.0). La tua app non deve necessariamente fare lo stesso.

2. Configura

L'app legge la configurazione solo dalle variabili d'ambiente. Non committare mai i loro valori.

Variabile Obbligatoria Significato
KEENAUTH_ISSUER sì L'issuer del tuo ambiente, https
KEENAUTH_CLIENT_ID sì Il client_id dell'applicazione web
KEENAUTH_CLIENT_SECRET sì Il suo client secret
APP_BASE_URL sì L'URL pubblico dell'app. L'URI di redirect viene costruito a partire da esso. http solo per localhost, 127.0.0.1 o [::1]
API_URL sì L'API che l'app chiama, per esempio http://localhost:3001
KEENAUTH_API_AUDIENCE sì L'identifier di quell'API in KeenAuth
SESSION_SECURE_COOKIE no false solo per http://localhost semplice

KeenAuthSettings.from legge e verifica questi valori all'avvio. Un valore mancante o non utilizzabile ferma l'app, e nessun messaggio di errore contiene un valore.

	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. Registra il client

La registrazione del client deriva dalla discovery (<issuer>/.well-known/openid-configuration), che Spring Security recupera all'avvio. L'esempio imposta le credenziali del client, l'URI di redirect e gli scope, e attiva PKCE per questo client confidenziale:

	/** 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. Configura l'accesso, l'audience dell'API e la disconnessione

Il resolver della richiesta di autorizzazione aggiunge l'identifier della tua API come resource (RFC 8707). Senza di esso, l'access token non è destinato alla tua API e l'API lo rifiuta. La disconnessione è il POST /logout di Spring Security con il token CSRF, seguito da OpenID Connect RP-Initiated Logout con id_token_hint e 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 verifica state sulla callback, scambia il codice con il verifier PKCE e controlla l'ID token (firma con le chiavi del tuo ambiente, RS256, iss, aud, exp, iat e nonce). Dopo l'accesso assegna all'utente un nuovo id di sessione. La sessione vive sul server; con più server, usa Spring Session con uno store condiviso.

La home page contiene il link per accedere e invia un POST per disconnettersi; th:action aggiunge il token CSRF:

      <!-- 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. Chiama la tua API

La dashboard richiede una sessione. Prende l'access token dal client autorizzato che Spring Security conserva nella sessione e chiama la tua API con esso. Il token resta sul server; il browser riceve solo l'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);
	}

L'esempio non ha un refresh token: quando l'access token scade, l'utente accede di nuovo.

6. Avvia l'app

Avvia l'API della quickstart Express (o qualsiasi API che accetti i token) ed esegui l'app con le variabili esportate:

mvn spring-boot:run

Apri http://localhost:8080, scegli Accedi, accedi su KeenAuth e apri la dashboard.

7. Testa senza rete

I test non richiedono rete né un account KeenAuth. Un piccolo server HTTP su una porta di loopback simula il tuo ambiente (discovery, un set di chiavi creato per l'esecuzione dei test, l'endpoint del token e userinfo) e l'API. Il test sostituisce la registrazione del client con una ricavata da quel server, tramite lo stesso metodo registration:

	/** 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

I test verificano che la configurazione venga validata all'avvio (e che nessun errore contenga un secret); che l'accesso invii PKCE S256, state, nonce e resource; che il codice venga scambiato con il verifier corrispondente e l'autenticazione del client; che l'access token venga inviato all'API nell'header Authorization e non finisca mai in un cookie; che uno state sconosciuto, un ID token per un altro client e un ID token con un altro nonce vengano rifiutati senza creare una sessione; che una pagina senza sessione avvii l'accesso; e che la disconnessione richieda il token CSRF e vada all'endpoint di logout con id_token_hint.

L'accesso completo con un browser reale richiede un ambiente KeenAuth. Non fa parte dei test offline.

Dietro un proxy o un load balancer

Imposta APP_BASE_URL sull'URL pubblico dell'app; l'URI di redirect viene costruito a partire da esso. Se un proxy termina la TLS, imposta server.forward-headers-strategy=framework solo quando il proxy è l'unico modo per raggiungere l'app.

Limiti attuali

Passi successivi