Ajouter la connexion à une application Spring Boot

Ce démarrage rapide ajoute la connexion KeenAuth à une application Spring Boot 4.1 avec le client OAuth 2.0 de Spring Security, le client OpenID Connect standard de l'écosystème Spring. KeenAuth ne dispose pas encore de SDK Java, et il n'en a pas besoin : KeenAuth est un fournisseur OpenID Connect standard. Votre application est le client OAuth et conserve tous les jetons dans la session côté serveur ; le navigateur ne reçoit qu'un cookie contenant l'identifiant de session. Vous configurez la connexion, protégez une page, appelez une API protégée avec le jeton d'accès de l'utilisateur, déconnectez l'utilisateur et testez l'application hors ligne.

L'application expose les routes suivantes :

Route Rôle
GET / Page d'accueil publique : un lien de connexion, ou le nom de l'utilisateur et un bouton de déconnexion
GET /dashboard Nécessite une session. Affiche l'utilisateur et les messages renvoyés par votre API
GET /oauth2/authorization/keenauth Servie par Spring Security : démarre la connexion
GET /login/oauth2/code/keenauth Servie par Spring Security : le callback
POST /logout Servie par Spring Security : termine la session ici et chez KeenAuth. Nécessite le jeton CSRF

Avant de commencer

1. Ajouter les dépendances

Le parent Spring Boot (spring-boot-starter-parent 4.1.2) fixe toutes les versions. Ajoutez les starters web, client OAuth 2.0 et 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'exemple remplace deux dépendances dont les licences sortent de la liste des licences autorisées pour nos exemples : Logback (EPL-1.0 ou LGPL-2.1) par Log4j 2, et jakarta.annotation-api (EPL-2.0 ou GPL-2.0 avec Classpath Exception), que l'application n'utilise pas. Pour la même raison, ses tests utilisent TestNG au lieu de JUnit (EPL-2.0). Votre propre application n'a pas besoin de faire cela.

2. Configurer

L'application lit sa configuration uniquement depuis des variables d'environnement. Ne committez jamais leurs valeurs.

Variable Obligatoire Signification
KEENAUTH_ISSUER oui L'issuer de votre environnement, en https
KEENAUTH_CLIENT_ID oui Le client_id de l'application web
KEENAUTH_CLIENT_SECRET oui Son secret client
APP_BASE_URL oui L'URL publique de l'application. L'URI de redirection est construite à partir d'elle. http uniquement pour localhost, 127.0.0.1 ou [::1]
API_URL oui L'API appelée par l'application, par exemple http://localhost:3001
KEENAUTH_API_AUDIENCE oui L'identifiant de cette API dans KeenAuth
SESSION_SECURE_COOKIE non false uniquement pour un http://localhost simple

KeenAuthSettings.from lit et vérifie ces valeurs au démarrage. Une valeur manquante ou inutilisable arrête l'application, et aucun message d'erreur ne contient de valeur.

	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. Enregistrer le client

L'enregistrement du client provient de la découverte (<issuer>/.well-known/openid-configuration), que Spring Security récupère au démarrage. L'exemple définit les identifiants du client, l'URI de redirection et les scopes, et active PKCE pour ce client confidentiel :

	/** 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. Configurer la connexion, l'audience de l'API et la déconnexion

Le resolver de requête d'autorisation ajoute l'identifiant de votre API comme resource (RFC 8707). Sans lui, le jeton d'accès n'est pas destiné à votre API, et celle-ci le rejette. La déconnexion correspond au POST /logout de Spring Security avec le jeton CSRF, suivi de la déconnexion initiée par la partie utilisatrice d'OpenID Connect (RP-Initiated Logout) avec id_token_hint et 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 vérifie state sur le callback, échange le code avec le verifier PKCE et contrôle le jeton ID (signature avec les clés de votre environnement, RS256, iss, aud, exp, iat et nonce). Après la connexion, il attribue à l'utilisateur un nouvel identifiant de session. La session réside sur le serveur ; avec plusieurs serveurs, utilisez Spring Session avec un stockage partagé.

La page d'accueil renvoie vers la connexion et envoie le formulaire de déconnexion en POST ; th:action ajoute le jeton 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. Appeler votre API

Le tableau de bord nécessite une session. Il prend le jeton d'accès dans le client autorisé que Spring Security conserve en session, et appelle votre API avec celui-ci. Le jeton reste sur le serveur ; le navigateur ne reçoit que le 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'exemple n'a pas de jeton de rafraîchissement : lorsque le jeton d'accès expire, l'utilisateur se reconnecte.

6. Lancer l'application

Démarrez l'API du démarrage rapide Express (ou toute API qui accepte les jetons) puis lancez l'application avec les variables exportées :

mvn spring-boot:run

Ouvrez http://localhost:8080, choisissez Se connecter, connectez-vous chez KeenAuth et ouvrez le tableau de bord.

7. Tester sans réseau

Les tests n'ont besoin ni de réseau ni de compte KeenAuth. Un petit serveur HTTP sur un port de bouclage joue le rôle de votre environnement (découverte, jeu de clés créé pour l'exécution des tests, point de terminaison de jeton et userinfo) et celui de l'API. Le test remplace l'enregistrement du client par un enregistrement découvert depuis ce serveur, via la même méthode 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

Les tests vérifient que la configuration est validée au démarrage (et qu'aucune erreur ne contient un secret) ; que la connexion envoie PKCE S256, state, nonce et resource ; que le code est échangé avec le verifier correspondant et l'authentification du client ; que le jeton d'accès est envoyé à l'API dans l'en-tête Authorization et n'est jamais placé dans un cookie ; qu'un state inconnu, un jeton ID destiné à un autre client et un jeton ID avec un autre nonce sont refusés sans session ; qu'une page sans session lance la connexion ; et que la déconnexion nécessite le jeton CSRF et se rend au point de terminaison de déconnexion avec id_token_hint.

La connexion complète dans un vrai navigateur nécessite un environnement KeenAuth. Elle ne fait pas partie des tests hors ligne.

Derrière un proxy ou un équilibreur de charge

Définissez APP_BASE_URL sur l'URL publique de l'application ; l'URI de redirection est construite à partir d'elle. Si un proxy termine le TLS, définissez server.forward-headers-strategy=framework uniquement lorsque le proxy est le seul moyen d'atteindre l'application.

Limites actuelles

Étapes suivantes