Java / Spring Boot

Spring Security's OAuth 2.0 Client support handles Kenni without any custom code — point its provider config at your discovery URL and it does the rest. A few Kenni-specific defaults need wiring (PKCE on confidential clients, RFC 9068 access tokens, audience validation), all covered below.

Dependencies

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

application.yml

spring:
  security:
    oauth2:
      client:
        provider:
          kenni:
            issuer-uri: ${KENNI_ISSUER:}
        registration:
          kenni:
            provider: kenni
            client-id: ${KENNI_CLIENT_ID:}
            client-secret: ${KENNI_CLIENT_SECRET:}
            authorization-grant-type: authorization_code
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
            scope:
              - openid
              - profile
              - national_id
              - offline_access

Setting issuer-uri lets Spring discover endpoints, JWKS, and supported features automatically.

{baseUrl} is a Spring URL template — it expands to http://<request-host>:<port> at sign-in time. Register https://<your-host>/login/oauth2/code/kenni as a redirect URI on your application in the developer portal. Changing the kenni registration ID requires re-registering a different redirect URI.

Default ${KENNI_*} placeholders to empty (${KENNI_ISSUER:}) and validate at startup. Unresolved ${KENNI_ISSUER} strings get treated as URI templates by Spring's UriComponentsBuilder and surface as confusing IllegalArgumentException: Not enough variable values available errors elsewhere.

When the API scope is conditional

Apps that conditionally request a custom API scope can't express that in YAML — scope: is a fixed list. Drop spring.security.oauth2.client.* from YAML and build a ClientRegistrationRepository @Bean programmatically:

@Bean
ClientRegistrationRepository clientRegistrations(KenniProperties kenni) {
  var scopes = new LinkedHashSet<>(List.of("openid", "profile", "national_id", "offline_access"));
  if (kenni.apiEnabled()) scopes.add(kenni.apiScope());
  var registration = ClientRegistrations.fromIssuerLocation(kenni.issuer())
      .registrationId("kenni")
      .clientId(kenni.clientId())
      .clientSecret(kenni.clientSecret())
      .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
      .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
      .scope(scopes)
      .build();
  return new InMemoryClientRegistrationRepository(registration);
}

The Spring Boot auto-config bean is @ConditionalOnMissingBean, so this suppresses the YAML-driven setup. Without it, an empty ${KENNI_API_SCOPE} either gets appended literally or is silently dropped — and /api/protected-resource returns 401 forever even after a successful sign-in.

Wire PKCE for confidential clients

Spring Security 6 auto-enables PKCE only for public clients (client-authentication-method: none). Confidential clients (client_secret_basic / client_secret_post) must wire it in explicitly. Kenni requires PKCE — without this, the auth flow fails on token exchange and Spring redirects to its default /login?error page with the misleading message "Invalid credentials".

import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;

private static OAuth2AuthorizationRequestResolver pkceAwareResolver(
    ClientRegistrationRepository repo) {
  var resolver = new DefaultOAuth2AuthorizationRequestResolver(repo, "/oauth2/authorization");
  resolver.setAuthorizationRequestCustomizer(
      OAuth2AuthorizationRequestCustomizers.withPkce());
  return resolver;
}

…then plug it into oauth2Login (see the security configuration below).

Security configuration

Mix UI (cookie-session OIDC) and API (bearer JWT) endpoints with two SecurityFilterChain beans, each scoped by securityMatcher and ordered:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  @Order(1)
  SecurityFilterChain apiChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
    return http
      .securityMatcher("/api/**")
      .authorizeHttpRequests(auth -> auth
        .anyRequest().hasAuthority("SCOPE_" + System.getenv("KENNI_API_SCOPE")))
      .oauth2ResourceServer(rs -> rs.jwt(jwt -> jwt.decoder(jwtDecoder)))
      .csrf(csrf -> csrf.disable())
      .build();
  }

  @Bean
  @Order(2)
  SecurityFilterChain uiChain(HttpSecurity http,
                              ClientRegistrationRepository clients) throws Exception {
    var logoutSuccess = new OidcClientInitiatedLogoutSuccessHandler(clients);
    logoutSuccess.setPostLogoutRedirectUri("{baseUrl}/");

    return http
      .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
      .oauth2Login(o -> o.authorizationEndpoint(a ->
          a.authorizationRequestResolver(pkceAwareResolver(clients))))
      .logout(logout -> logout.logoutSuccessHandler(logoutSuccess))
      .build();
  }
}

Spring's default JwtAuthenticationConverter maps the scope claim to authorities prefixed with SCOPE_. Use .hasAuthority("SCOPE_<your-scope>") — not .hasRole(...) and not .hasAuthority("<your-scope>").

Verifying access tokens (JwtDecoder)

Two Kenni-specific defaults need overriding on the resource-server JwtDecoder:

  1. RFC 9068 token type. Kenni issues access tokens with "typ": "at+jwt" per RFC 9068. Spring's default NimbusJwtDecoder accepts only "typ": "JWT" (or absent) and rejects everything else with WWW-Authenticate: Bearer error="invalid_token", error_description="JOSE header typ (type) at+jwt not allowed".
  2. Audience validation. issuer-uri alone validates issuer + signature + expiry but not audience. Without audience validation, any Kenni token from the same tenant — including id_tokens or access tokens minted for other APIs — will pass.
@Bean
JwtDecoder jwtDecoder(@Value("${KENNI_ISSUER}") String issuer,
                      @Value("${KENNI_API_AUDIENCE}") String audience) {
  var decoder = NimbusJwtDecoder.withIssuerLocation(issuer)
      .jwtProcessorCustomizer(processor -> processor.setJWSTypeVerifier(
          new DefaultJOSEObjectTypeVerifier<>(
              JOSEObjectType.JWT,
              new JOSEObjectType("at+jwt"))))
      .build();

  var withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
  var withAudience = new JwtClaimValidator<List<String>>(
      JwtClaimNames.AUD, aud -> aud != null && aud.contains(audience));
  decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, withAudience));
  return decoder;
}

On Spring Boot 3.4+ you can replace the JwtClaimValidator block with the audiences: property:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${KENNI_ISSUER}
          audiences:
            - ${KENNI_API_AUDIENCE}

…but you still need the jwtProcessorCustomizer for at+jwt, so a custom @Bean is usually simpler.

Reading user claims

Inject @AuthenticationPrincipal OidcUser principal into your controller methods. principal.getClaims() returns everything from the ID token; principal.getIdToken().getTokenValue() returns the raw JWT.

Calling APIs with the access token

@RegisteredOAuth2AuthorizedClient("kenni") exposes the user's authorized client, including the access token:

@GetMapping("/api/me")
public Map<String, Object> me(
    @RegisteredOAuth2AuthorizedClient("kenni") OAuth2AuthorizedClient client) {
  String accessToken = client.getAccessToken().getTokenValue();
  // Forward the token to a downstream service…
  return Map.of("token", accessToken);
}

Client credentials (M2M)

For machine-to-machine calls, hit the token endpoint directly with HTTP Basic auth:

@Bean
RestClient kenniTokenClient(@Value("${KENNI_ISSUER}") String issuer) {
  return RestClient.builder().baseUrl(issuer).build();
}

public String fetchClientCredentialsToken(RestClient client,
                                          String clientId, String clientSecret,
                                          String scope) {
  var basic = Base64.getEncoder()
      .encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));

  Map<String, Object> body = client.post()
      .uri("/oidc/token")
      .header(HttpHeaders.AUTHORIZATION, "Basic " + basic)
      .contentType(MediaType.APPLICATION_FORM_URLENCODED)
      .body("grant_type=client_credentials&scope=" + URLEncoder.encode(scope, UTF_8))
      .retrieve()
      .body(new ParameterizedTypeReference<>() {});

  return (String) body.get("access_token");
}

For credentials with special characters, the form-urlencoded Basic auth from RFC 6749 §2.3.1 requires base64(form_urlencode(id) + ":" + form_urlencode(secret)).

Sign out

OidcClientInitiatedLogoutSuccessHandler (wired in the UI chain above) redirects logout through Kenni's end_session_endpoint. It requires the servlet stack — not WebFlux. Spring's default logout responds to POST /logout; a GET /logout link returns 404.

Register the resolved post-logout URL (e.g. https://<your-host>/) on the application's Post logout redirect URIs list in the developer portal. See the No framework (curl) guide for the full reasoning.

Troubleshooting

  • "Invalid credentials" on /login?error — Spring's default error page hides the cause. Set logging.level.org.springframework.security: DEBUG (or TRACE) to see the actual reason. The most common culprit on a fresh Kenni integration is missing PKCE.
  • HTTP 400 — Request header is too large — Tomcat's default 8KB header limit overflows after a couple of failed sign-in attempts (stale OAUTH2_AUTHORIZATION_REQUEST cookies pile up). Bump it: server.max-http-request-header-size: 16KB.
  • Empty 401 / 403 bodies — Spring's resource-server returns the actual reason in the WWW-Authenticate header (error="insufficient_scope", error="invalid_token"). JS proxies should fall back to that header when the upstream body is empty.

Next steps