diff --git a/.gitignore b/.gitignore index b6185c6c..348548ff 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,13 @@ coverage/ # Personal files .idea +.vscode # System files .DS_Store # --------------------------------------------------------------------- # Add specific rules here… + +# Environment files — never commit secrets. Track a .env.example template instead. +.env diff --git a/MIGRATION.md b/MIGRATION.md index 0adc0f8a..47a0299d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,94 @@ # MIGRATION +## V 2.x (Spring Boot 3) to V 2.2 (Spring Boot 4) + +`kinde-springboot-starter` and `kinde-springboot-core` were upgraded to +[Spring Boot 4.0.x](https://spring.io/blog/2025/11/20/spring-boot-4-0-0-available-now) +and [Spring Security 7.x](https://docs.spring.io/spring-security/reference/7.0.0/migration/index.html). +If you depend on either, here is what changes for you. + +### Baseline requirements + +- **Java 17 or later.** Spring Boot 4 raised the minimum JVM. The SDK + itself is built on Java 25 but produces Java 17-compatible bytecode. +- **Spring Boot 4.x** in your application. Spring Boot 3.x consumers + should stay on the previous Kinde SDK release line. + +### Behaviour change: JWT audience validation is now opt-in + +Previously the resource-server JWT validator was hardcoded to require an +audience claim of `"api://default"` (a leftover sample value). Real Kinde +access tokens carry an empty `aud` array unless you explicitly configure +an API resource on the Kinde dashboard, so the previous default rejected +every default Kinde token with a 401: + +``` +"This aud claim is not equal to the configured audience" +``` + +The validator now skips audience checking unless you explicitly configure +an expected audience: + +```yaml +kinde: + oauth2: + audience: https://your-api.example.com # optional; only set if you + # configured an API resource + # on the Kinde dashboard +``` + +If you previously relied on the implicit default and your tokens actually +contained `api://default` as their audience (highly unlikely for Kinde +deployments), set `kinde.oauth2.audience: api://default` to preserve the +old behaviour. + +### Configuration property rename: `okta.oauth2.post-logout-redirect-uri` → `kinde.oauth2.post-logout-redirect-uri` + +RP-Initiated logout (redirect to the IdP's logout endpoint after a local +sign-out) is wired in by the SDK when this property is set. The previous +property name was an Okta-fork leftover that nobody in a Kinde deployment +could plausibly have set. To enable RP-Initiated logout now: + +```yaml +kinde: + oauth2: + post-logout-redirect-uri: http://localhost:8080 # where to land + # after Kinde + # clears its + # session +``` + +The same rename applies to the reactive (WebFlux) auto-configuration. + +### New transitive dependencies for `kinde-springboot-starter` + +Spring Boot 4 split OAuth2 auto-configuration out of +`spring-boot-autoconfigure` into dedicated starters. The +`kinde-springboot-starter` pom now pulls them in for you: + +- `spring-boot-starter-oauth2-client` +- `spring-boot-starter-oauth2-resource-server` + +You do **not** need to add them to your own pom unless you previously +declared them explicitly and want to keep that explicit (which is fine). + +### Spring Security 7 source-level changes (only affects custom code) + +If you previously customized security by writing your own +`SecurityFilterChain` against Spring Security 6 APIs, some method +signatures have changed in Spring Security 7. Notable items: + +- `oauth2Login(Customizer)` and `oauth2ResourceServer(Customizer)` + signatures changed slightly; the lambda forms still work but the + no-arg deprecated forms are gone. +- `WebSecurityConfigurerAdapter` (long deprecated) is fully removed. +- Several token-endpoint client classes moved packages. The SDK does + this internally; you are only affected if you wired your own + `OAuth2AccessTokenResponseClient` bean. + +The [official Spring Security 7 migration guide](https://docs.spring.io/spring-security/reference/7.0.0/migration/index.html) +is the source of truth. + ## V 1.0.0 to V 2.0.0 The original implementation was based on Spring Boot controllers. It provided a controller that could be instantiated inline and then invoked to perform the PKCE authentication. Version 2.0.0 implements a core OAuth and OpenID library. This library provides a rich set of functions, from token management to OpenID authentication. diff --git a/kinde-core/.env b/kinde-core/.env deleted file mode 100644 index 74788b39..00000000 --- a/kinde-core/.env +++ /dev/null @@ -1 +0,0 @@ -KINDE_SCOPES=openid \ No newline at end of file diff --git a/kinde-core/.env.example b/kinde-core/.env.example new file mode 100644 index 00000000..b861249b --- /dev/null +++ b/kinde-core/.env.example @@ -0,0 +1,41 @@ +# Template for kinde-core .env. Copy to `.env` and customise as needed. +# `.env` is gitignored; this template is committed so contributors have a starting point. +# +# kinde-core is the SDK library. Most callers configure it programmatically via +# KindeClientBuilder, but the SDK will also pick up these variables from .env or +# the process environment at runtime. The full list of supported keys is defined +# in com.kinde.config.KindeParameters. + +# === Tenant + credentials === +KINDE_DOMAIN=https://.kinde.com +KINDE_CLIENT_ID= +KINDE_CLIENT_SECRET= + +# === OAuth flow === +# AuthorizationType values: CODE | PKCE | CLIENT_CREDENTIALS | TOKEN +KINDE_GRANT_TYPE=CODE +KINDE_SCOPES=openid +KINDE_REDIRECT_URI=http://localhost:8080/callback +KINDE_LOGOUT_REDIRECT_URI=http://localhost:8080 + +# === Optional === +# Audience claim (comma-separated). Required when calling the Kinde Management API. +# KINDE_AUDIENCE=https://.kinde.com/api + +# UI language hint passed to Kinde (e.g. en, fr, de) +# KINDE_LANG=en + +# Scope auth to a specific organisation +# KINDE_ORG_CODE= + +# Whether Kinde redirects to a success page after registration +# KINDE_HAS_SUCCESS_PAGE=false + +# Auth protocol override (typically derived by the SDK) +# KINDE_PROTOCOL= + +# === Endpoint overrides (rarely needed — defaults derive from KINDE_DOMAIN) === +# KINDE_OPENID_ENDPOINT=https://.kinde.com/.well-known/openid-configuration +# KINDE_AUTHORIZATION_ENDPOINT=https://.kinde.com/oauth2/auth +# KINDE_TOKEN_ENDPOINT=https://.kinde.com/oauth2/token +# KINDE_LOGOUT_ENDPOINT=https://.kinde.com/logout diff --git a/kinde-core/pom.xml b/kinde-core/pom.xml index e09d5ac4..016e83d2 100644 --- a/kinde-core/pom.xml +++ b/kinde-core/pom.xml @@ -15,33 +15,6 @@ http://maven.apache.org - - - com.nimbusds - oauth2-oidc-sdk - - - - com.nimbusds - nimbus-jose-jwt - - - junit - junit - test - - - - org.junit.jupiter - junit-jupiter-api - test - - - org.junit.jupiter - junit-jupiter-engine - test - - com.nimbusds oauth2-oidc-sdk @@ -71,15 +44,13 @@ junit-jupiter-engine test - - org.junit.jupiter junit-jupiter-params test - + org.mockito mockito-core @@ -91,11 +62,9 @@ test - com.google.inject guice - com.google.guava @@ -103,24 +72,27 @@ - com.google.guava guava - - org.projectlombok lombok provided - org.slf4j slf4j-api + + + org.slf4j + slf4j-simple + test + io.github.cdimascio dotenv-java diff --git a/kinde-core/src/main/java/com/kinde/session/KindeClientKindeTokenSessionImpl.java b/kinde-core/src/main/java/com/kinde/session/KindeClientKindeTokenSessionImpl.java index 7bcb7cd6..dbc690ae 100644 --- a/kinde-core/src/main/java/com/kinde/session/KindeClientKindeTokenSessionImpl.java +++ b/kinde-core/src/main/java/com/kinde/session/KindeClientKindeTokenSessionImpl.java @@ -80,14 +80,18 @@ public KindeTokens retrieveTokens() { throw new Exception("Access token validation failed: " + e.getMessage(), e); } } else if (this.kindeToken instanceof com.kinde.token.RefreshToken) { - // If we have a refresh token, perform the token exchange - // But first check if we have the necessary configuration - if (this.kindeConfig.tokenEndpoint() == null || this.kindeConfig.tokenEndpoint().isEmpty()) { + // Resolve the token endpoint: prefer an explicit KINDE_TOKEN_ENDPOINT override + // and fall back to the OIDC-discovered endpoint exposed via OidcMetaData. This + // matches the behavior of sibling code (KindeClientSessionImpl, + // KindeClientCodeSessionImpl) which already rely on discovery via + // `oidcMetaData.getOpMetadata().getTokenEndpointURI()`. + URI tokenEndpoint = resolveTokenEndpoint(); + if (tokenEndpoint == null) { throw new Exception("Token endpoint not configured - cannot exchange refresh token"); } - + com.kinde.token.RefreshToken refreshToken = (com.kinde.token.RefreshToken) this.kindeToken; - + // Create the token request using the correct approach RefreshToken nimbusRefreshToken = new RefreshToken(refreshToken.token()); AuthorizationGrant refreshTokenGrant = new RefreshTokenGrant(nimbusRefreshToken); @@ -96,8 +100,6 @@ public KindeTokens retrieveTokens() { Secret clientSecret = new Secret(this.kindeConfig.clientSecret()); ClientAuthentication clientAuth = new ClientSecretBasic(clientID, clientSecret); - // Convert the token endpoint string to URI and create the request - URI tokenEndpoint = URI.create(this.kindeConfig.tokenEndpoint()); TokenRequest request = new TokenRequest(tokenEndpoint, clientAuth, refreshTokenGrant); HTTPRequest httpRequest = request.toHTTPRequest(); @@ -151,4 +153,22 @@ public UserInfo retrieveUserInfo() { return new UserInfo(userInfoResponse.toSuccessResponse().getUserInfo()); } + + /** + * Returns the token endpoint URI to use for refresh-token exchanges. Honours an explicit + * override on {@link KindeConfig#tokenEndpoint()} (sourced from + * {@code KINDE_TOKEN_ENDPOINT}) and otherwise falls back to the OIDC-discovered endpoint + * on {@link OidcMetaData}. Returns {@code null} when neither is available, which is treated + * as a configuration error by the caller. + */ + private URI resolveTokenEndpoint() { + String configured = this.kindeConfig.tokenEndpoint(); + if (configured != null && !configured.isEmpty()) { + return URI.create(configured); + } + if (this.oidcMetaData != null && this.oidcMetaData.getOpMetadata() != null) { + return this.oidcMetaData.getOpMetadata().getTokenEndpointURI(); + } + return null; + } } diff --git a/kinde-core/src/main/java/com/kinde/token/BaseToken.java b/kinde-core/src/main/java/com/kinde/token/BaseToken.java index 613f196e..c33ac60d 100644 --- a/kinde-core/src/main/java/com/kinde/token/BaseToken.java +++ b/kinde-core/src/main/java/com/kinde/token/BaseToken.java @@ -554,11 +554,10 @@ public boolean hasAny(List permissions, List roles, List // ========== Helper Methods ========== /** - * Gets roles from the token using the typed accessor. - * This method is now deprecated in favor of using token.getRoles() directly. - * + * Internal helper that returns the token's roles claim with null-safety, + * falling back to an empty list when the token or roles are absent. + * * @return List of role strings, or empty list if no roles are found - * @deprecated Use token.getRoles() instead */ private List getTokenRoles() { List roles = (token != null) ? getRoles() : null; diff --git a/kinde-core/src/test/java/com/kinde/session/KindeClientCodeSessionImplTest.java b/kinde-core/src/test/java/com/kinde/session/KindeClientCodeSessionImplTest.java index 976d6fdd..07d5cf1f 100644 --- a/kinde-core/src/test/java/com/kinde/session/KindeClientCodeSessionImplTest.java +++ b/kinde-core/src/test/java/com/kinde/session/KindeClientCodeSessionImplTest.java @@ -116,8 +116,6 @@ public void setUp() { } """))); - ///oauth2/token - System.out.println("Instanciate the wiremock service"); } @@ -204,7 +202,6 @@ public void testRegisterUrlRequestTest() { AuthorizationUrl authorizationUrl1 = kindeClientSession.register(); assertNotNull(authorizationUrl1); assertNotNull(authorizationUrl1.getUrl()); - System.out.println(authorizationUrl1.getUrl()); assertTrue(authorizationUrl1.getUrl().toString().contains("prompt=create")); assertTrue(authorizationUrl1.getCodeVerifier() == null); @@ -223,7 +220,6 @@ public void testRegisterUrlRequestTest() { AuthorizationUrl authorizationUrl2 = kindeClientSession2.register(); assertNotNull(authorizationUrl2); assertNotNull(authorizationUrl2.getUrl()); - System.out.println(authorizationUrl2.getUrl()); assertTrue(authorizationUrl2.getUrl().toString().contains("prompt=create")); assertTrue(authorizationUrl2.getUrl().toString().contains("org_code=TEST")); assertTrue(authorizationUrl2.getUrl().toString().contains("has_success_page=true")); @@ -258,7 +254,6 @@ public void testOrgCreateUrlRequestTest() { AuthorizationUrl authorizationUrl1 = kindeClientSession.createOrg("TEST1"); assertNotNull(authorizationUrl1); assertNotNull(authorizationUrl1.getUrl()); - System.out.println(authorizationUrl1.getUrl()); assertTrue(authorizationUrl1.getUrl().toString().contains("prompt=create")); assertTrue(authorizationUrl1.getUrl().toString().contains("org_name=TEST1")); assertTrue(authorizationUrl1.getCodeVerifier() == null); @@ -278,7 +273,6 @@ public void testOrgCreateUrlRequestTest() { AuthorizationUrl authorizationUrl2 = kindeClientSession2.createOrg("TEST2"); assertNotNull(authorizationUrl2); assertNotNull(authorizationUrl2.getUrl()); - System.out.println(authorizationUrl2.getUrl()); assertTrue(authorizationUrl2.getUrl().toString().contains("prompt=create")); assertTrue(authorizationUrl2.getUrl().toString().contains("org_code=TEST")); assertTrue(authorizationUrl2.getUrl().toString().contains("org_name=TEST2")); diff --git a/kinde-core/src/test/java/com/kinde/token/IDTokenTest.java b/kinde-core/src/test/java/com/kinde/token/IDTokenTest.java index 90260b0e..c8f9107f 100644 --- a/kinde-core/src/test/java/com/kinde/token/IDTokenTest.java +++ b/kinde-core/src/test/java/com/kinde/token/IDTokenTest.java @@ -26,14 +26,6 @@ public void testIDTokenTest() throws Exception { assertTrue( kindeToken2.token().equals(token2) ); assertTrue( kindeToken2.valid() ); - - String tokenString = JwtGenerator.generateIDToken(); - System.out.println(tokenString); - - KindeToken kindeToken4 = IDToken.init(tokenString,true); - - System.out.println(kindeToken4.getPermissions()); - assertTrue(kindeToken2.getStringFlag("test_str").equals("test_str")); assertTrue(kindeToken2.getIntegerFlag("test_integer").equals(1)); assertTrue(kindeToken2.getBooleanFlag("test_boolean").equals(false)); diff --git a/kinde-core/src/test/resources/simplelogger.properties b/kinde-core/src/test/resources/simplelogger.properties new file mode 100644 index 00000000..55edcf02 --- /dev/null +++ b/kinde-core/src/test/resources/simplelogger.properties @@ -0,0 +1,4 @@ +# SLF4J Simple provider configuration for tests. +# Default to WARN so the no-provider warning is gone but CI output stays clean. +# Raise to "info"/"debug" locally when troubleshooting a specific test. +org.slf4j.simpleLogger.defaultLogLevel=warn diff --git a/kinde-j2ee/.env b/kinde-j2ee/.env deleted file mode 100644 index 2fe52023..00000000 --- a/kinde-j2ee/.env +++ /dev/null @@ -1,4 +0,0 @@ -KINDE_DOMAIN=https://burntjam.kinde.com -KINDE_CLIENT_ID=< replace > -KINDE_CLIENT_SECRET=< replace > -KINDE_SCOPES=openid \ No newline at end of file diff --git a/kinde-j2ee/.env.example b/kinde-j2ee/.env.example new file mode 100644 index 00000000..63e20589 --- /dev/null +++ b/kinde-j2ee/.env.example @@ -0,0 +1,41 @@ +# Template for kinde-j2ee .env. Copy to `.env` and fill in your Kinde tenant details. +# `.env` is gitignored; this template is committed so contributors have a starting point. +# +# kinde-j2ee provides KindeAuthenticationFilter / KindeAuthenticationServlet for +# Jakarta EE / Servlet web apps. The SDK reads these variables at runtime; the +# full list of supported keys is defined in com.kinde.config.KindeParameters. + +# === Required: tenant + credentials === +KINDE_DOMAIN=https://.kinde.com +KINDE_CLIENT_ID= +KINDE_CLIENT_SECRET= + +# === Required: OAuth flow === +# Where Kinde redirects after the user authenticates. Must match the redirect +# URI registered on your Kinde application. +KINDE_REDIRECT_URI=http://localhost:8080//login +# AuthorizationType values: CODE | PKCE | CLIENT_CREDENTIALS | TOKEN +KINDE_GRANT_TYPE=CODE +KINDE_SCOPES=openid,profile,email + +# === Optional === +# Where to send users after logout +# KINDE_LOGOUT_REDIRECT_URI=http://localhost:8080/ + +# Audience claim — required when calling the Kinde Management API +# KINDE_AUDIENCE=https://.kinde.com/api + +# UI language hint passed to Kinde (e.g. en, fr, de) +# KINDE_LANG=en + +# Scope auth to a specific organisation +# KINDE_ORG_CODE= + +# Whether Kinde redirects to a success page after registration +# KINDE_HAS_SUCCESS_PAGE=false + +# === Endpoint overrides (rarely needed — defaults derive from KINDE_DOMAIN) === +# KINDE_OPENID_ENDPOINT=https://.kinde.com/.well-known/openid-configuration +# KINDE_AUTHORIZATION_ENDPOINT=https://.kinde.com/oauth2/auth +# KINDE_TOKEN_ENDPOINT=https://.kinde.com/oauth2/token +# KINDE_LOGOUT_ENDPOINT=https://.kinde.com/logout diff --git a/kinde-j2ee/pom.xml b/kinde-j2ee/pom.xml index 0f27d572..c4f31092 100644 --- a/kinde-j2ee/pom.xml +++ b/kinde-j2ee/pom.xml @@ -30,7 +30,6 @@ com.google.inject guice - com.google.guava @@ -46,6 +45,13 @@ org.slf4j slf4j-api + + + org.slf4j + slf4j-simple + test + jakarta.servlet jakarta.servlet-api @@ -81,10 +87,4 @@ test - - - - - - diff --git a/kinde-j2ee/src/test/resources/simplelogger.properties b/kinde-j2ee/src/test/resources/simplelogger.properties new file mode 100644 index 00000000..55edcf02 --- /dev/null +++ b/kinde-j2ee/src/test/resources/simplelogger.properties @@ -0,0 +1,4 @@ +# SLF4J Simple provider configuration for tests. +# Default to WARN so the no-provider warning is gone but CI output stays clean. +# Raise to "info"/"debug" locally when troubleshooting a specific test. +org.slf4j.simpleLogger.defaultLogLevel=warn diff --git a/kinde-management/.env b/kinde-management/.env deleted file mode 100644 index e0dc2c83..00000000 --- a/kinde-management/.env +++ /dev/null @@ -1,4 +0,0 @@ -KINDE_DOMAIN=https://burntjam.kinde.com -KINDE_CLIENT_ID=< replace > -KINDE_CLIENT_SECRET=< replace > -KINDE_SCOPES=openid diff --git a/kinde-management/.env.example b/kinde-management/.env.example new file mode 100644 index 00000000..4e2b9041 --- /dev/null +++ b/kinde-management/.env.example @@ -0,0 +1,29 @@ +# Template for kinde-management .env. Copy to `.env` and fill in your Kinde tenant details. +# `.env` is gitignored; this template is committed so contributors have a starting point. +# +# kinde-management is the SDK client for the Kinde Management API. Configure a +# Machine-to-Machine application in Kinde (admin → Applications → Add → M2M) and +# grant it Management API access. The SDK reads these variables at runtime; the +# full list of supported keys is defined in com.kinde.config.KindeParameters. + +# === Required: tenant + M2M credentials === +KINDE_DOMAIN=https://.kinde.com +KINDE_CLIENT_ID= +KINDE_CLIENT_SECRET= + +# === Required: target audience === +KINDE_AUDIENCE=https://.kinde.com/api + +# === OAuth flow === +# AuthorizationType values: CODE | PKCE | CLIENT_CREDENTIALS | TOKEN +# For Management API access (M2M) use CLIENT_CREDENTIALS. +KINDE_GRANT_TYPE=CLIENT_CREDENTIALS +# Typically empty for the client_credentials flow. +KINDE_SCOPES= + +# === Optional === +# UI language hint passed to Kinde (e.g. en, fr, de) +# KINDE_LANG=en + +# === Endpoint overrides (rarely needed — defaults derive from KINDE_DOMAIN) === +# KINDE_TOKEN_ENDPOINT=https://.kinde.com/oauth2/token diff --git a/kinde-management/pom.xml b/kinde-management/pom.xml index 62a533c7..e7b488e7 100644 --- a/kinde-management/pom.xml +++ b/kinde-management/pom.xml @@ -90,7 +90,6 @@ 4.13.2 test - org.junit.jupiter junit-jupiter-api @@ -105,7 +104,6 @@ gson 2.14.0 - io.gsonfire gson-fire @@ -162,15 +160,7 @@ javax.annotation-api 1.3.2 - - - - com.kinde - kinde-test-utils - ${project.version} - test - - + org.jetbrains.kotlin @@ -259,7 +249,7 @@ generate-sources - Fixing syntax error in generated OpenAPI code + Fixing syntax error in generated OpenAPI code @@ -272,7 +262,7 @@ org.jacoco jacoco-maven-plugin - 0.8.14 + 0.8.14 @@ -295,10 +285,10 @@ ${project.build.directory}/jacoco ${project.build.directory}/jacoco/jacoco.exec - + HTML XML - + true false @@ -311,7 +301,6 @@ **/generated/** **/thirdparty/** **/openapitools/** - diff --git a/kinde-management/src/test/java/com/kinde/KindeAdminSessionBuilderTest.java b/kinde-management/src/test/java/com/kinde/KindeAdminSessionBuilderTest.java deleted file mode 100644 index 02c7a4cd..00000000 --- a/kinde-management/src/test/java/com/kinde/KindeAdminSessionBuilderTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.kinde; - -import com.kinde.client.KindeManagementGuiceTestModule; -import com.kinde.token.KindeTokenGuiceTestModule; -import com.kinde.guice.KindeEnvironmentSingleton; -import com.kinde.guice.KindeGuiceSingleton; -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; - -/** - * Unit test for simple App. - */ -public class KindeAdminSessionBuilderTest - extends TestCase -{ - /** - * Create the test case - * - * @param testName name of the test case - */ - public KindeAdminSessionBuilderTest(String testName ) - { - super( testName ); - } - - /** - * @return the suite of tests being tested - */ - public static Test suite() - { - return new TestSuite( KindeAdminSessionBuilderTest.class ); - } - - /** - * Rigourous Test :-) - */ - public void testApp() { - KindeEnvironmentSingleton.init(KindeEnvironmentSingleton.State.TEST); - KindeGuiceSingleton.init( - new KindeManagementGuiceTestModule(), - new KindeTokenGuiceTestModule()); - try { - KindeClient kindeClient = KindeClientBuilder.builder().build(); - KindeAdminSession kindeAdminSession1 = KindeAdminSessionBuilder.builder().build(); - KindeAdminSession kindeAdminSession2 = KindeAdminSessionBuilder.builder().client(kindeClient).build(); - assertTrue( kindeAdminSession1 != kindeAdminSession2 ); - } finally { - KindeGuiceSingleton.fin(); - KindeEnvironmentSingleton.fin(); - } - } -} diff --git a/kinde-management/src/test/java/com/kinde/client/KindeClientGuiceTestModule.java b/kinde-management/src/test/java/com/kinde/client/KindeClientGuiceTestModule.java deleted file mode 100644 index e443bd5c..00000000 --- a/kinde-management/src/test/java/com/kinde/client/KindeClientGuiceTestModule.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.kinde.client; - -import com.google.inject.AbstractModule; -import com.kinde.client.OidcMetaData; -import com.kinde.client.oidc.OidcMetaDataTestImpl; -import com.kinde.token.TestKeyGenerator; -import com.kinde.token.TestKeyGeneratorImpl; - -public class KindeClientGuiceTestModule extends AbstractModule { - @Override - protected void configure() { - // Bind test implementations for testing - bind(OidcMetaData.class).to(OidcMetaDataTestImpl.class); - bind(TestKeyGenerator.class).to(TestKeyGeneratorImpl.class); - } -} diff --git a/kinde-management/src/test/java/com/kinde/client/KindeManagementGuiceTestModule.java b/kinde-management/src/test/java/com/kinde/client/KindeManagementGuiceTestModule.java deleted file mode 100644 index 8755c0e5..00000000 --- a/kinde-management/src/test/java/com/kinde/client/KindeManagementGuiceTestModule.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.kinde.client; - -import com.kinde.client.oidc.OidcMetaDataTestImpl; -import com.kinde.token.BaseKindeClientGuiceTestModule; - -/** - * Kinde Management specific Guice test module. - * Extends the base module and adds management-specific test bindings. - */ -public class KindeManagementGuiceTestModule extends BaseKindeClientGuiceTestModule { - - @Override - protected void configureModuleSpecificBindings() { - // Bind test implementation for OIDC metadata - bind(OidcMetaData.class).to(OidcMetaDataTestImpl.class); - } -} diff --git a/kinde-management/src/test/java/com/kinde/client/oidc/OidcMetaDataTestImpl.java b/kinde-management/src/test/java/com/kinde/client/oidc/OidcMetaDataTestImpl.java deleted file mode 100644 index 63aa8885..00000000 --- a/kinde-management/src/test/java/com/kinde/client/oidc/OidcMetaDataTestImpl.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.kinde.client.oidc; - -import com.google.inject.Inject; -import com.kinde.client.OidcMetaData; -import com.kinde.token.TestKeyGenerator; -import com.nimbusds.oauth2.sdk.id.Issuer; -import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; - - -import java.net.URI; -import java.net.URL; -import java.nio.file.Path; -import java.util.Arrays; - -public class OidcMetaDataTestImpl implements OidcMetaData { - - private TestKeyGenerator testKeyGenerator; - private Path jwksPath; - private OIDCProviderMetadata opMetadata; - - @Inject - public OidcMetaDataTestImpl(TestKeyGenerator testKeyGenerator) { - this.testKeyGenerator = testKeyGenerator; - this.jwksPath = testKeyGenerator.regenerateKey(); - this.opMetadata = createMockOIDCProviderMetadata(); - } - - private OIDCProviderMetadata createMockOIDCProviderMetadata() { - // Create a mock OIDC provider metadata for testing - Issuer issuer = new Issuer("http://localhost:8089"); - - // Create metadata with required parameters - OIDCProviderMetadata metadata = new OIDCProviderMetadata( - issuer, - Arrays.asList(com.nimbusds.openid.connect.sdk.SubjectType.PUBLIC), - URI.create("http://localhost:8089/oauth2/auth") - ); - - // Explicitly set the authorization endpoint - metadata.setAuthorizationEndpointURI(URI.create("http://localhost:8089/oauth2/auth")); - - // Set additional endpoints - metadata.setTokenEndpointURI(URI.create("http://localhost:8089/oauth2/token")); - metadata.setEndSessionEndpointURI(URI.create("http://localhost:8089/logout")); - metadata.setJWKSetURI(jwksPath.toUri()); // Use the actual JWKS file generated by TestKeyGenerator - metadata.setUserInfoEndpointURI(URI.create("http://localhost:8089/oauth2/v2/user_profile")); - - return metadata; - } - - @Override - public OIDCProviderMetadata getOpMetadata() { - return opMetadata; - } - - @Override - public URL getJwkUrl() { - try { - return jwksPath.toUri().toURL(); - } catch (java.net.MalformedURLException e) { - throw new RuntimeException("Failed to create URL from path: " + jwksPath, e); - } - } -} diff --git a/kinde-report-aggregate/README.md b/kinde-report-aggregate/README.md new file mode 100644 index 00000000..2e81009b --- /dev/null +++ b/kinde-report-aggregate/README.md @@ -0,0 +1,73 @@ +# kinde-report-aggregate + +This module is **not a library**. It produces nothing that consumers of the +Kinde Java SDK ever depend on. Its only job is to merge per-module +[JaCoCo](https://www.jacoco.org/jacoco/) coverage data into a single +aggregate report that the CI pipeline uploads. + +## Why a dedicated module? + +JaCoCo's `report-aggregate` goal can only read `jacoco.exec` files from +modules that the aggregator module **depends on**. There's no way to do +this from the root parent pom alone (the parent pom doesn't have +dependencies). So the canonical JaCoCo recipe — followed here — is to +create a tiny leaf module whose `pom.xml`: + +1. depends on every module you want covered, and +2. binds `jacoco:report-aggregate` to the `verify` phase. + +The depended-on modules in this case are: + +- `kinde-core` +- `kinde-j2ee` +- `kinde-management` +- `kinde-springboot-core` + +The pom layout, the EPL header, and the structure all come from the +[official JaCoCo example](https://github.com/jacoco/jacoco/tree/master/jacoco-maven-plugin.test/it/it-report-aggregate) +maintained by the JaCoCo authors (Mountainminds GmbH). + +## How to run it + +`mvn verify` from the repo root runs the full multi-module build and, as +part of the `verify` phase on this module, produces: + +``` +kinde-report-aggregate/target/site/jacoco-aggregate/ + index.html ← human-readable coverage report + jacoco.xml ← machine-readable, consumed by CI +``` + +## How CI uses the output + +`.github/workflows/maven.yml` uploads `jacoco.xml` to the coverage service +(Codecov): + +```yaml +files: 'kinde-report-aggregate/target/site/jacoco-aggregate/jacoco.xml' +``` + +That single file represents the merged coverage of every production module +in the repo. + +## Why is there an empty `ReportTest.java`? + +Maven's `test` phase produces the per-module `jacoco.exec` file via the +JaCoCo agent (configured in the parent pom). If a module has zero tests, +no `jacoco.exec` is written for it, and the surefire phase can in some +configurations skip the JaCoCo agent attachment entirely. A single empty +test guarantees the lifecycle runs end-to-end on this module. It is +intentional, not dead code. + +## Maintenance tips + +- **Adding a new production module that should count toward coverage:** + add it as a `` in this pom. +- **The artifactId is `report`, not `kinde-report-aggregate`.** That's + a JaCoCo-recipe convention; the root pom's + `report` (used by other plugins to + skip this non-deployable module) relies on that exact name. If you ever + rename it, update both places. +- **This module is excluded from Sonatype Central publishing** via the + `central-publishing-maven-plugin` `true` configuration in + its pom — it is intentionally never published to Maven Central. diff --git a/kinde-report-aggregate/pom.xml b/kinde-report-aggregate/pom.xml index 64e46a15..c255578e 100644 --- a/kinde-report-aggregate/pom.xml +++ b/kinde-report-aggregate/pom.xml @@ -27,6 +27,8 @@ report Aggregate Report 2.3.0 + + pom @@ -54,7 +56,6 @@ 4.13.2 test - org.junit.jupiter junit-jupiter-api diff --git a/kinde-springboot/kinde-springboot-core/pom.xml b/kinde-springboot/kinde-springboot-core/pom.xml index 5cf93af0..21be7e3b 100644 --- a/kinde-springboot/kinde-springboot-core/pom.xml +++ b/kinde-springboot/kinde-springboot-core/pom.xml @@ -12,11 +12,78 @@ 2.3.0 kinde-springboot-core http://maven.apache.org + + + 4.0.6 + 7.0.5 + + 6.0.3 + + 4.2.13.Final + + + + + + + io.netty + netty-bom + ${netty.version} + pom + import + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + org.springframework.security + spring-security-bom + ${spring-security.version} + pom + import + + + + org.junit.jupiter + junit-jupiter-api + ${junit-jupiter.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter.version} + + + org.junit.jupiter + junit-jupiter-params + ${junit-jupiter.version} + + + + jakarta.servlet jakarta.servlet-api - 6.1.0 provided @@ -30,74 +97,69 @@ org.springframework.boot spring-boot-starter true - 3.5.10 - - org.springframework.boot - spring-boot-starter-webflux - true - 3.5.13 - + + org.springframework.boot + spring-boot-starter-webflux + true + jakarta.validation jakarta.validation-api - 3.1.1 org.springframework.security spring-security-config - 6.5.10 org.springframework.boot spring-boot-starter-security - 3.5.6 org.springframework.security spring-security-oauth2-client - 6.5.10 org.springframework.security spring-security-oauth2-jose - 6.5.10 org.springframework.security spring-security-oauth2-resource-server - 6.5.10 - + org.springframework.boot - spring-boot-starter-security - 3.5.6 + spring-boot-security-oauth2-client + true + + + org.springframework.boot + spring-boot-security-oauth2-resource-server + true + + + + org.springframework.boot + spring-boot-configuration-processor + true - - org.springframework.boot - spring-boot-configuration-processor - true - 3.5.5 - - - org.springframework.boot - spring-boot-test - test - 3.5.5 - + + org.springframework.boot + spring-boot-test + test + org.springframework spring-test test - 6.2.18 org.mockito @@ -119,17 +181,15 @@ 4.13.2 test - + org.junit.jupiter junit-jupiter-api - 5.13.4 test org.junit.jupiter junit-jupiter-engine - 5.13.4 test @@ -137,21 +197,13 @@ org.junit.jupiter junit-jupiter-params - 5.13.4 test - - - org.mockito - mockito-core - 5.19.0 - test - net.bytebuddy byte-buddy - 1.17.7 + 1.17.7 @@ -186,7 +238,7 @@ slf4j-api 2.0.17 - + com.kinde @@ -208,7 +260,7 @@ org.jacoco jacoco-maven-plugin - 0.8.14 + 0.8.14 @@ -217,7 +269,7 @@ report - verify + verify report @@ -227,10 +279,9 @@ org.apache.maven.plugins maven-compiler-plugin - 3.15.0 + 3.15.0 - 17 - 17 + 17 diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/Kinde.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/Kinde.java index 75cd8d40..4dd4fa25 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/Kinde.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/Kinde.java @@ -28,9 +28,8 @@ public class Kinde { public static HttpSecurity configureOAuth2WithPkce(HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception { KindeOAuth2AuthorizationRequestResolver authorizationRequestResolver = new KindeOAuth2AuthorizationRequestResolver(clientRegistrationRepository, "/oauth2/authorization"); - http.oauth2Login() - .authorizationEndpoint() - .authorizationRequestResolver(authorizationRequestResolver); + http.oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))); return http; } @@ -51,11 +50,10 @@ public static HttpSecurity configureOAuth2WithPkce(HttpSecurity http, ClientRegi * @return the {@code http} to allow method chaining */ public static ServerHttpSecurity configureOAuth2WithPkce(ServerHttpSecurity http, ReactiveClientRegistrationRepository clientRegistrationRepository) { - // Create a request resolver that enables PKCE - DefaultServerOAuth2AuthorizationRequestResolver authorizationRequestResolver = new DefaultServerOAuth2AuthorizationRequestResolver(clientRegistrationRepository); + DefaultServerOAuth2AuthorizationRequestResolver authorizationRequestResolver = + new DefaultServerOAuth2AuthorizationRequestResolver(clientRegistrationRepository); authorizationRequestResolver.setAuthorizationRequestCustomizer(withPkce()); - // enable oauth2 login that uses PKCE - http.oauth2Login().authorizationRequestResolver(authorizationRequestResolver); + http.oauth2Login(oauth2 -> oauth2.authorizationRequestResolver(authorizationRequestResolver)); return http; } diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2AutoConfig.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2AutoConfig.java index 19a48a31..2d321094 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2AutoConfig.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2AutoConfig.java @@ -9,14 +9,13 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -45,7 +44,7 @@ public void setKindeSdkClient(KindeSdkClient kindeSdkClient) { } @Bean - @ConditionalOnProperty(name = "okta.oauth2.post-logout-redirect-uri") + @ConditionalOnProperty(name = "kinde.oauth2.post-logout-redirect-uri") OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) { OidcClientInitiatedLogoutSuccessHandler successHandler = new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository); String logoutUri = kindeSdkClient.getClient().kindeConfig().logoutRedirectUri(); @@ -78,10 +77,10 @@ static class OAuth2SecurityFilterChainConfiguration { SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception { // as of Spring Security 5.4 the default chain uses oauth2Login OR a JWT resource server (NOT both) // this does the same as both defaults merged together (and provides the previous behavior) - http.authorizeRequests((requests) -> requests.anyRequest().authenticated()); + http.authorizeHttpRequests(requests -> requests.anyRequest().authenticated()); Kinde.configureOAuth2WithPkce(http, clientRegistrationRepository); - http.oauth2Client(); - http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); + http.oauth2Client(Customizer.withDefaults()); + http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); } } diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2Configurer.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2Configurer.java index fb54c73c..28f7165f 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2Configurer.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2Configurer.java @@ -3,110 +3,110 @@ import com.kinde.spring.config.KindeOAuth2Properties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientProperties; import org.springframework.context.ApplicationContext; import org.springframework.core.env.Environment; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; -import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.RestClientAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; -import org.springframework.web.client.RestTemplate; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; import java.lang.reflect.Field; -import java.security.AccessController; -import java.security.PrivilegedAction; import java.util.Optional; final class KindeOAuth2Configurer extends AbstractHttpConfigurer { private static final Logger log = LoggerFactory.getLogger(KindeOAuth2Configurer.class); + @SuppressWarnings("rawtypes") @Override - public void init(HttpSecurity http) throws Exception { + public void init(HttpSecurity http) { ApplicationContext context = http.getSharedObject(ApplicationContext.class); - // make sure OktaOAuth2Properties are available - if (!context.getBeansOfType(KindeOAuth2Properties.class).isEmpty()) { - KindeOAuth2Properties kindeOAuth2Properties = context.getBean(KindeOAuth2Properties.class); - - // Auth Code Flow Config - - // if OAuth2ClientProperties bean is not available do NOT configure - OAuth2ClientProperties.Provider propertiesProvider; - OAuth2ClientProperties.Registration propertiesRegistration; - if (!context.getBeansOfType(OAuth2ClientProperties.class).isEmpty() - && (propertiesProvider = context.getBean(OAuth2ClientProperties.class).getProvider().get("kinde")) != null - && (propertiesRegistration = context.getBean(OAuth2ClientProperties.class).getRegistration().get("kinde")) != null - && !propertiesProvider.getIssuerUri().isEmpty() - && !propertiesRegistration.getClientId().isEmpty()) { - // configure kinde user services - configureLogin(http, kindeOAuth2Properties, context.getEnvironment()); - - // check for RP-Initiated logout - if (!context.getBeansOfType(OidcClientInitiatedLogoutSuccessHandler.class).isEmpty()) { - http.logout().logoutSuccessHandler(context.getBean(OidcClientInitiatedLogoutSuccessHandler.class)); - } - - // Resource Server Config - OAuth2ResourceServerConfigurer oAuth2ResourceServerConfigurer = http.getConfigurer(OAuth2ResourceServerConfigurer.class); - - if (getJwtConfigurer(oAuth2ResourceServerConfigurer).isPresent()) { - log.debug("JWT configurer is set in OAuth resource server configuration. " + - "JWT validation will be configured."); - configureResourceServerForJwtValidation(http, kindeOAuth2Properties); - } else if (getOpaqueTokenConfigurer(oAuth2ResourceServerConfigurer).isPresent()) { - log.debug("Opaque Token configurer is set in OAuth resource server configuration. " + - "Opaque Token validation/introspection will be configured."); - configureResourceServerForOpaqueTokenValidation(http, kindeOAuth2Properties); - } else { - log.debug("OAuth2ResourceServerConfigurer bean not configured, Resource Server support will not be enabled."); - } + if (context.getBeansOfType(KindeOAuth2Properties.class).isEmpty()) { + return; + } + KindeOAuth2Properties kindeOAuth2Properties = context.getBean(KindeOAuth2Properties.class); + + // Auth Code Flow Config + + // if OAuth2ClientProperties bean is not available do NOT configure + OAuth2ClientProperties.Provider propertiesProvider; + OAuth2ClientProperties.Registration propertiesRegistration; + if (!context.getBeansOfType(OAuth2ClientProperties.class).isEmpty() + && (propertiesProvider = context.getBean(OAuth2ClientProperties.class).getProvider().get("kinde")) != null + && (propertiesRegistration = context.getBean(OAuth2ClientProperties.class).getRegistration().get("kinde")) != null + && StringUtils.hasText(propertiesProvider.getIssuerUri()) + && StringUtils.hasText(propertiesRegistration.getClientId())) { + + configureLogin(http, kindeOAuth2Properties, context.getEnvironment()); + + // check for RP-Initiated logout + if (!context.getBeansOfType(OidcClientInitiatedLogoutSuccessHandler.class).isEmpty()) { + OidcClientInitiatedLogoutSuccessHandler handler = + context.getBean(OidcClientInitiatedLogoutSuccessHandler.class); + http.logout(logout -> logout.logoutSuccessHandler(handler)); + } + + // Resource Server Config + OAuth2ResourceServerConfigurer oAuth2ResourceServerConfigurer = http.getConfigurer(OAuth2ResourceServerConfigurer.class); + + if (getJwtConfigurer(oAuth2ResourceServerConfigurer).isPresent()) { + log.debug("JWT configurer is set in OAuth resource server configuration. " + + "JWT validation will be configured."); + configureResourceServerForJwtValidation(http, kindeOAuth2Properties); + } else if (getOpaqueTokenConfigurer(oAuth2ResourceServerConfigurer).isPresent()) { + log.debug("Opaque Token configurer is set in OAuth resource server configuration. " + + "Opaque Token validation/introspection will be configured."); + configureResourceServerForOpaqueTokenValidation(http, kindeOAuth2Properties); } else { - log.debug("Kinde/OIDC Login not configured due to missing issuer, client-id, or client-secret property"); + log.debug("OAuth2ResourceServerConfigurer bean not configured, Resource Server support will not be enabled."); } + } else { + log.debug("Kinde/OIDC Login not configured due to missing issuer, client-id, or client-secret property"); } } - private Optional.JwtConfigurer> getJwtConfigurer(OAuth2ResourceServerConfigurer oAuth2ResourceServerConfigurer) throws IllegalAccessException { + private Optional.JwtConfigurer> getJwtConfigurer(OAuth2ResourceServerConfigurer oAuth2ResourceServerConfigurer) { if (oAuth2ResourceServerConfigurer != null) { return getFieldValue(oAuth2ResourceServerConfigurer, "jwtConfigurer"); } return Optional.empty(); } - private Optional.OpaqueTokenConfigurer> getOpaqueTokenConfigurer(OAuth2ResourceServerConfigurer oAuth2ResourceServerConfigurer) throws IllegalAccessException { + private Optional.OpaqueTokenConfigurer> getOpaqueTokenConfigurer(OAuth2ResourceServerConfigurer oAuth2ResourceServerConfigurer) { if (oAuth2ResourceServerConfigurer != null) { return getFieldValue(oAuth2ResourceServerConfigurer, "opaqueTokenConfigurer"); } return Optional.empty(); } - private Optional getFieldValue(Object source, String fieldName) throws IllegalAccessException { - Field field = AccessController.doPrivileged((PrivilegedAction) () -> { - Field result = null; - try { - result = OAuth2ResourceServerConfigurer.class.getDeclaredField(fieldName); - result.setAccessible(true); - } catch (NoSuchFieldException e) { - log.warn("Could not get field '" + fieldName + "' of {} via reflection", - OAuth2ResourceServerConfigurer.class.getName(), e); - } - return result; - }); - - if (field == null) { + @SuppressWarnings("unchecked") + private Optional getFieldValue(Object source, String fieldName) { + Field field; + try { + field = OAuth2ResourceServerConfigurer.class.getDeclaredField(fieldName); + field.setAccessible(true); + } catch (NoSuchFieldException e) { + log.warn("Could not get field '{}' of {} via reflection", + fieldName, OAuth2ResourceServerConfigurer.class.getName(), e); String errMsg = "Expected field '" + fieldName + "' was not found in OAuth resource server configuration. " + - "Version incompatibility with Spring Security detected." + - "Check https://github.com/okta/okta-spring-boot for project updates."; - throw new RuntimeException(errMsg); + "Version incompatibility with Spring Security detected."; + throw new RuntimeException(errMsg, e); } - return Optional.ofNullable((T) field.get(source)); + try { + return Optional.ofNullable((T) field.get(source)); + } catch (IllegalAccessException e) { + throw new RuntimeException("Unable to access field '" + fieldName + "' on " + + OAuth2ResourceServerConfigurer.class.getName(), e); + } } /** @@ -118,65 +118,63 @@ private Optional getFieldValue(Object source, String fieldName) throws Il * To address this, we need this helper method to unset Jwt configurer before attempting to set Opaque Token configuration * for Root/Org issuer use case. */ - @SuppressWarnings("PMD.UnusedPrivateMethod") + @SuppressWarnings({"PMD.UnusedPrivateMethod", "rawtypes"}) private void unsetJwtConfigurer(OAuth2ResourceServerConfigurer oAuth2ResourceServerConfigurer) { - - AccessController.doPrivileged((PrivilegedAction) () -> { - Field result = null; - try { - result = OAuth2ResourceServerConfigurer.class.getDeclaredField("jwtConfigurer"); - result.setAccessible(true); - - result.set(oAuth2ResourceServerConfigurer, null); - } catch (NoSuchFieldException | IllegalAccessException e) { - log.warn("Could not access field '" + "jwtConfigurer" + "' of {} via reflection", - OAuth2ResourceServerConfigurer.class.getName(), e); - } - return result; - }); + try { + Field field = OAuth2ResourceServerConfigurer.class.getDeclaredField("jwtConfigurer"); + field.setAccessible(true); + field.set(oAuth2ResourceServerConfigurer, null); + } catch (NoSuchFieldException | IllegalAccessException e) { + log.warn("Could not access field 'jwtConfigurer' of {} via reflection", + OAuth2ResourceServerConfigurer.class.getName(), e); + } } - private void configureLogin(HttpSecurity http, KindeOAuth2Properties kindeOAuth2Properties, Environment environment) throws Exception { - - RestTemplate restTemplate = KindeOAuth2ResourceServerAutoConfig.restTemplate(kindeOAuth2Properties); + private void configureLogin(HttpSecurity http, KindeOAuth2Properties kindeOAuth2Properties, Environment environment) { - http.oauth2Login() - .tokenEndpoint() - .accessTokenResponseClient(accessTokenResponseClient(restTemplate)); + RestClient restClient = KindeOAuth2ResourceServerAutoConfig.restClient(kindeOAuth2Properties); + OAuth2AccessTokenResponseClient tokenResponseClient = + accessTokenResponseClient(restClient); String redirectUriProperty = environment.getProperty("spring.security.oauth2.client.registration.kinde.redirect-uri"); - if (redirectUriProperty != null) { - // remove `{baseUrl}` pattern, if present, as Spring will solve this on its own - String redirectUri = redirectUriProperty.replace("{baseUrl}", ""); - http.oauth2Login().redirectionEndpoint().baseUri(redirectUri); - } + + http.oauth2Login(oauth2 -> { + oauth2.tokenEndpoint(token -> token.accessTokenResponseClient(tokenResponseClient)); + if (redirectUriProperty != null) { + // remove `{baseUrl}` pattern, if present, as Spring will solve this on its own + String redirectUri = redirectUriProperty.replace("{baseUrl}", ""); + oauth2.redirectionEndpoint(redirect -> redirect.baseUri(redirectUri)); + } + }); } - private void configureResourceServerForJwtValidation(HttpSecurity http, KindeOAuth2Properties kindeOAuth2Properties) throws Exception { - http.oauth2ResourceServer() - .jwt().jwtAuthenticationConverter(new KindeJwtAuthenticationConverter(kindeOAuth2Properties.getPermissionsClaim())); + private void configureResourceServerForJwtValidation(HttpSecurity http, KindeOAuth2Properties kindeOAuth2Properties) { + http.oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter( + new KindeJwtAuthenticationConverter(kindeOAuth2Properties.getPermissionsClaim())))); } - private void configureResourceServerForOpaqueTokenValidation(HttpSecurity http, KindeOAuth2Properties kindeOAuth2Properties) throws Exception { + @SuppressWarnings("rawtypes") + private void configureResourceServerForOpaqueTokenValidation(HttpSecurity http, KindeOAuth2Properties kindeOAuth2Properties) { if (!kindeOAuth2Properties.getClientId().isEmpty() && !kindeOAuth2Properties.getClientSecret().isEmpty()) { - // Spring (2.7.x+) configures JWT be default and this creates startup failure "Spring Security - // only supports JWTs or Opaque Tokens, not both at the same time" when we try to configure Opaque Token mode in following line. - // Therefore, we are unsetting JWT mode before attempting to configure Opaque Token mode for ROOT issuer case. - - if (http.getConfigurer(OAuth2ResourceServerConfigurer.class) != null) { - unsetJwtConfigurer(http.getConfigurer(OAuth2ResourceServerConfigurer.class)); + // Spring (2.7.x+) configures JWT by default and this creates a startup failure ("Spring Security + // only supports JWTs or Opaque Tokens, not both at the same time") when we try to configure Opaque Token + // mode in the following call. Therefore, we unset the JWT configuration before attempting to configure + // Opaque Token mode for the ROOT issuer case. + + OAuth2ResourceServerConfigurer existing = http.getConfigurer(OAuth2ResourceServerConfigurer.class); + if (existing != null) { + unsetJwtConfigurer(existing); } - http.oauth2ResourceServer().opaqueToken(); + http.oauth2ResourceServer(oauth2 -> oauth2.opaqueToken(opaque -> {})); } } - private OAuth2AccessTokenResponseClient accessTokenResponseClient(RestTemplate restTemplate) { - - DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient(); - accessTokenResponseClient.setRestOperations(restTemplate); - - return accessTokenResponseClient; + private OAuth2AccessTokenResponseClient accessTokenResponseClient(RestClient restClient) { + RestClientAuthorizationCodeTokenResponseClient client = new RestClientAuthorizationCodeTokenResponseClient(); + client.setRestClient(restClient); + return client; } } diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2ResourceServerAutoConfig.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2ResourceServerAutoConfig.java index 651fd91e..53f5d67d 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2ResourceServerAutoConfig.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2ResourceServerAutoConfig.java @@ -2,36 +2,33 @@ import com.kinde.spring.config.KindeOAuth2Properties; import com.kinde.spring.http.KindeClientRequestInterceptor; +import com.kinde.spring.http.ProxyBasicAuthenticationInterceptor; import com.kinde.spring.http.UserAgentRequestInterceptor; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; -import org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerProperties; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Conditional; import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.http.client.support.BasicAuthenticationInterceptor; import org.springframework.http.converter.FormHttpMessageConverter; -import org.springframework.http.converter.StringHttpMessageConverter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector; -import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestTemplate; import java.net.InetSocketAddress; import java.net.Proxy; -import java.util.*; +import java.util.Objects; +import java.util.Optional; @AutoConfiguration @AutoConfigureBefore(OAuth2ResourceServerAutoConfiguration.class) @@ -58,29 +55,32 @@ JwtDecoder jwtDecoder(OAuth2ResourceServerProperties oAuth2ResourceServerPropert return decoder; } + /** + * Builds a {@link RestTemplate} that respects the configured Kinde proxy and adds the standard Kinde + * request interceptors. Used internally by the JWK fetcher and any other low-level HTTP work that + * still operates against {@code RestOperations}. + */ static RestTemplate restTemplate(KindeOAuth2Properties kindeOAuth2Properties) { Proxy proxy = null; KindeOAuth2Properties.Proxy proxyProperties = kindeOAuth2Properties.getProxy(); - Optional basicAuthenticationInterceptor = Optional.empty(); - if (proxyProperties != null && !proxyProperties.getHost().trim().isEmpty() && proxyProperties.getPort() > 0) { + Optional proxyAuthenticationInterceptor = Optional.empty(); + if (proxyProperties != null && StringUtils.hasText(proxyProperties.getHost()) && proxyProperties.getPort() > 0) { proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyProperties.getHost(), proxyProperties.getPort())); - if (!proxyProperties.getUsername().trim().isEmpty() && - !proxyProperties.getPassword().trim().isEmpty()) { + if (StringUtils.hasText(proxyProperties.getUsername()) && + StringUtils.hasText(proxyProperties.getPassword())) { - basicAuthenticationInterceptor = Optional.of(new BasicAuthenticationInterceptor(proxyProperties.getUsername(), + proxyAuthenticationInterceptor = Optional.of(new ProxyBasicAuthenticationInterceptor(proxyProperties.getUsername(), proxyProperties.getPassword())); } } - RestTemplate restTemplate = new RestTemplate(Arrays.asList( - new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter(), new StringHttpMessageConverter() - )); + RestTemplate restTemplate = new RestTemplate(); restTemplate.getInterceptors().add(new UserAgentRequestInterceptor()); restTemplate.getInterceptors().add(new KindeClientRequestInterceptor()); - basicAuthenticationInterceptor.ifPresent(restTemplate.getInterceptors()::add); + proxyAuthenticationInterceptor.ifPresent(restTemplate.getInterceptors()::add); SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); if (Objects.nonNull(proxy)) { requestFactory.setProxy(proxy); @@ -88,4 +88,56 @@ static RestTemplate restTemplate(KindeOAuth2Properties kindeOAuth2Properties) { restTemplate.setRequestFactory(requestFactory); return restTemplate; } + + /** + * Builds a {@link RestClient} that respects the configured Kinde proxy and adds the standard Kinde + * request interceptors. Spring Security 7's token response clients (e.g. + * {@code RestClientAuthorizationCodeTokenResponseClient}) require a {@link RestClient} rather than + * the legacy {@code RestOperations}. + */ + static RestClient restClient(KindeOAuth2Properties kindeOAuth2Properties) { + + Proxy proxy = null; + + KindeOAuth2Properties.Proxy proxyProperties = kindeOAuth2Properties.getProxy(); + Optional proxyAuthenticationInterceptor = Optional.empty(); + if (proxyProperties != null && StringUtils.hasText(proxyProperties.getHost()) && proxyProperties.getPort() > 0) { + proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyProperties.getHost(), proxyProperties.getPort())); + + if (StringUtils.hasText(proxyProperties.getUsername()) && + StringUtils.hasText(proxyProperties.getPassword())) { + + proxyAuthenticationInterceptor = Optional.of(new ProxyBasicAuthenticationInterceptor(proxyProperties.getUsername(), + proxyProperties.getPassword())); + } + } + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + if (Objects.nonNull(proxy)) { + requestFactory.setProxy(proxy); + } + + // Spring Security 7's RestClient-based token response clients (e.g. + // RestClientAuthorizationCodeTokenResponseClient) build their internal RestClient with the + // FormHttpMessageConverter (for the form-encoded request body) and the + // OAuth2AccessTokenResponseHttpMessageConverter (which constructs the OAuth2AccessTokenResponse + // via its Builder, guaranteeing a non-null additionalParameters map). They also install an + // OAuth2ErrorResponseErrorHandler so non-2xx token endpoint responses surface as + // OAuth2AuthorizationException. When we replace that RestClient with our own, we must register + // the same converters / error handler, otherwise the token response gets deserialized by a + // generic JSON converter into an OAuth2AccessTokenResponse with null additionalParameters, + // which then NPEs inside OidcAuthorizationCodeAuthenticationProvider when it checks for the + // id_token. See AbstractRestClientOAuth2AccessTokenResponseClient (Spring Security 7.x). + RestClient.Builder builder = RestClient.builder() + .requestFactory(requestFactory) + .configureMessageConverters(messageConverters -> { + messageConverters.addCustomConverter(new FormHttpMessageConverter()); + messageConverters.addCustomConverter(new OAuth2AccessTokenResponseHttpMessageConverter()); + }) + .defaultStatusHandler(new OAuth2ErrorResponseErrorHandler()) + .requestInterceptor(new UserAgentRequestInterceptor()) + .requestInterceptor(new KindeClientRequestInterceptor()); + proxyAuthenticationInterceptor.ifPresent(builder::requestInterceptor); + return builder.build(); + } } diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2AutoConfig.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2AutoConfig.java index 29509e7f..9014215a 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2AutoConfig.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2AutoConfig.java @@ -13,6 +13,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; @@ -29,8 +30,8 @@ @AutoConfiguration @AutoConfigureBefore(name = { - "org.springframework.boot.autoconfigure.security.oauth2.client.reactive.ReactiveOAuth2ClientAutoConfiguration", - "org.springframework.boot.autoconfigure.security.oauth2.resource.reactive.ReactiveOAuth2ResourceServerAutoConfiguration"}) + "org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientAutoConfiguration", + "org.springframework.boot.security.oauth2.server.resource.autoconfigure.reactive.ReactiveOAuth2ResourceServerAutoConfiguration"}) @EnableConfigurationProperties(KindeOAuth2Properties.class) @ConditionalOnKindeClientProperties @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) @@ -60,14 +61,14 @@ OidcReactiveOAuth2UserService oidcUserService(Collection au SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, ReactiveJwtDecoder jwtDecoder, ReactiveClientRegistrationRepository clientRegistrationRepository) { // as of Spring Security 5.4 the default chain uses oauth2Login OR a JWT resource server (NOT both) // this does the same as both defaults merged together (and provides the previous behavior) - http.authorizeExchange().anyExchange().authenticated(); + http.authorizeExchange(exchanges -> exchanges.anyExchange().authenticated()); Kinde.configureOAuth2WithPkce(http, clientRegistrationRepository); - http.oauth2Client(); - http.oauth2ResourceServer((server) -> customDecoder(server, jwtDecoder)); + http.oauth2Client(Customizer.withDefaults()); + http.oauth2ResourceServer(server -> customDecoder(server, jwtDecoder)); return http.build(); } private void customDecoder(ServerHttpSecurity.OAuth2ResourceServerSpec server, ReactiveJwtDecoder decoder) { - server.jwt((jwt) -> jwt.jwtDecoder(decoder)); + server.jwt(jwt -> jwt.jwtDecoder(decoder)); } -} \ No newline at end of file +} diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerAutoConfig.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerAutoConfig.java index acb3ec5c..b68898df 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerAutoConfig.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerAutoConfig.java @@ -6,9 +6,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; -import org.springframework.boot.autoconfigure.security.oauth2.resource.reactive.ReactiveOAuth2ResourceServerAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerProperties; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.reactive.ReactiveOAuth2ResourceServerAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig.java index 6f07c26d..d6052607 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig.java @@ -6,13 +6,13 @@ import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; -import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter; @AutoConfiguration @@ -40,11 +40,11 @@ static class KindeOAuth2ResourceServerBeanPostProcessor implements BeanPostProce public Object postProcessAfterInitialization(Object bean, String beanName) { if (bean instanceof ServerHttpSecurity) { final ServerHttpSecurity http = (ServerHttpSecurity) bean; - http.oauth2ResourceServer().jwt() + http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt .jwtAuthenticationConverter(new ReactiveJwtAuthenticationConverterAdapter( - new KindeJwtAuthenticationConverter(kindeOAuth2Properties.getPermissionsClaim()))); + new KindeJwtAuthenticationConverter(kindeOAuth2Properties.getPermissionsClaim()))))); } return bean; } } -} \ No newline at end of file +} diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ServerHttpServerAutoConfig.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ServerHttpServerAutoConfig.java index 6b2ed48b..528ca81a 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ServerHttpServerAutoConfig.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/ReactiveKindeOAuth2ServerHttpServerAutoConfig.java @@ -51,7 +51,7 @@ BeanPostProcessor authManagerServerHttpSecurityBeanPostProcessor(@Qualifier("oau @Bean @ConditionalOnMissingBean - @ConditionalOnProperty(name = "okta.oauth2.post-logout-redirect-uri") + @ConditionalOnProperty(name = "kinde.oauth2.post-logout-redirect-uri") OidcClientInitiatedServerLogoutSuccessHandler oidcClientInitiatedServerLogoutSuccessHandler(KindeOAuth2Properties kindeOAuth2Properties, ReactiveClientRegistrationRepository repository) throws URISyntaxException { OidcClientInitiatedServerLogoutSuccessHandler logoutSuccessHandler = new OidcClientInitiatedServerLogoutSuccessHandler(repository); @@ -124,13 +124,14 @@ static class KindeOAuth2LoginServerBeanPostProcessor implements BeanPostProcesso public Object postProcessAfterInitialization(Object bean, String beanName) { if (bean instanceof ServerHttpSecurity) { ServerHttpSecurity httpSecurity = (ServerHttpSecurity) bean; - httpSecurity.oauth2Login().authenticationManager(reactiveAuthenticationManager(oAuth2UserService, oidcUserService)); + httpSecurity.oauth2Login(oauth2 -> oauth2.authenticationManager( + reactiveAuthenticationManager(oAuth2UserService, oidcUserService))); if (logoutSuccessHandler != null) { - httpSecurity.logout().logoutSuccessHandler(logoutSuccessHandler); + httpSecurity.logout(logout -> logout.logoutSuccessHandler(logoutSuccessHandler)); } } return bean; } } -} \ No newline at end of file +} diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/TokenUtil.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/TokenUtil.java index 7272370d..b0c3db5c 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/TokenUtil.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/TokenUtil.java @@ -64,17 +64,31 @@ static Collection opaqueTokenClaimsToAuthorities(Map return mappedAuthorities; } - static OAuth2TokenValidator jwtValidator(String issuer, String audience ) { + static OAuth2TokenValidator jwtValidator(String issuer, String audience) { List> validators = new ArrayList<>(); - validators.add(new JwtTimestampValidator()); - validators.add(new JwtIssuerValidator(issuer)); + validators.add(new JwtTimestampValidator()); + validators.add(new JwtIssuerValidator(issuer)); + // Audience validation is opt-in: only enforced when an expected audience is explicitly + // configured (kinde.oauth2.audience). Kinde access tokens issued for clients without a + // configured API resource carry an empty `aud` array, so requiring a default audience + // such as "api://default" would reject every default Kinde token. When no audience is + // configured we therefore skip the audience check entirely; in production deployments + // that have a Kinde API resource set up, callers SHOULD set kinde.oauth2.audience to + // the matching value. + if (StringUtils.hasText(audience)) { + final String expected = audience; validators.add(token -> { + List tokenAudience = token.getAudience(); + if (tokenAudience == null || tokenAudience.isEmpty()) { + return OAuth2TokenValidatorResult.failure(INVALID_AUDIENCE); + } Set expectedAudience = new HashSet<>(); - expectedAudience.add(audience); - return !Collections.disjoint(token.getAudience(), expectedAudience) + expectedAudience.add(expected); + return !Collections.disjoint(tokenAudience, expectedAudience) ? OAuth2TokenValidatorResult.success() : OAuth2TokenValidatorResult.failure(INVALID_AUDIENCE); }); + } return new DelegatingOAuth2TokenValidator<>(validators); } @@ -100,12 +114,12 @@ static boolean isRootOrgIssuer(String issuerUri) throws MalformedURLException { if (tokenizedUri.length >= 2 && "oauth2".equals(tokenizedUri[0]) && !tokenizedUri[1].trim().isEmpty()) { - log.debug("The issuer URL: '{}' is an Okta custom authorization server", issuerUri); + log.debug("The issuer URL: '{}' is a custom authorization server", issuerUri); return false; } } - log.debug("The issuer URL: '{}' is an Okta root/org authorization server", issuerUri); + log.debug("The issuer URL: '{}' is a root/org authorization server", issuerUri); return true; } } \ No newline at end of file diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/UserUtil.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/UserUtil.java index 30d0357f..b140d49d 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/UserUtil.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/UserUtil.java @@ -21,7 +21,7 @@ private UserUtil() {} static OAuth2User decorateUser(OAuth2User user, OAuth2UserRequest userRequest, Collection authoritiesProviders, KindeClient kindeClient) { - // Only post process requests from the "Okta" reg + // Only post process requests from the Kinde registration if (!"kinde".equalsIgnoreCase(userRequest.getClientRegistration().getRegistrationId())) { return user; } @@ -50,8 +50,8 @@ static OAuth2User decorateUser(OAuth2User user, OAuth2UserRequest userRequest, C static OidcUser decorateUser(OidcUser user, OidcUserRequest userRequest, Collection authoritiesProviders, KindeClient kindeClient) { - // Only post process requests from the "Okta" reg - if (!"kinde".equals(userRequest.getClientRegistration().getRegistrationId())) { + // Only post process requests from the Kinde registration + if (!"kinde".equalsIgnoreCase(userRequest.getClientRegistration().getRegistrationId())) { return user; } diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/config/KindeOAuth2Properties.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/config/KindeOAuth2Properties.java index b014ca93..40f77179 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/config/KindeOAuth2Properties.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/config/KindeOAuth2Properties.java @@ -1,8 +1,8 @@ package com.kinde.spring.config; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientProperties; import org.springframework.validation.Errors; import org.springframework.validation.Validator; @@ -16,7 +16,8 @@ public final class KindeOAuth2Properties implements Validator { /** * Login route path. This property should NOT be used with applications that have multiple OAuth2 providers. - * NOTE: this does NOT work with WebFlux, where the redirect URI will always be: /login/oauth2/code/okta + * NOTE: this does NOT work with WebFlux, where the redirect URI is derived from the OAuth2 client + * registration id and defaults to {@code /login/oauth2/code/kinde} for the Kinde registration. */ private String redirectUri; @@ -36,7 +37,8 @@ public final class KindeOAuth2Properties implements Validator { private String authorizationGrantType; /** - * Custom authorization server issuer URL: i.e. 'https://dev-123456.oktapreview.com/oauth2/ausar5cbq5TRooicu812'. + * Kinde authorization server URL: e.g. {@code https://your-subdomain.kinde.com}. This is the + * issuer URI for the OIDC provider. */ private String domain; @@ -47,8 +49,14 @@ public final class KindeOAuth2Properties implements Validator { /** * Expected access token audience claim value. + * + *

Defaults to {@code null}, meaning audience validation is disabled. This matches Kinde's + * out-of-the-box behaviour: tokens issued for clients without a configured API resource + * carry an empty {@code aud} array. When you have a Kinde API resource configured, set this + * property to the matching audience value so {@link com.kinde.spring.TokenUtil#jwtValidator} + * enforces it. */ - private String audience = "api://default"; + private String audience; /** * Access token permissions claim key. diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/env/KindeOAuth2PropertiesMappingEnvironmentPostProcessor.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/env/KindeOAuth2PropertiesMappingEnvironmentPostProcessor.java index fafb47f7..dc4d8d91 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/env/KindeOAuth2PropertiesMappingEnvironmentPostProcessor.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/env/KindeOAuth2PropertiesMappingEnvironmentPostProcessor.java @@ -8,7 +8,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.boot.EnvironmentPostProcessor; import org.springframework.boot.logging.DeferredLog; import org.springframework.core.Ordered; import org.springframework.core.env.ConfigurableEnvironment; @@ -67,7 +67,7 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp if (kindeClient != null) { // default scopes, as of Spring Security 5.4 default scopes are no longer added, this restores that functionality environment.getPropertySources().addLast(defaultKindeScopesSource(environment, kindeClient)); - // okta's endpoints can be resolved from an issuer + // Kinde's endpoints can be resolved from an issuer environment.getPropertySources().addLast(kindeStaticDiscoveryPropertySource(environment, kindeClient)); // Auth0 does not have an introspection endpoint environment.getPropertySources().addLast(kindeOpaqueTokenPropertySource(environment, kindeClient)); @@ -115,7 +115,7 @@ private PropertySource remappedKindeOAuth2ScopesPropertySource(Environment envir Map properties = new HashMap<>(); properties.put("spring.security.oauth2.client.registration.kinde.scope", "${" + KINDE_OAUTH_SCOPES + "}"); - return new KindeScopesPropertySource("okta-scope-remaper", properties, environment); + return new KindeScopesPropertySource("kinde-scope-remapper", properties, environment); } private PropertySource kindeRedirectUriPropertySource(Environment environment) { diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/http/ProxyBasicAuthenticationInterceptor.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/http/ProxyBasicAuthenticationInterceptor.java new file mode 100644 index 00000000..615ca4e5 --- /dev/null +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/http/ProxyBasicAuthenticationInterceptor.java @@ -0,0 +1,36 @@ +package com.kinde.spring.http; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Adds an HTTP {@code Proxy-Authorization: Basic } header to the outgoing + * request. Unlike {@link org.springframework.http.client.support.BasicAuthenticationInterceptor}, + * which targets origin-server auth via the {@code Authorization} header, this interceptor is + * intended for authenticating against an upstream HTTP proxy and therefore uses + * {@link HttpHeaders#PROXY_AUTHORIZATION} (RFC 7235 §4.4). Routing proxy credentials through the + * origin-server {@code Authorization} header is wrong both functionally (the proxy cannot read it) + * and from a confidentiality standpoint (the credentials would leak to the origin server). + */ +public final class ProxyBasicAuthenticationInterceptor implements ClientHttpRequestInterceptor { + + private final String authorization; + + public ProxyBasicAuthenticationInterceptor(String username, String password) { + this.authorization = "Basic " + Base64.getEncoder() + .encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)); + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { + request.getHeaders().set(HttpHeaders.PROXY_AUTHORIZATION, authorization); + return execution.execute(request, body); + } +} diff --git a/kinde-springboot/kinde-springboot-core/src/main/resources/META-INF/spring.factories b/kinde-springboot/kinde-springboot-core/src/main/resources/META-INF/spring.factories index ff43908f..dade7236 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/resources/META-INF/spring.factories +++ b/kinde-springboot/kinde-springboot-core/src/main/resources/META-INF/spring.factories @@ -14,7 +14,7 @@ # limitations under the License. # -org.springframework.boot.env.EnvironmentPostProcessor = com.kinde.spring.env.KindeOAuth2PropertiesMappingEnvironmentPostProcessor +org.springframework.boot.EnvironmentPostProcessor = com.kinde.spring.env.KindeOAuth2PropertiesMappingEnvironmentPostProcessor org.springframework.context.ApplicationListener = com.kinde.spring.env.KindeEnvironmentPostProcessorApplicationListener org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer = \ diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2AutoConfigTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2AutoConfigTest.java index 23907f11..87d08169 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2AutoConfigTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2AutoConfigTest.java @@ -5,91 +5,98 @@ import com.kinde.spring.config.KindeOAuth2Properties; import com.kinde.spring.env.KindeOAuth2PropertiesMappingEnvironmentPostProcessor; import com.kinde.spring.sdk.KindeSdkClient; -import org.checkerframework.checker.units.qual.K; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.web.server.WebServer; -import org.springframework.boot.web.servlet.ServletContextInitializer; -import org.springframework.boot.web.servlet.server.ServletWebServerFactory; -import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import org.springframework.security.access.SecurityConfig; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; -import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; -import org.springframework.test.context.ContextConfiguration; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.SecurityFilterChain; import org.springframework.test.context.TestPropertySource; -import java.util.ArrayList; +import java.lang.reflect.Field; import java.util.List; -import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @TestPropertySource(properties = { - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_DOMAIN + "=https://test.kinde.com" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_ID + "=test_client_id" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_SECRET + "=test_client_secret" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_SCOPES + "=profile" , + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_DOMAIN + "=https://test.kinde.com", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_ID + "=test_client_id", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_SECRET + "=test_client_secret", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_SCOPES + "=profile", "spring.security.oauth2.client.registration.kinde.client-id=test_client_id" }) @SpringBootTest(classes = {KindeOAuth2AutoConfigTest.class}) @Import(KindeOAuth2AutoConfigTest.MyTestConfig.class) public class KindeOAuth2AutoConfigTest { + /** + * Minimal Spring context for the autoconfig under test. {@link KindeOAuth2AutoConfig} is + * instantiated directly (instead of being picked up via auto-configuration) so we can test + * its bean-factory methods in isolation, while the {@link KindeSdkClient} chain is mocked + * end-to-end so individual tests can re-stub {@code logoutRedirectUri()} per scenario. + */ @TestConfiguration public static class MyTestConfig { - @Bean public ClientRegistrationRepository clientRegistrationRepository() { - System.out.println("Hello 1"); - return Mockito.mock(ClientRegistrationRepository.class); + return mock(ClientRegistrationRepository.class); } @Bean public KindeConfig kindeConfig() { - KindeConfig kindeConfig = Mockito.mock(KindeConfig.class); - when(kindeConfig.logoutRedirectUri()).thenReturn("http://localhost:8080/"); - return kindeConfig; + return mock(KindeConfig.class); } @Bean public KindeClient kindeClient(KindeConfig kindeConfig) { - KindeClient kindeClient = Mockito.mock(KindeClient.class); + KindeClient kindeClient = mock(KindeClient.class); when(kindeClient.kindeConfig()).thenReturn(kindeConfig); return kindeClient; } @Bean public KindeSdkClient kindeSdkClient(KindeClient kindeClient) { - System.out.println("Hello 2"); - KindeSdkClient kindeSdkClient = Mockito.mock(KindeSdkClient.class); + KindeSdkClient kindeSdkClient = mock(KindeSdkClient.class); when(kindeSdkClient.getClient()).thenReturn(kindeClient); return kindeSdkClient; } + /** + * Required to satisfy the {@code @Autowired KindeOAuth2Properties} field on + * {@link KindeSdkClient}'s real autoconfig, which Spring Boot picks up from the + * classpath even though we provide a mocked {@link KindeSdkClient} bean below. + */ @Bean public KindeOAuth2Properties kindeOAuth2Properties() { - return Mockito.mock(KindeOAuth2Properties.class); + return mock(KindeOAuth2Properties.class); } @Bean public KindeOAuth2AutoConfig kindeOAuth2AutoConfig(KindeSdkClient kindeSdkClient) { - System.out.println("Hello 3"); - KindeOAuth2AutoConfig kindeOAuth2AutoConfig = new KindeOAuth2AutoConfig(); - kindeOAuth2AutoConfig.setKindeSdkClient(kindeSdkClient); - return kindeOAuth2AutoConfig; + KindeOAuth2AutoConfig autoConfig = new KindeOAuth2AutoConfig(); + autoConfig.setKindeSdkClient(kindeSdkClient); + return autoConfig; } } @@ -100,26 +107,122 @@ public KindeOAuth2AutoConfig kindeOAuth2AutoConfig(KindeSdkClient kindeSdkClient private ClientRegistrationRepository clientRegistrationRepository; @Autowired - private ApplicationContext context; + private KindeConfig kindeConfig; + + // --- oidcLogoutSuccessHandler (covers both branches of the {baseUrl} ternary) -------------- + + @Test + public void oidcLogoutSuccessHandlerWithAbsoluteUriDoesNotPrependBaseUrl() throws Exception { + when(kindeConfig.logoutRedirectUri()).thenReturn("http://localhost:8080/"); - @BeforeEach - public void setUp() { + OidcClientInitiatedLogoutSuccessHandler handler = + kindeOAuth2AutoConfig.oidcLogoutSuccessHandler(clientRegistrationRepository); + + assertNotNull(handler); + assertEquals("http://localhost:8080/", postLogoutRedirectUriOf(handler), + "Absolute logout URIs must be passed through verbatim (no {baseUrl} prefix)"); } @Test - public void testOidcLogoutSuccessHandler() throws Exception { - OidcClientInitiatedLogoutSuccessHandler oidcClientInitiatedLogoutSuccessHandler = kindeOAuth2AutoConfig.oidcLogoutSuccessHandler(clientRegistrationRepository); + public void oidcLogoutSuccessHandlerWithRelativePathPrependsBaseUrlPlaceholder() throws Exception { + when(kindeConfig.logoutRedirectUri()).thenReturn("/post-logout"); + + OidcClientInitiatedLogoutSuccessHandler handler = + kindeOAuth2AutoConfig.oidcLogoutSuccessHandler(clientRegistrationRepository); + + assertNotNull(handler); + assertEquals("{baseUrl}/post-logout", postLogoutRedirectUriOf(handler), + "Relative logout paths must be prefixed with {baseUrl} so Spring resolves them"); } + // --- user-service factories --------------------------------------------------------------- + @Test - public void testOAuth2UserService() throws Exception { - OAuth2UserService oAuth2UserService = kindeOAuth2AutoConfig.oAuth2UserService(List.of()); + public void oAuth2UserServiceProducesKindeOAuth2UserService() { + OAuth2UserService service = + kindeOAuth2AutoConfig.oAuth2UserService(List.of()); + + assertNotNull(service); + assertInstanceOf(KindeOAuth2UserService.class, service); } @Test - public void testOidcUserService() throws Exception { - OAuth2UserService oAuth2UserService = kindeOAuth2AutoConfig.oidcUserService(kindeOAuth2AutoConfig.oAuth2UserService(List.of()),List.of()); + public void oidcUserServiceProducesKindeOidcUserService() { + OAuth2UserService oAuth2UserService = + kindeOAuth2AutoConfig.oAuth2UserService(List.of()); + + OAuth2UserService oidcUserService = + kindeOAuth2AutoConfig.oidcUserService(oAuth2UserService, List.of()); + + assertNotNull(oidcUserService); + assertInstanceOf(KindeOidcUserService.class, oidcUserService); } + // --- OAuth2SecurityFilterChainConfiguration ------------------------------------------------ + /** + * Exercises the inner {@code OAuth2SecurityFilterChainConfiguration#oauth2SecurityFilterChain(...)} + * factory by mocking {@link HttpSecurity} and driving the {@code authorizeHttpRequests} customizer + * through a Mockito {@code Answer}, so the {@code requests.anyRequest().authenticated()} lambda + * body actually executes under JaCoCo. The other DSL calls ({@code oauth2Login}, + * {@code oauth2Client}, {@code oauth2ResourceServer}) only need to be verified at the call-site + * level since their lambda bodies are covered by other tests / are simple {@code Customizer.withDefaults()}. + */ + @Test + @SuppressWarnings("rawtypes") // raw types unavoidable on Spring Security's owner-parameterized inner DSL types + public void oauth2SecurityFilterChainBuildsTheChainAndDelegatesToTheDsl() throws Exception { + HttpSecurity http = mock(HttpSecurity.class); + // HttpSecurity#build() returns DefaultSecurityFilterChain, not the SecurityFilterChain + // interface, so the mock has to match that concrete type. + DefaultSecurityFilterChain expectedChain = mock(DefaultSecurityFilterChain.class); + when(http.build()).thenReturn(expectedChain); + + // Drive the inner customizer for authorizeHttpRequests so the + // `requests.anyRequest().authenticated()` body fires. + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry = + mock(AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry.class); + AuthorizeHttpRequestsConfigurer.AuthorizedUrl authorizedUrl = + mock(AuthorizeHttpRequestsConfigurer.AuthorizedUrl.class); + when(registry.anyRequest()).thenReturn(authorizedUrl); + when(authorizedUrl.authenticated()).thenReturn(registry); + when(http.authorizeHttpRequests(any())).thenAnswer(invocation -> { + Customizer customizer = + invocation.getArgument(0); + customizer.customize(registry); + return http; + }); + + // The remaining DSL calls just record the customizer; we verify call counts below. + when(http.oauth2Login(any())).thenReturn(http); + when(http.oauth2Client(any())).thenReturn(http); + when(http.oauth2ResourceServer(any())).thenReturn(http); + + ClientRegistrationRepository repo = mock(ClientRegistrationRepository.class); + KindeOAuth2AutoConfig.OAuth2SecurityFilterChainConfiguration config = + new KindeOAuth2AutoConfig.OAuth2SecurityFilterChainConfiguration(); + + SecurityFilterChain chain = config.oauth2SecurityFilterChain(http, repo); + + assertSame(expectedChain, chain, "should return the chain produced by http.build()"); + verify(http).authorizeHttpRequests(any()); + verify(registry).anyRequest(); + verify(authorizedUrl).authenticated(); + // oauth2Login is invoked exactly once - by Kinde.configureOAuth2WithPkce - in this chain. + verify(http, atLeastOnce()).oauth2Login(any()); + verify(http).oauth2Client(any()); + verify(http).oauth2ResourceServer(any()); + verify(http).build(); + } + + /** + * Reads the package-private {@code postLogoutRedirectUri} field of + * {@link OidcClientInitiatedLogoutSuccessHandler} via reflection so tests can assert + * the exact URI that the autoconfig installed on the handler. + */ + private static String postLogoutRedirectUriOf(OidcClientInitiatedLogoutSuccessHandler handler) throws Exception { + Field field = OidcClientInitiatedLogoutSuccessHandler.class.getDeclaredField("postLogoutRedirectUri"); + field.setAccessible(true); + Object value = field.get(handler); + return value == null ? null : value.toString(); + } } diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2ConfigurerTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2ConfigurerTest.java index 7b00b542..24577a92 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2ConfigurerTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2ConfigurerTest.java @@ -2,76 +2,405 @@ import com.kinde.spring.config.KindeOAuth2Properties; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientProperties; import org.springframework.context.ApplicationContext; import org.springframework.core.env.Environment; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer; import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collections; import java.util.HashMap; import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +/** + * Unit tests for the {@link KindeOAuth2Configurer} {@code init} flow. + *

+ * Spring Security 7's {@code HttpSecurity#oauth2Login()/logout()/oauth2ResourceServer()} API is + * Customizer-lambda based, so to drive JaCoCo through the configurer's lambda bodies we use + * Mockito {@code Answer}s that actually invoke the captured {@link Customizer}s against mocked + * sub-configurers. The short-circuit branches that bail out before touching the DSL are exercised + * by the simpler "noop" tests at the top. + */ public class KindeOAuth2ConfigurerTest { + private static final String VALID_ISSUER = "https://test.kinde.com"; + private static final String VALID_CLIENT_ID = "test-client-id"; + @Test - public void testInit() throws Exception { - KindeOAuth2Configurer kindeOAuth2Configurer = new KindeOAuth2Configurer(); - HttpSecurity httpSecurity = Mockito.mock(HttpSecurity.class); - ApplicationContext context = Mockito.mock(ApplicationContext.class); + public void initWithoutKindeOAuth2PropertiesIsNoop() { + KindeOAuth2Configurer configurer = new KindeOAuth2Configurer(); + HttpSecurity httpSecurity = mock(HttpSecurity.class); + ApplicationContext context = mock(ApplicationContext.class); when(httpSecurity.getSharedObject(ApplicationContext.class)).thenReturn(context); - kindeOAuth2Configurer.init(httpSecurity); - KindeOAuth2Properties kindeOAuth2Properties = Mockito.mock(KindeOAuth2Properties.class); - - OAuth2LoginConfigurer oAuth2LoginConfigurer = Mockito.mock(OAuth2LoginConfigurer.class); - when(httpSecurity.oauth2Login()).thenReturn(oAuth2LoginConfigurer); - OAuth2LoginConfigurer.TokenEndpointConfig tokenEndpointConfig = Mockito.mock(OAuth2LoginConfigurer.TokenEndpointConfig.class); - when(oAuth2LoginConfigurer.tokenEndpoint()).thenReturn(tokenEndpointConfig); - when(tokenEndpointConfig.accessTokenResponseClient(any())).thenReturn(tokenEndpointConfig); + when(context.getBeansOfType(KindeOAuth2Properties.class)).thenReturn(Collections.emptyMap()); + + configurer.init(httpSecurity); + + verify(httpSecurity, never()).oauth2Login(any()); + } + + @Test + public void initWithoutKindeRegistrationIsNoop() { + KindeOAuth2Configurer configurer = new KindeOAuth2Configurer(); + HttpSecurity httpSecurity = mock(HttpSecurity.class); + ApplicationContext context = mock(ApplicationContext.class); when(httpSecurity.getSharedObject(ApplicationContext.class)).thenReturn(context); - Map kindeOAuth2PropertiesMap = new HashMap<>(); - kindeOAuth2PropertiesMap.put(KindeOAuth2Properties.class.getName(),kindeOAuth2Properties); - when(context.getBeansOfType(KindeOAuth2Properties.class)).thenReturn(kindeOAuth2PropertiesMap); - when(context.getBeansOfType(KindeOAuth2Properties.class)).thenReturn(kindeOAuth2PropertiesMap); + + seedKindeProperties(context, mock(KindeOAuth2Properties.class)); + when(context.getBeansOfType(OAuth2ClientProperties.class)).thenReturn(Collections.emptyMap()); + + configurer.init(httpSecurity); + + verify(httpSecurity, never()).oauth2Login(any()); + } + + @Test + public void initWithMissingKindeProviderIsNoop() { + Fixture fx = happyPathFixture(); + when(fx.oauth2ClientProperties.getProvider()).thenReturn(Collections.emptyMap()); + + new KindeOAuth2Configurer().init(fx.httpSecurity); + + verify(fx.httpSecurity, never()).oauth2Login(any()); + } + + @Test + public void initWithMissingKindeClientRegistrationIsNoop() { + Fixture fx = happyPathFixture(); + when(fx.oauth2ClientProperties.getRegistration()).thenReturn(Collections.emptyMap()); + + new KindeOAuth2Configurer().init(fx.httpSecurity); + + verify(fx.httpSecurity, never()).oauth2Login(any()); + } + + @Test + public void initWithBlankIssuerUriIsNoop() { + Fixture fx = happyPathFixture(); + when(fx.provider.getIssuerUri()).thenReturn(""); + + new KindeOAuth2Configurer().init(fx.httpSecurity); + + verify(fx.httpSecurity, never()).oauth2Login(any()); + } + + @Test + public void initWithBlankClientIdIsNoop() { + Fixture fx = happyPathFixture(); + when(fx.registration.getClientId()).thenReturn(""); + + new KindeOAuth2Configurer().init(fx.httpSecurity); + + verify(fx.httpSecurity, never()).oauth2Login(any()); + } + + @Test + public void initHappyPathConfiguresOAuth2Login() { + Fixture fx = happyPathFixture(); + + new KindeOAuth2Configurer().init(fx.httpSecurity); + + verify(fx.httpSecurity).oauth2Login(any()); + verify(fx.oauth2LoginConfigurer).tokenEndpoint(any()); + verify(fx.tokenEndpoint).accessTokenResponseClient(any()); + // No redirect URI configured, so redirectionEndpoint should NOT be touched. + verify(fx.oauth2LoginConfigurer, never()).redirectionEndpoint(any()); + // No logout handler bean, so logout() should NOT be wired. + verify(fx.httpSecurity, never()).logout(any()); + } + + @Test + public void initHappyPathHonorsRedirectUriPropertyAndStripsBaseUrlPlaceholder() { + Fixture fx = happyPathFixture(); + when(fx.environment.getProperty("spring.security.oauth2.client.registration.kinde.redirect-uri")) + .thenReturn("{baseUrl}/login/oauth2/code/kinde"); + + new KindeOAuth2Configurer().init(fx.httpSecurity); + + verify(fx.httpSecurity).oauth2Login(any()); + verify(fx.oauth2LoginConfigurer).redirectionEndpoint(any()); + verify(fx.redirectionEndpoint).baseUri("/login/oauth2/code/kinde"); + } + + @Test + public void initHappyPathWiresOidcLogoutHandlerWhenBeanPresent() { + Fixture fx = happyPathFixture(); + OidcClientInitiatedLogoutSuccessHandler handler = mock(OidcClientInitiatedLogoutSuccessHandler.class); + Map handlerMap = new HashMap<>(); + handlerMap.put("oidcLogoutSuccessHandler", handler); + when(fx.context.getBeansOfType(OidcClientInitiatedLogoutSuccessHandler.class)).thenReturn(handlerMap); + when(fx.context.getBean(OidcClientInitiatedLogoutSuccessHandler.class)).thenReturn(handler); + + new KindeOAuth2Configurer().init(fx.httpSecurity); + + verify(fx.httpSecurity).logout(any()); + verify(fx.logoutConfigurer).logoutSuccessHandler(handler); + } + + @Test + @SuppressWarnings("unchecked") + public void initHappyPathWithJwtResourceServerInvokesJwtBranch() { + Fixture fx = happyPathFixture(); + OAuth2ResourceServerConfigurer resourceServerConfigurer = mock(OAuth2ResourceServerConfigurer.class); + // Plant a non-null jwtConfigurer so the configurer's reflection-based getJwtConfigurer() + // returns non-empty and the JWT branch fires. The field is strongly typed, so the value + // has to be an actual mock of the (non-static) inner JwtConfigurer. + OAuth2ResourceServerConfigurer.JwtConfigurer jwtConfigurer = + mock(OAuth2ResourceServerConfigurer.JwtConfigurer.class); + setReflectionField(resourceServerConfigurer, "jwtConfigurer", jwtConfigurer); + when(fx.httpSecurity.getConfigurer(OAuth2ResourceServerConfigurer.class)) + .thenAnswer(inv -> resourceServerConfigurer); + + // Drive the customizer passed to http.oauth2ResourceServer(...) so the nested + // jwt(jwt -> jwt.jwtAuthenticationConverter(...)) lambda actually executes. + OAuth2ResourceServerConfigurer rsCustomizerTarget = + (OAuth2ResourceServerConfigurer) mock(OAuth2ResourceServerConfigurer.class); + OAuth2ResourceServerConfigurer.JwtConfigurer jwtCustomizerTarget = + mock(OAuth2ResourceServerConfigurer.JwtConfigurer.class); + when(rsCustomizerTarget.jwt(any())).thenAnswer(inv -> { + Customizer.JwtConfigurer> inner = inv.getArgument(0); + inner.customize(jwtCustomizerTarget); + return rsCustomizerTarget; + }); + when(jwtCustomizerTarget.jwtAuthenticationConverter(any())).thenReturn(jwtCustomizerTarget); + when(fx.httpSecurity.oauth2ResourceServer(any())).thenAnswer(inv -> { + Customizer> customizer = inv.getArgument(0); + customizer.customize(rsCustomizerTarget); + return fx.httpSecurity; + }); + + new KindeOAuth2Configurer().init(fx.httpSecurity); + + verify(fx.httpSecurity).oauth2ResourceServer(any()); + verify(rsCustomizerTarget).jwt(any()); + verify(jwtCustomizerTarget).jwtAuthenticationConverter(any()); + } + + @Test + @SuppressWarnings("unchecked") + public void initHappyPathWithOpaqueTokenResourceServerInvokesOpaqueBranchAndUnsetsJwt() { + Fixture fx = happyPathFixture(); + // Opaque branch also reads clientId/clientSecret off KindeOAuth2Properties; non-empty + // values are required to actually call http.oauth2ResourceServer(...). + when(fx.kindeProperties.getClientId()).thenReturn(VALID_CLIENT_ID); + when(fx.kindeProperties.getClientSecret()).thenReturn("test-client-secret"); + + OAuth2ResourceServerConfigurer resourceServerConfigurer = mock(OAuth2ResourceServerConfigurer.class); + // jwtConfigurer left null so init() bypasses the JWT branch and falls through to the + // opaque branch. Plant a non-null opaqueTokenConfigurer so getOpaqueTokenConfigurer() + // returns non-empty. + OAuth2ResourceServerConfigurer.OpaqueTokenConfigurer opaqueConfigurer = + mock(OAuth2ResourceServerConfigurer.OpaqueTokenConfigurer.class); + setReflectionField(resourceServerConfigurer, "opaqueTokenConfigurer", opaqueConfigurer); + + when(fx.httpSecurity.getConfigurer(OAuth2ResourceServerConfigurer.class)) + .thenAnswer(inv -> resourceServerConfigurer); + + // Drive the customizer for oauth2ResourceServer(...) so we walk through the opaque() call. + OAuth2ResourceServerConfigurer rsCustomizerTarget = + (OAuth2ResourceServerConfigurer) mock(OAuth2ResourceServerConfigurer.class); + OAuth2ResourceServerConfigurer.OpaqueTokenConfigurer opaqueCustomizerTarget = + mock(OAuth2ResourceServerConfigurer.OpaqueTokenConfigurer.class); + when(rsCustomizerTarget.opaqueToken(any())).thenAnswer(inv -> { + Customizer.OpaqueTokenConfigurer> inner = inv.getArgument(0); + inner.customize(opaqueCustomizerTarget); + return rsCustomizerTarget; + }); + when(fx.httpSecurity.oauth2ResourceServer(any())).thenAnswer(inv -> { + Customizer> customizer = inv.getArgument(0); + customizer.customize(rsCustomizerTarget); + return fx.httpSecurity; + }); + + new KindeOAuth2Configurer().init(fx.httpSecurity); + + verify(fx.httpSecurity).oauth2ResourceServer(any()); + verify(rsCustomizerTarget).opaqueToken(any()); + } + + /** + * The private {@code getFieldValue(Object, String)} helper has a defensive + * {@code NoSuchFieldException} catch that translates a Spring Security version mismatch + * (a renamed/removed internal field on {@link OAuth2ResourceServerConfigurer}) into a + * {@link RuntimeException} with a "Version incompatibility" message. The public callers + * ({@code getJwtConfigurer} / {@code getOpaqueTokenConfigurer}) hardcode known field names, + * so we exercise the catch by invoking {@code getFieldValue} reflectively with a deliberately + * unknown field name. + * + *

The sibling {@code IllegalAccessException} catch (and the catch in + * {@code unsetJwtConfigurer}) is not asserted here: after {@code Field#setAccessible(true)} + * succeeds, modern JVMs bypass the access check inside {@code Field#get}, so that branch is + * effectively dead code in a unit-test environment and only exists to satisfy the compiler. + */ + @Test + public void getFieldValueWrapsNoSuchFieldExceptionAsVersionIncompatibilityError() throws Exception { + KindeOAuth2Configurer configurer = new KindeOAuth2Configurer(); + Method getFieldValue = KindeOAuth2Configurer.class.getDeclaredMethod( + "getFieldValue", Object.class, String.class); + getFieldValue.setAccessible(true); + + OAuth2ResourceServerConfigurer source = mock(OAuth2ResourceServerConfigurer.class); + + InvocationTargetException ite = assertThrows(InvocationTargetException.class, + () -> getFieldValue.invoke(configurer, source, "definitely-not-a-real-field")); + + Throwable wrapped = ite.getCause(); + assertInstanceOf(RuntimeException.class, wrapped); + assertTrue(wrapped.getMessage().contains("Expected field 'definitely-not-a-real-field'"), + "wrapper message must mention the missing field name; was: " + wrapped.getMessage()); + assertTrue(wrapped.getMessage().contains("Version incompatibility"), + "wrapper message must call out version incompatibility; was: " + wrapped.getMessage()); + assertInstanceOf(NoSuchFieldException.class, wrapped.getCause(), + "the underlying cause must be the original NoSuchFieldException"); + } + + // --- helpers --------------------------------------------------------------------- + + private void seedKindeProperties(ApplicationContext context, KindeOAuth2Properties kindeOAuth2Properties) { + Map kindePropsMap = new HashMap<>(); + kindePropsMap.put(KindeOAuth2Properties.class.getName(), kindeOAuth2Properties); + when(context.getBeansOfType(KindeOAuth2Properties.class)).thenReturn(kindePropsMap); when(context.getBean(KindeOAuth2Properties.class)).thenReturn(kindeOAuth2Properties); - OAuth2ClientProperties oAuth2ClientProperties = Mockito.mock(OAuth2ClientProperties.class); - when(context.getBean(OAuth2ClientProperties.class)).thenReturn(oAuth2ClientProperties); - Map oauth2ClientMap = new HashMap<>(); - oauth2ClientMap.put("kinde",oAuth2ClientProperties); - - when(context.getBeansOfType(OAuth2ClientProperties.class)).thenReturn(oauth2ClientMap); - Map providers = new HashMap<>(); - OAuth2ClientProperties.Provider propertiesProvider = Mockito.mock(OAuth2ClientProperties.Provider.class); - providers.put("kinde",propertiesProvider); - when(oAuth2ClientProperties.getProvider()).thenReturn(providers); - OAuth2ClientProperties.Registration propertiesRegistration = Mockito.mock(OAuth2ClientProperties.Registration.class); - Map registration = new HashMap<>(); - registration.put("kinde",propertiesRegistration); - when(oAuth2ClientProperties.getRegistration()).thenReturn(registration); - when(propertiesProvider.getIssuerUri()).thenReturn("http://kinde.com"); - when(propertiesRegistration.getClientId()).thenReturn("test"); - - Environment environment = Mockito.mock(Environment.class); - when(context.getEnvironment()).thenReturn(environment); - - kindeOAuth2Configurer.init(httpSecurity); - - OidcClientInitiatedLogoutSuccessHandler oidcClientInitiatedLogoutSuccessHandler = Mockito.mock(OidcClientInitiatedLogoutSuccessHandler.class); - Map oidcClientInitiatedLogoutSuccessHandlerHashMap = new HashMap<>(); - oidcClientInitiatedLogoutSuccessHandlerHashMap.put("kinde",oidcClientInitiatedLogoutSuccessHandler); - when(context.getBeansOfType(OidcClientInitiatedLogoutSuccessHandler.class)).thenReturn(oidcClientInitiatedLogoutSuccessHandlerHashMap); - LogoutConfigurer logoutConfigurer = Mockito.mock(LogoutConfigurer.class); - when(httpSecurity.logout()).thenReturn(logoutConfigurer); - when(logoutConfigurer.logoutSuccessHandler(any())).thenReturn(logoutConfigurer); - kindeOAuth2Configurer.init(httpSecurity); + } + + /** + * Sets a (possibly inherited) private field via reflection. Used to plant a mock value on + * the {@code jwtConfigurer} field of {@link OAuth2ResourceServerConfigurer} so the + * configurer's reflection-based lookup finds it and routes into the JWT branch. + */ + @SuppressWarnings("SameParameterValue") + private static void setReflectionField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = OAuth2ResourceServerConfigurer.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("Failed to set " + fieldName + " via reflection", e); + } + } + /** + * Wires up all the Mockito {@link org.mockito.stubbing.Answer}s required to walk the + * {@code init()} happy path: + *

    + *
  • KindeOAuth2Properties bean + getPermissionsClaim()
  • + *
  • OAuth2ClientProperties bean with a valid {@code kinde} provider/registration
  • + *
  • {@code http.oauth2Login(...)} invokes its customizer against a mocked + * {@link OAuth2LoginConfigurer}, which in turn invokes its inner customizers for + * {@code tokenEndpoint} and {@code redirectionEndpoint}
  • + *
  • {@code http.logout(...)} invokes its customizer against a mocked + * {@link LogoutConfigurer}
  • + *
  • {@code http.oauth2ResourceServer(...)} invokes its customizer against a mocked + * {@link OAuth2ResourceServerConfigurer} (and its nested jwt() customizer)
  • + *
  • No logout handler bean is present and no resource-server configurer is wired, + * so individual tests can opt-in to those branches
  • + *
+ */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private Fixture happyPathFixture() { + Fixture fx = new Fixture(); + fx.httpSecurity = mock(HttpSecurity.class); + fx.context = mock(ApplicationContext.class); + fx.environment = mock(Environment.class); + when(fx.httpSecurity.getSharedObject(ApplicationContext.class)).thenReturn(fx.context); + when(fx.context.getEnvironment()).thenReturn(fx.environment); + fx.kindeProperties = mock(KindeOAuth2Properties.class); + when(fx.kindeProperties.getPermissionsClaim()).thenReturn("permissions"); + when(fx.kindeProperties.getProxy()).thenReturn(null); + seedKindeProperties(fx.context, fx.kindeProperties); + + fx.oauth2ClientProperties = mock(OAuth2ClientProperties.class); + fx.provider = mock(OAuth2ClientProperties.Provider.class); + when(fx.provider.getIssuerUri()).thenReturn(VALID_ISSUER); + Map providerMap = new HashMap<>(); + providerMap.put("kinde", fx.provider); + when(fx.oauth2ClientProperties.getProvider()).thenReturn(providerMap); + + fx.registration = mock(OAuth2ClientProperties.Registration.class); + when(fx.registration.getClientId()).thenReturn(VALID_CLIENT_ID); + Map registrationMap = new HashMap<>(); + registrationMap.put("kinde", fx.registration); + when(fx.oauth2ClientProperties.getRegistration()).thenReturn(registrationMap); + + Map oauth2ClientPropsMap = new HashMap<>(); + oauth2ClientPropsMap.put("oauth2ClientProperties", fx.oauth2ClientProperties); + when(fx.context.getBeansOfType(OAuth2ClientProperties.class)).thenReturn(oauth2ClientPropsMap); + when(fx.context.getBean(OAuth2ClientProperties.class)).thenReturn(fx.oauth2ClientProperties); + + // Default: no RP-Initiated logout handler bean configured. + when(fx.context.getBeansOfType(OidcClientInitiatedLogoutSuccessHandler.class)) + .thenReturn(Collections.emptyMap()); + + // Default: no resource-server configurer wired, so the JWT/Opaque branches are skipped. + when(fx.httpSecurity.getConfigurer(OAuth2ResourceServerConfigurer.class)).thenReturn(null); + + // --- oauth2Login customizer drives tokenEndpoint(...) and (optionally) redirectionEndpoint(...). + fx.oauth2LoginConfigurer = mock(OAuth2LoginConfigurer.class); + fx.tokenEndpoint = mock(OAuth2LoginConfigurer.TokenEndpointConfig.class); + fx.redirectionEndpoint = mock(OAuth2LoginConfigurer.RedirectionEndpointConfig.class); + when(fx.oauth2LoginConfigurer.tokenEndpoint(any())).thenAnswer(inv -> { + Customizer inner = inv.getArgument(0); + inner.customize(fx.tokenEndpoint); + return fx.oauth2LoginConfigurer; + }); + when(fx.tokenEndpoint.accessTokenResponseClient(any())).thenReturn(fx.tokenEndpoint); + when(fx.oauth2LoginConfigurer.redirectionEndpoint(any())).thenAnswer(inv -> { + Customizer inner = inv.getArgument(0); + inner.customize(fx.redirectionEndpoint); + return fx.oauth2LoginConfigurer; + }); + when(fx.redirectionEndpoint.baseUri(any())).thenReturn(fx.redirectionEndpoint); + when(fx.httpSecurity.oauth2Login(any())).thenAnswer(inv -> { + Customizer> customizer = inv.getArgument(0); + customizer.customize(fx.oauth2LoginConfigurer); + return fx.httpSecurity; + }); + + // --- logout customizer drives logoutSuccessHandler(...) + fx.logoutConfigurer = mock(LogoutConfigurer.class); + when(fx.logoutConfigurer.logoutSuccessHandler(any())).thenReturn(fx.logoutConfigurer); + when(fx.httpSecurity.logout(any())).thenAnswer(inv -> { + Customizer> customizer = inv.getArgument(0); + customizer.customize(fx.logoutConfigurer); + return fx.httpSecurity; + }); + + // --- oauth2ResourceServer customizer just no-ops; we only need the outer call to be recorded + // so the JWT-branch verify() succeeds. The nested jwt(...) customizer is not invoked to + // avoid having to mock OAuth2ResourceServerConfigurer.JwtConfigurer too. + when(fx.httpSecurity.oauth2ResourceServer(any())).thenReturn(fx.httpSecurity); + + return fx; + } + /** Bag of mocks/state used by happy-path-style tests. */ + private static final class Fixture { + HttpSecurity httpSecurity; + ApplicationContext context; + Environment environment; + KindeOAuth2Properties kindeProperties; + OAuth2ClientProperties oauth2ClientProperties; + OAuth2ClientProperties.Provider provider; + OAuth2ClientProperties.Registration registration; + OAuth2LoginConfigurer oauth2LoginConfigurer; + OAuth2LoginConfigurer.TokenEndpointConfig tokenEndpoint; + OAuth2LoginConfigurer.RedirectionEndpointConfig redirectionEndpoint; + LogoutConfigurer logoutConfigurer; } } diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2ResourceServerAutoConfigTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2ResourceServerAutoConfigTest.java index 90bc1107..10910ac9 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2ResourceServerAutoConfigTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2ResourceServerAutoConfigTest.java @@ -2,17 +2,39 @@ import com.kinde.spring.config.KindeOAuth2Properties; import com.kinde.spring.env.KindeOAuth2PropertiesMappingEnvironmentPostProcessor; -import com.kinde.spring.sdk.KindeSdkClient; +import com.kinde.spring.http.ProxyBasicAuthenticationInterceptor; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerProperties; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.when; @TestPropertySource(properties = { @@ -30,9 +52,7 @@ public class KindeOAuth2ResourceServerAutoConfigTest { public static class MyTestConfig { @Bean public KindeOAuth2ResourceServerAutoConfig kindeOAuth2ResourceServerAutoConfig() { - System.out.println("Hello 3"); - KindeOAuth2ResourceServerAutoConfig kindeOAuth2ResourceServerAutoConfig = new KindeOAuth2ResourceServerAutoConfig(); - return kindeOAuth2ResourceServerAutoConfig; + return new KindeOAuth2ResourceServerAutoConfig(); } } @@ -59,4 +79,216 @@ public void jwtDecoder() { kindeOAuth2ResourceServerAutoConfig.jwtDecoder(oAuth2ResourceServerProperties,kindeOAuth2Properties); } + // --- restClient() / restTemplate() proxy-branch coverage ---------------------------------- + // + // These tests assert that the proxy host/port and (when present) the basic-auth credentials + // are actually wired into the produced RestClient / RestTemplate. Asserting non-null on the + // factory result alone would only confirm "no exception during construction" and would let a + // future regression that silently drops proxy.setProxy(...) or the auth interceptor go + // unnoticed. + // + // RestClient has no public accessor for its request factory or interceptors, so we walk the + // DefaultRestClient fields by type rather than by name (resilient to Spring-internal renames). + // For the proxy-auth interceptor we exercise it against a MockClientHttpRequest and assert on + // the resulting Proxy-Authorization header (RFC 7235 §4.4) instead of reflecting into the + // interceptor's internals. Proxy credentials must NOT be carried in the origin-server + // Authorization header (they would leak to the origin and the proxy would never see them). + + private static final String EXPECTED_PROXY_HOST = "proxy.example.com"; + private static final int EXPECTED_PROXY_PORT = 8080; + private static final String PROXY_USER = "proxy-user"; + private static final String PROXY_PASS = "proxy-pass"; + + @Test + public void restClientWithNoProxyConfigured() { + KindeOAuth2Properties props = Mockito.mock(KindeOAuth2Properties.class); + when(props.getProxy()).thenReturn(null); + + RestClient client = KindeOAuth2ResourceServerAutoConfig.restClient(props); + assertNotNull(client); + + assertNull(proxyOf(requestFactoryOf(client)), + "Expected no proxy on the request factory when properties#getProxy() is null"); + assertFalse(hasProxyAuthInterceptor(interceptorsOf(client)), + "Expected no ProxyBasicAuthenticationInterceptor when no proxy is configured"); + } + + @Test + public void restClientWithProxyHostAndPort() { + KindeOAuth2Properties props = propertiesWithProxy(EXPECTED_PROXY_HOST, EXPECTED_PROXY_PORT, "", ""); + + RestClient client = KindeOAuth2ResourceServerAutoConfig.restClient(props); + assertProxyAddress(proxyOf(requestFactoryOf(client))); + assertFalse(hasProxyAuthInterceptor(interceptorsOf(client)), + "Expected no ProxyBasicAuthenticationInterceptor when username/password are blank"); + } + + @Test + public void restClientWithAuthenticatedProxy() throws Exception { + KindeOAuth2Properties props = propertiesWithProxy(EXPECTED_PROXY_HOST, EXPECTED_PROXY_PORT, PROXY_USER, PROXY_PASS); + + RestClient client = KindeOAuth2ResourceServerAutoConfig.restClient(props); + assertProxyAddress(proxyOf(requestFactoryOf(client))); + assertProxyAuthInterceptorEmits(interceptorsOf(client), PROXY_USER, PROXY_PASS); + } + + @Test + public void restTemplateWithNoProxyConfigured() { + KindeOAuth2Properties props = Mockito.mock(KindeOAuth2Properties.class); + when(props.getProxy()).thenReturn(null); + + RestTemplate template = KindeOAuth2ResourceServerAutoConfig.restTemplate(props); + assertNull(proxyOf(requestFactoryOf(template)), + "Expected no proxy on the request factory when properties#getProxy() is null"); + assertFalse(hasProxyAuthInterceptor(template.getInterceptors()), + "Expected no ProxyBasicAuthenticationInterceptor when no proxy is configured"); + } + + @Test + public void restTemplateWithProxyHostAndPort() { + KindeOAuth2Properties props = propertiesWithProxy(EXPECTED_PROXY_HOST, EXPECTED_PROXY_PORT, "", ""); + + RestTemplate template = KindeOAuth2ResourceServerAutoConfig.restTemplate(props); + assertProxyAddress(proxyOf(requestFactoryOf(template))); + assertFalse(hasProxyAuthInterceptor(template.getInterceptors()), + "Expected no ProxyBasicAuthenticationInterceptor when username/password are blank"); + } + + @Test + public void restTemplateWithAuthenticatedProxy() throws Exception { + KindeOAuth2Properties props = propertiesWithProxy(EXPECTED_PROXY_HOST, EXPECTED_PROXY_PORT, PROXY_USER, PROXY_PASS); + + RestTemplate template = KindeOAuth2ResourceServerAutoConfig.restTemplate(props); + assertProxyAddress(proxyOf(requestFactoryOf(template))); + assertProxyAuthInterceptorEmits(template.getInterceptors(), PROXY_USER, PROXY_PASS); + } + + // Regression: previously the proxy block called proxyProperties.getUsername().trim() / + // getPassword().trim() unconditionally. Spring binds unset @ConfigurationProperties fields + // to null, so configuring `kinde.oauth2.proxy.host`/`.port` without credentials would NPE + // during bean initialisation. The two tests below pin that null credentials are treated the + // same as blank ones: the proxy address is honoured, no auth interceptor is installed, + // and nothing throws. + + @Test + public void restClientWithProxyHostAndNullCredentials() { + KindeOAuth2Properties props = propertiesWithProxy(EXPECTED_PROXY_HOST, EXPECTED_PROXY_PORT, null, null); + + RestClient client = KindeOAuth2ResourceServerAutoConfig.restClient(props); + assertProxyAddress(proxyOf(requestFactoryOf(client))); + assertFalse(hasProxyAuthInterceptor(interceptorsOf(client)), + "Expected no ProxyBasicAuthenticationInterceptor when username/password are null"); + } + + @Test + public void restTemplateWithProxyHostAndNullCredentials() { + KindeOAuth2Properties props = propertiesWithProxy(EXPECTED_PROXY_HOST, EXPECTED_PROXY_PORT, null, null); + + RestTemplate template = KindeOAuth2ResourceServerAutoConfig.restTemplate(props); + assertProxyAddress(proxyOf(requestFactoryOf(template))); + assertFalse(hasProxyAuthInterceptor(template.getInterceptors()), + "Expected no ProxyBasicAuthenticationInterceptor when username/password are null"); + } + + // --- helpers --------------------------------------------------------------------------------- + + private static KindeOAuth2Properties propertiesWithProxy(String host, int port, String user, String pass) { + KindeOAuth2Properties.Proxy proxyProps = new KindeOAuth2Properties.Proxy(); + proxyProps.setHost(host); + proxyProps.setPort(port); + proxyProps.setUsername(user); + proxyProps.setPassword(pass); + + KindeOAuth2Properties props = Mockito.mock(KindeOAuth2Properties.class); + when(props.getProxy()).thenReturn(proxyProps); + return props; + } + + private static Proxy proxyOf(SimpleClientHttpRequestFactory factory) { + return (Proxy) ReflectionTestUtils.getField(factory, "proxy"); + } + + private static void assertProxyAddress(Proxy proxy) { + assertNotNull(proxy, "Expected proxy to be set on the request factory"); + assertEquals(Proxy.Type.HTTP, proxy.type(), "Expected an HTTP-typed proxy"); + assertEquals(new InetSocketAddress(EXPECTED_PROXY_HOST, EXPECTED_PROXY_PORT), proxy.address(), + "Expected proxy address to match the configured host/port"); + } + + private static boolean hasProxyAuthInterceptor(List interceptors) { + return interceptors.stream().anyMatch(ProxyBasicAuthenticationInterceptor.class::isInstance); + } + + private static void assertProxyAuthInterceptorEmits( + List interceptors, String user, String pass) throws Exception { + ClientHttpRequestInterceptor auth = interceptors.stream() + .filter(ProxyBasicAuthenticationInterceptor.class::isInstance) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Expected a ProxyBasicAuthenticationInterceptor when proxy credentials are configured")); + + MockClientHttpRequest outgoing = new MockClientHttpRequest(HttpMethod.GET, URI.create("https://example.test")); + auth.intercept(outgoing, new byte[0], + (req, body) -> new MockClientHttpResponse(new byte[0], HttpStatus.OK)); + + String expected = "Basic " + Base64.getEncoder() + .encodeToString((user + ":" + pass).getBytes(StandardCharsets.UTF_8)); + assertEquals(expected, outgoing.getHeaders().getFirst(HttpHeaders.PROXY_AUTHORIZATION), + "ProxyBasicAuthenticationInterceptor must emit Proxy-Authorization header for the configured credentials"); + assertNull(outgoing.getHeaders().getFirst(HttpHeaders.AUTHORIZATION), + "Proxy credentials must not leak into the origin-server Authorization header"); + } + + /** + * Returns the {@link SimpleClientHttpRequestFactory} that was passed to + * {@code RestTemplate#setRequestFactory}, bypassing the {@code InterceptingClientHttpRequestFactory} + * that {@link RestTemplate#getRequestFactory()} auto-wraps around it whenever interceptors are + * registered. The {@code requestFactory} field lives on {@code HttpAccessor} and has been stable + * across Spring Framework's public API for many releases. + */ + private static SimpleClientHttpRequestFactory requestFactoryOf(RestTemplate template) { + return (SimpleClientHttpRequestFactory) ReflectionTestUtils.getField(template, "requestFactory"); + } + + /** + * Walks {@code DefaultRestClient}'s declared fields and returns the first + * {@link SimpleClientHttpRequestFactory} found. RestClient has no public accessor for its + * request factory; matching by type keeps this resilient to Spring-internal field renames. + */ + private static SimpleClientHttpRequestFactory requestFactoryOf(RestClient client) { + for (Field field : client.getClass().getDeclaredFields()) { + field.setAccessible(true); + try { + Object value = field.get(client); + if (value instanceof SimpleClientHttpRequestFactory factory) { + return factory; + } + } catch (IllegalAccessException ignored) { + } + } + throw new AssertionError( + "No SimpleClientHttpRequestFactory field found on " + client.getClass().getName()); + } + + /** + * Walks {@code DefaultRestClient}'s declared fields and returns the first non-empty + * {@code List}. Same rationale as {@link #requestFactoryOf}. + */ + @SuppressWarnings("unchecked") + private static List interceptorsOf(RestClient client) { + for (Field field : client.getClass().getDeclaredFields()) { + field.setAccessible(true); + try { + Object value = field.get(client); + if (value instanceof List list + && !list.isEmpty() + && list.get(0) instanceof ClientHttpRequestInterceptor) { + return (List) list; + } + } catch (IllegalAccessException ignored) { + } + } + return List.of(); + } + } diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeTest.java index 4eb0e83c..75f1c6c9 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeTest.java @@ -2,49 +2,68 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; - -import java.util.Set; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +/** + * Spring Security 7 enforces the {@link Customizer}-based DSL on + * {@link HttpSecurity}/{@link ServerHttpSecurity}. The pre-upgrade tests stubbed the now-removed + * chained methods (e.g. {@code oauth2Login()}, {@code authorizationEndpoint()}); we now invoke the + * customizer that {@code Kinde#configureOAuth2WithPkce} passes in to drive the same configuration paths. + */ public class KindeTest { + @SuppressWarnings({"unchecked", "rawtypes"}) @Test public void testConfigureOAuth2WithPkce() throws Exception { HttpSecurity httpSecurity = Mockito.mock(HttpSecurity.class); ClientRegistrationRepository clientRegistrationRepository = Mockito.mock(ClientRegistrationRepository.class); OAuth2LoginConfigurer oAuth2LoginConfigurer = Mockito.mock(OAuth2LoginConfigurer.class); - OAuth2LoginConfigurer.AuthorizationEndpointConfig authorizationEndpointConfig = Mockito.mock(OAuth2LoginConfigurer.AuthorizationEndpointConfig.class); + OAuth2LoginConfigurer.AuthorizationEndpointConfig authorizationEndpointConfig = + Mockito.mock(OAuth2LoginConfigurer.AuthorizationEndpointConfig.class); - when(httpSecurity.oauth2Login()) - .thenReturn(oAuth2LoginConfigurer); - when(oAuth2LoginConfigurer.authorizationEndpoint()) - .thenReturn(authorizationEndpointConfig); - when(authorizationEndpointConfig.authorizationRequestResolver(any())) - .thenReturn(authorizationEndpointConfig); + when(httpSecurity.oauth2Login(any(Customizer.class))).thenAnswer(invocation -> { + Customizer> customizer = invocation.getArgument(0); + customizer.customize(oAuth2LoginConfigurer); + return httpSecurity; + }); + when(oAuth2LoginConfigurer.authorizationEndpoint(any(Customizer.class))).thenAnswer(invocation -> { + Customizer customizer = invocation.getArgument(0); + customizer.customize(authorizationEndpointConfig); + return oAuth2LoginConfigurer; + }); + when(authorizationEndpointConfig.authorizationRequestResolver(any())).thenReturn(authorizationEndpointConfig); Kinde.configureOAuth2WithPkce(httpSecurity, clientRegistrationRepository); + + verify(authorizationEndpointConfig).authorizationRequestResolver(any()); } + @SuppressWarnings("unchecked") @Test - public void testConfigureOAuth2WithPkceServerSecurity() throws Exception { + public void testConfigureOAuth2WithPkceServerSecurity() { ServerHttpSecurity serverHttpSecurity = Mockito.mock(ServerHttpSecurity.class); - ReactiveClientRegistrationRepository reactiveClientRegistrationRepository = Mockito.mock(ReactiveClientRegistrationRepository.class); + ReactiveClientRegistrationRepository reactiveClientRegistrationRepository = + Mockito.mock(ReactiveClientRegistrationRepository.class); ServerHttpSecurity.OAuth2LoginSpec oAuth2LoginSpec = Mockito.mock(ServerHttpSecurity.OAuth2LoginSpec.class); - when(serverHttpSecurity.oauth2Login()) - .thenReturn(oAuth2LoginSpec); - when(oAuth2LoginSpec.authorizationRequestResolver(any())) - .thenReturn(oAuth2LoginSpec); - Kinde.configureOAuth2WithPkce(serverHttpSecurity, reactiveClientRegistrationRepository); - } + when(serverHttpSecurity.oauth2Login(any(Customizer.class))).thenAnswer(invocation -> { + Customizer customizer = invocation.getArgument(0); + customizer.customize(oAuth2LoginSpec); + return serverHttpSecurity; + }); + when(oAuth2LoginSpec.authorizationRequestResolver(any())).thenReturn(oAuth2LoginSpec); + Kinde.configureOAuth2WithPkce(serverHttpSecurity, reactiveClientRegistrationRepository); + verify(oAuth2LoginSpec).authorizationRequestResolver(any()); + } } diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2AutoConfigTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2AutoConfigTest.java index d1632a7f..6cdd1026 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2AutoConfigTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2AutoConfigTest.java @@ -1,64 +1,154 @@ package com.kinde.spring; - import com.kinde.spring.config.KindeOAuth2Properties; import com.kinde.spring.env.KindeOAuth2PropertiesMappingEnvironmentPostProcessor; import com.kinde.spring.sdk.KindeSdkClient; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.test.context.TestPropertySource; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + @TestPropertySource(properties = { - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_DOMAIN + "=https://test.kinde.com" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_ID + "=test_client_id" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_SECRET + "=test_client_secret" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_SCOPES + "=profile" , + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_DOMAIN + "=https://test.kinde.com", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_ID + "=test_client_id", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_SECRET + "=test_client_secret", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_SCOPES + "=profile", "spring.security.oauth2.client.registration.kinde.client-id=test_client_id" }) @SpringBootTest(classes = {ReactiveKindeOAuth2AutoConfigTest.class}) @Import(ReactiveKindeOAuth2AutoConfigTest.MyTestConfig.class) public class ReactiveKindeOAuth2AutoConfigTest { + @TestConfiguration public static class MyTestConfig { @Bean public ReactiveKindeOAuth2AutoConfig reactiveKindeOAuth2AutoConfig() { - System.out.println("Hello 3"); - ReactiveKindeOAuth2AutoConfig reactiveKindeOAuth2AutoConfig = new ReactiveKindeOAuth2AutoConfig(); - return reactiveKindeOAuth2AutoConfig; + return new ReactiveKindeOAuth2AutoConfig(); } @Bean public KindeSdkClient kindeSdkClient() { - return Mockito.mock(KindeSdkClient.class); + return mock(KindeSdkClient.class); } + /** + * Required because the real {@code KindeSdkClient} autoconfig in the classpath autowires + * {@link KindeOAuth2Properties}; without a bean of that type, context refresh fails. + */ @Bean public KindeOAuth2Properties kindeOAuth2Properties() { - return Mockito.mock(KindeOAuth2Properties.class); + return mock(KindeOAuth2Properties.class); } } @Autowired - private ReactiveKindeOAuth2AutoConfig reactiveKindeOAuth2AutoConfig; - + private ReactiveKindeOAuth2AutoConfig autoConfig; @Test - public void testOauth2UserService() { - reactiveKindeOAuth2AutoConfig.oauth2UserService(List.of()); + public void oauth2UserServiceProducesReactiveKindeUserService() { + ReactiveOAuth2UserService service = autoConfig.oauth2UserService(List.of()); + + assertNotNull(service); + assertInstanceOf(ReactiveKindeOAuth2UserService.class, service); } @Test - public void testOidcUserService() { - ReactiveOAuth2UserService reactiveOAuth2UserService = Mockito.mock(ReactiveOAuth2UserService.class); - reactiveKindeOAuth2AutoConfig.oidcUserService(List.of(),reactiveOAuth2UserService); + public void oidcUserServiceProducesReactiveKindeOidcUserService() { + ReactiveOAuth2UserService oauth2UserService = autoConfig.oauth2UserService(List.of()); + + OidcReactiveOAuth2UserService oidcUserService = autoConfig.oidcUserService(List.of(), oauth2UserService); + + assertNotNull(oidcUserService); + assertInstanceOf(ReactiveKindeOidcUserService.class, oidcUserService); } + /** + * Drives the reactive equivalent of the servlet filter-chain factory by mocking + * {@link ServerHttpSecurity}. The {@code authorizeExchange} customizer fires through a + * Mockito {@code Answer} so the {@code exchanges.anyExchange().authenticated()} chain + * executes; the {@code oauth2ResourceServer} customizer fires too so {@code customDecoder()} + * gets exercised end-to-end. + */ + @Test + public void springSecurityFilterChainBuildsTheReactiveChainAndDelegatesToTheDsl() { + ServerHttpSecurity http = mock(ServerHttpSecurity.class); + SecurityWebFilterChain expectedChain = mock(SecurityWebFilterChain.class); + when(http.build()).thenReturn(expectedChain); + + // authorizeExchange(...) -> drive the inner exchanges.anyExchange().authenticated() + ServerHttpSecurity.AuthorizeExchangeSpec authorizeSpec = + mock(ServerHttpSecurity.AuthorizeExchangeSpec.class); + ServerHttpSecurity.AuthorizeExchangeSpec.Access access = + mock(ServerHttpSecurity.AuthorizeExchangeSpec.Access.class); + when(authorizeSpec.anyExchange()).thenReturn(access); + when(access.authenticated()).thenReturn(authorizeSpec); + when(http.authorizeExchange(any())).thenAnswer(invocation -> { + Customizer customizer = invocation.getArgument(0); + customizer.customize(authorizeSpec); + return http; + }); + + // oauth2Login is invoked by Kinde.configureOAuth2WithPkce; oauth2Client by Customizer.withDefaults(). + when(http.oauth2Login(any())).thenReturn(http); + when(http.oauth2Client(any())).thenReturn(http); + + // oauth2ResourceServer(...) -> drive the inner customDecoder which calls server.jwt(...) + // and the nested jwt -> jwt.jwtDecoder(decoder) customizer so we can verify the configured + // ReactiveJwtDecoder is the one actually installed onto the JwtSpec. + ServerHttpSecurity.OAuth2ResourceServerSpec resourceServerSpec = + mock(ServerHttpSecurity.OAuth2ResourceServerSpec.class); + ServerHttpSecurity.OAuth2ResourceServerSpec.JwtSpec jwtSpec = + mock(ServerHttpSecurity.OAuth2ResourceServerSpec.JwtSpec.class); + when(jwtSpec.jwtDecoder(any())).thenReturn(jwtSpec); + when(resourceServerSpec.jwt(any())).thenAnswer(invocation -> { + Customizer inner = invocation.getArgument(0); + inner.customize(jwtSpec); + return resourceServerSpec; + }); + when(http.oauth2ResourceServer(any())).thenAnswer(invocation -> { + Customizer customizer = invocation.getArgument(0); + customizer.customize(resourceServerSpec); + return http; + }); + + ReactiveJwtDecoder jwtDecoder = mock(ReactiveJwtDecoder.class); + ReactiveClientRegistrationRepository repo = mock(ReactiveClientRegistrationRepository.class); + + SecurityWebFilterChain chain = autoConfig.springSecurityFilterChain(http, jwtDecoder, repo); + + assertSame(expectedChain, chain, "should return the chain produced by http.build()"); + verify(http).authorizeExchange(any()); + verify(authorizeSpec).anyExchange(); + verify(access).authenticated(); + // Kinde.configureOAuth2WithPkce calls oauth2Login(...) once. + verify(http, atLeastOnce()).oauth2Login(any()); + verify(http).oauth2Client(any()); + verify(http).oauth2ResourceServer(any()); + verify(resourceServerSpec).jwt(any()); + verify(jwtSpec).jwtDecoder(jwtDecoder); + verify(http).build(); + } } diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerAutoConfigTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerAutoConfigTest.java index c9fedaa4..d3684d03 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerAutoConfigTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerAutoConfigTest.java @@ -7,16 +7,13 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerProperties; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; import org.springframework.test.context.TestPropertySource; -import java.util.List; - import static org.mockito.Mockito.when; @TestPropertySource(properties = { @@ -33,9 +30,7 @@ public class ReactiveKindeOAuth2ResourceServerAutoConfigTest { public static class MyTestConfig { @Bean public ReactiveKindeOAuth2ResourceServerAutoConfig reactiveKindeOAuth2AutoConfig() { - System.out.println("Hello 3"); - ReactiveKindeOAuth2ResourceServerAutoConfig reactiveKindeOAuth2ResourceServerAutoConfig = new ReactiveKindeOAuth2ResourceServerAutoConfig(); - return reactiveKindeOAuth2ResourceServerAutoConfig; + return new ReactiveKindeOAuth2ResourceServerAutoConfig(); } @Bean diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerHttpServerAutoConfigTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerHttpServerAutoConfigTest.java index d05fb368..7554b385 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerHttpServerAutoConfigTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ResourceServerHttpServerAutoConfigTest.java @@ -2,43 +2,109 @@ import com.kinde.spring.config.KindeOAuth2Properties; import com.kinde.spring.env.KindeOAuth2PropertiesMappingEnvironmentPostProcessor; -import com.kinde.spring.sdk.KindeSdkClient; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.test.context.TestPropertySource; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + @TestPropertySource(properties = { - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_DOMAIN + "=https://test.kinde.com" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_ID + "=test_client_id" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_SECRET + "=test_client_secret" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_SCOPES + "=profile" , + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_DOMAIN + "=https://test.kinde.com", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_ID + "=test_client_id", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_SECRET + "=test_client_secret", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_SCOPES + "=profile", "spring.security.oauth2.client.registration.kinde.client-id=test_client_id" }) @SpringBootTest(classes = {ReactiveKindeOAuth2ResourceServerHttpServerAutoConfigTest.class}) @Import(ReactiveKindeOAuth2ResourceServerHttpServerAutoConfigTest.MyTestConfig.class) public class ReactiveKindeOAuth2ResourceServerHttpServerAutoConfigTest { + @TestConfiguration public static class MyTestConfig { @Bean - public ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig reactiveKindeOAuth2ResourceServerHttpServerAutoConfig() { - System.out.println("Hello 3"); - ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig reactiveKindeOAuth2ResourceServerAutoConfig = - new ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig(); - return reactiveKindeOAuth2ResourceServerAutoConfig; + public ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig reactiveAutoConfig() { + return new ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig(); } } @Autowired - private ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig reactiveKindeOAuth2ResourceServerHttpServerAutoConfig; + private ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig autoConfig; + + @Test + public void kindeOAuth2ResourceServerBeanPostProcessorReturnsNonNullProcessor() { + KindeOAuth2Properties props = mock(KindeOAuth2Properties.class); + + BeanPostProcessor processor = autoConfig.kindeOAuth2ResourceServerBeanPostProcessor(props); + + assertNotNull(processor); + } + + /** + * The post-processor wires JWT validation onto every {@link ServerHttpSecurity} bean Spring + * surfaces. We invoke {@code postProcessAfterInitialization} directly on a mocked + * {@link ServerHttpSecurity}, driving the {@code oauth2ResourceServer} and nested {@code jwt} + * customizer lambdas through Mockito {@code Answer}s so the {@code jwtAuthenticationConverter} + * call inside the lambda body actually executes under JaCoCo. + */ + @Test + public void postProcessAfterInitializationWiresJwtAuthenticationConverter() { + KindeOAuth2Properties props = mock(KindeOAuth2Properties.class); + when(props.getPermissionsClaim()).thenReturn("permissions"); + + ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig.KindeOAuth2ResourceServerBeanPostProcessor processor = + new ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig.KindeOAuth2ResourceServerBeanPostProcessor(props); + + ServerHttpSecurity http = mock(ServerHttpSecurity.class); + ServerHttpSecurity.OAuth2ResourceServerSpec resourceServerSpec = + mock(ServerHttpSecurity.OAuth2ResourceServerSpec.class); + ServerHttpSecurity.OAuth2ResourceServerSpec.JwtSpec jwtSpec = + mock(ServerHttpSecurity.OAuth2ResourceServerSpec.JwtSpec.class); + when(jwtSpec.jwtAuthenticationConverter(any())).thenReturn(jwtSpec); + when(resourceServerSpec.jwt(any())).thenAnswer(invocation -> { + Customizer inner = invocation.getArgument(0); + inner.customize(jwtSpec); + return resourceServerSpec; + }); + when(http.oauth2ResourceServer(any())).thenAnswer(invocation -> { + Customizer outer = invocation.getArgument(0); + outer.customize(resourceServerSpec); + return http; + }); + + Object result = processor.postProcessAfterInitialization(http, "anyBeanName"); + + assertSame(http, result, "postProcessAfterInitialization should return the input bean"); + verify(http).oauth2ResourceServer(any()); + verify(resourceServerSpec).jwt(any()); + verify(jwtSpec).jwtAuthenticationConverter(any()); + verify(props).getPermissionsClaim(); + } @Test - public void testKindeOAuth2ResourceServerBeanPostProcessor() { - KindeOAuth2Properties kindeOAuth2Properties = Mockito.mock(KindeOAuth2Properties.class); - reactiveKindeOAuth2ResourceServerHttpServerAutoConfig.kindeOAuth2ResourceServerBeanPostProcessor(kindeOAuth2Properties); + public void postProcessAfterInitializationLeavesNonHttpBeansUntouched() { + KindeOAuth2Properties props = mock(KindeOAuth2Properties.class); + + ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig.KindeOAuth2ResourceServerBeanPostProcessor processor = + new ReactiveKindeOAuth2ResourceServerHttpServerAutoConfig.KindeOAuth2ResourceServerBeanPostProcessor(props); + + Object input = "not-a-server-http-security"; + + Object result = processor.postProcessAfterInitialization(input, "someBean"); + + assertSame(input, result); + verify(props, never()).getPermissionsClaim(); } } diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ServerHttpServerAutoConfigTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ServerHttpServerAutoConfigTest.java index 663e13b8..f6606262 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ServerHttpServerAutoConfigTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/ReactiveKindeOAuth2ServerHttpServerAutoConfigTest.java @@ -3,25 +3,40 @@ import com.kinde.spring.config.KindeOAuth2Properties; import com.kinde.spring.env.KindeOAuth2PropertiesMappingEnvironmentPostProcessor; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; import org.springframework.security.oauth2.client.oidc.web.server.logout.OidcClientInitiatedServerLogoutSuccessHandler; import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.test.context.TestPropertySource; +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @TestPropertySource(properties = { - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_DOMAIN + "=https://test.kinde.com" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_ID + "=test_client_id" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_SECRET + "=test_client_secret" , - KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_SCOPES + "=profile" , + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_DOMAIN + "=https://test.kinde.com", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_ID + "=test_client_id", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_CLIENT_SECRET + "=test_client_secret", + KindeOAuth2PropertiesMappingEnvironmentPostProcessor.KINDE_OAUTH_SCOPES + "=profile", "spring.security.oauth2.client.registration.kinde.client-id=test_client_id" }) @SpringBootTest(classes = {ReactiveKindeOAuth2ServerHttpServerAutoConfigTest.class}) @@ -31,35 +46,183 @@ public class ReactiveKindeOAuth2ServerHttpServerAutoConfigTest { @TestConfiguration public static class MyTestConfig { @Bean - public ReactiveKindeOAuth2ServerHttpServerAutoConfig reactiveKindeOAuth2ServerHttpServerAutoConfig() { - System.out.println("Hello 3"); - ReactiveKindeOAuth2ServerHttpServerAutoConfig reactiveKindeOAuth2ResourceServerHttpServerAutoConfig = - new ReactiveKindeOAuth2ServerHttpServerAutoConfig(); - return reactiveKindeOAuth2ResourceServerHttpServerAutoConfig; + public ReactiveKindeOAuth2ServerHttpServerAutoConfig reactiveAutoConfig() { + return new ReactiveKindeOAuth2ServerHttpServerAutoConfig(); } } @Autowired - private ReactiveKindeOAuth2ServerHttpServerAutoConfig reactiveKindeOAuth2ResourceServerHttpServerAutoConfig; + private ReactiveKindeOAuth2ServerHttpServerAutoConfig autoConfig; + + // --- Bean factory smoke tests -------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + public void authManagerServerHttpSecurityBeanPostProcessorReturnsNonNullProcessor() { + ReactiveOAuth2UserService oAuth2UserService = mock(ReactiveOAuth2UserService.class); + OidcReactiveOAuth2UserService oidcUserService = mock(OidcReactiveOAuth2UserService.class); + OidcClientInitiatedServerLogoutSuccessHandler logoutHandler = mock(OidcClientInitiatedServerLogoutSuccessHandler.class); + + BeanPostProcessor processor = autoConfig.authManagerServerHttpSecurityBeanPostProcessor( + oAuth2UserService, oidcUserService, logoutHandler); + + assertNotNull(processor); + } + + @Test + @SuppressWarnings("unchecked") + public void authManagerServerHttpSecurityBeanPostProcessorAcceptsNullLogoutHandler() { + ReactiveOAuth2UserService oAuth2UserService = mock(ReactiveOAuth2UserService.class); + OidcReactiveOAuth2UserService oidcUserService = mock(OidcReactiveOAuth2UserService.class); + + BeanPostProcessor processor = autoConfig.authManagerServerHttpSecurityBeanPostProcessor( + oAuth2UserService, oidcUserService, null); + + assertNotNull(processor, "logout handler is @Autowired(required=false) so null must be tolerated"); + } + + // --- oidcClientInitiatedServerLogoutSuccessHandler ternary branches ------------------------ @Test - public void testAuthManagerServerHttpSecurityBeanPostProcessor() { - ReactiveOAuth2UserService reactiveOAuth2UserService = Mockito.mock(ReactiveOAuth2UserService.class); - OidcReactiveOAuth2UserService oidcReactiveOAuth2UserService = Mockito.mock(OidcReactiveOAuth2UserService.class); - OidcClientInitiatedServerLogoutSuccessHandler oidcClientInitiatedServerLogoutSuccessHandler = - Mockito.mock(OidcClientInitiatedServerLogoutSuccessHandler.class); - reactiveKindeOAuth2ResourceServerHttpServerAutoConfig.authManagerServerHttpSecurityBeanPostProcessor( - reactiveOAuth2UserService,oidcReactiveOAuth2UserService,oidcClientInitiatedServerLogoutSuccessHandler); + public void oidcLogoutSuccessHandlerWithAbsoluteUriDoesNotPrependBaseUrl() throws Exception { + KindeOAuth2Properties props = mock(KindeOAuth2Properties.class); + when(props.getPostLogoutRedirectUri()).thenReturn("https://app.kinde.com/post-logout"); + ReactiveClientRegistrationRepository repo = mock(ReactiveClientRegistrationRepository.class); + + OidcClientInitiatedServerLogoutSuccessHandler handler = + autoConfig.oidcClientInitiatedServerLogoutSuccessHandler(props, repo); + + assertNotNull(handler); + assertEquals("https://app.kinde.com/post-logout", postLogoutRedirectUriOf(handler)); } @Test - public void testOidcClientInitiatedServerLogoutSuccessHandler() throws Exception { - KindeOAuth2Properties kindeOAuth2Properties = Mockito.mock(KindeOAuth2Properties.class); - ReactiveClientRegistrationRepository repository = Mockito.mock(ReactiveClientRegistrationRepository.class); - when(kindeOAuth2Properties.getPostLogoutRedirectUri()).thenReturn("https://kinde.com"); - reactiveKindeOAuth2ResourceServerHttpServerAutoConfig.oidcClientInitiatedServerLogoutSuccessHandler( - kindeOAuth2Properties,repository); + public void oidcLogoutSuccessHandlerWithRelativePathPrependsBaseUrlPlaceholder() throws Exception { + KindeOAuth2Properties props = mock(KindeOAuth2Properties.class); + when(props.getPostLogoutRedirectUri()).thenReturn("/post-logout"); + ReactiveClientRegistrationRepository repo = mock(ReactiveClientRegistrationRepository.class); + + OidcClientInitiatedServerLogoutSuccessHandler handler = + autoConfig.oidcClientInitiatedServerLogoutSuccessHandler(props, repo); + + assertNotNull(handler); + assertEquals("{baseUrl}/post-logout", postLogoutRedirectUriOf(handler)); } + // --- KindeOAuth2LoginServerBeanPostProcessor.postProcessAfterInitialization --------------- + + /** + * Drives {@code postProcessAfterInitialization} against a mocked {@link ServerHttpSecurity} + * with a non-null logout handler. The {@code oauth2Login} customizer fires through a Mockito + * {@code Answer} so the call site that builds the static {@code reactiveAuthenticationManager(...)} + * helper actually executes (covering the constructor chain, the {@code ClassUtils.isPresent} + * check, and the {@code DelegatingReactiveAuthenticationManager} construction). Calling + * {@code authenticate(...)} on the captured manager additionally exercises the two anonymous + * authenticate overrides + {@code wrapOnErrorMap}. + */ + @Test + @SuppressWarnings("unchecked") + public void postProcessAfterInitializationWithServerHttpSecurityWiresOauth2LoginAndLogout() { + ReactiveOAuth2UserService oAuth2UserService = mock(ReactiveOAuth2UserService.class); + OidcReactiveOAuth2UserService oidcUserService = mock(OidcReactiveOAuth2UserService.class); + OidcClientInitiatedServerLogoutSuccessHandler logoutHandler = mock(OidcClientInitiatedServerLogoutSuccessHandler.class); + + ReactiveKindeOAuth2ServerHttpServerAutoConfig.KindeOAuth2LoginServerBeanPostProcessor processor = + new ReactiveKindeOAuth2ServerHttpServerAutoConfig.KindeOAuth2LoginServerBeanPostProcessor( + oAuth2UserService, oidcUserService, logoutHandler); + + ServerHttpSecurity http = mock(ServerHttpSecurity.class); + + // Drive the oauth2Login customizer so reactiveAuthenticationManager(...) is built and + // installed via login.authenticationManager(...). Capture the manager to fire authenticate(). + ServerHttpSecurity.OAuth2LoginSpec loginSpec = mock(ServerHttpSecurity.OAuth2LoginSpec.class); + when(loginSpec.authenticationManager(any())).thenReturn(loginSpec); + when(http.oauth2Login(any())).thenAnswer(invocation -> { + Customizer customizer = invocation.getArgument(0); + customizer.customize(loginSpec); + return http; + }); + // Drive the logout customizer too so logout.logoutSuccessHandler(...) actually fires. + ServerHttpSecurity.LogoutSpec logoutSpec = mock(ServerHttpSecurity.LogoutSpec.class); + when(logoutSpec.logoutSuccessHandler(any())).thenReturn(logoutSpec); + when(http.logout(any())).thenAnswer(invocation -> { + Customizer customizer = invocation.getArgument(0); + customizer.customize(logoutSpec); + return http; + }); + + Object result = processor.postProcessAfterInitialization(http, "anyName"); + + assertSame(http, result, "post-processor must return the input bean"); + verify(http).oauth2Login(any()); + verify(http).logout(any()); + verify(loginSpec).authenticationManager(any()); + verify(logoutSpec).logoutSuccessHandler(logoutHandler); + + // Drive the anonymous authenticate() overrides + wrapOnErrorMap by capturing the manager + // and invoking authenticate(...) once. The underlying OAuth2LoginReactiveAuthenticationManager + // forces a cast to OAuth2AuthorizationCodeAuthenticationToken inside the Mono pipeline, so + // any other Authentication subscriber-side throws ClassCastException -- which is fine: it + // still walks the override -> wrapOnErrorMap -> shouldWrapException path under JaCoCo. We + // just don't care about the resulting exception here. + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(ReactiveAuthenticationManager.class); + verify(loginSpec).authenticationManager(captor.capture()); + ReactiveAuthenticationManager manager = captor.getValue(); + assertNotNull(manager); + org.junit.jupiter.api.Assertions.assertThrows(Throwable.class, + () -> manager.authenticate(mock(Authentication.class)).block()); + } + + @Test + @SuppressWarnings("unchecked") + public void postProcessAfterInitializationWithoutLogoutHandlerSkipsLogoutWiring() { + ReactiveOAuth2UserService oAuth2UserService = mock(ReactiveOAuth2UserService.class); + OidcReactiveOAuth2UserService oidcUserService = mock(OidcReactiveOAuth2UserService.class); + + ReactiveKindeOAuth2ServerHttpServerAutoConfig.KindeOAuth2LoginServerBeanPostProcessor processor = + new ReactiveKindeOAuth2ServerHttpServerAutoConfig.KindeOAuth2LoginServerBeanPostProcessor( + oAuth2UserService, oidcUserService, null); + + ServerHttpSecurity http = mock(ServerHttpSecurity.class); + when(http.oauth2Login(any())).thenReturn(http); + + Object result = processor.postProcessAfterInitialization(http, "anyName"); + + assertSame(http, result); + verify(http).oauth2Login(any()); + verify(http, never()).logout(any()); + } + + @Test + @SuppressWarnings("unchecked") + public void postProcessAfterInitializationLeavesNonHttpBeansUntouched() { + ReactiveOAuth2UserService oAuth2UserService = mock(ReactiveOAuth2UserService.class); + OidcReactiveOAuth2UserService oidcUserService = mock(OidcReactiveOAuth2UserService.class); + + ReactiveKindeOAuth2ServerHttpServerAutoConfig.KindeOAuth2LoginServerBeanPostProcessor processor = + new ReactiveKindeOAuth2ServerHttpServerAutoConfig.KindeOAuth2LoginServerBeanPostProcessor( + oAuth2UserService, oidcUserService, null); + + Object input = "not-a-server-http-security"; + + Object result = processor.postProcessAfterInitialization(input, "name"); + + assertSame(input, result); + } + + // --- helpers --------------------------------------------------------------------------------- + + /** + * The reactive {@code OidcClientInitiatedServerLogoutSuccessHandler} stores the configured URI + * as a {@code RedirectServerLogoutSuccessHandler} delegate's URI, so the simplest reflection + * path is to read the {@code postLogoutRedirectUri} field directly. + */ + private static String postLogoutRedirectUriOf(OidcClientInitiatedServerLogoutSuccessHandler handler) throws Exception { + Field field = OidcClientInitiatedServerLogoutSuccessHandler.class.getDeclaredField("postLogoutRedirectUri"); + field.setAccessible(true); + Object value = field.get(handler); + return value == null ? null : value.toString(); + } } diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/TokenUtilTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/TokenUtilTest.java index 8e64d7d2..5336864b 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/TokenUtilTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/TokenUtilTest.java @@ -1,19 +1,26 @@ package com.kinde.spring; import org.junit.jupiter.api.Test; -import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.security.oauth2.core.OAuth2AccessToken; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; import java.util.Map; import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; public class TokenUtilTest { + private static final String ISSUER = "https://example.kinde.com"; + @Test public void testTokenClaimsToAuthorities() throws Exception { TokenUtil.tokenClaimsToAuthorities(Map.of(),"String"); @@ -36,11 +43,100 @@ public void testJwtValidator() throws Exception { TokenUtil.jwtValidator("Kinde","test.com"); } + /** + * When no audience is configured (null), the validator should NOT enforce an audience claim. + * This matches the Kinde default: tokens issued for clients without a configured API resource + * carry an empty {@code aud} array. Regression coverage for the prior {@code "api://default"} + * default that rejected every real Kinde token. + */ + @Test + public void testJwtValidatorWithNullAudienceSkipsAudienceCheck() { + OAuth2TokenValidator validator = TokenUtil.jwtValidator(ISSUER, null); + + Jwt tokenWithEmptyAud = jwt(ISSUER, List.of()); + Jwt tokenWithAud = jwt(ISSUER, List.of("https://api.example.com")); + + assertTrue(validator.validate(tokenWithEmptyAud).getErrors().isEmpty(), + "Empty aud should be accepted when no audience configured"); + assertTrue(validator.validate(tokenWithAud).getErrors().isEmpty(), + "Any aud should be accepted when no audience configured"); + } + + @Test + public void testJwtValidatorWithBlankAudienceSkipsAudienceCheck() { + OAuth2TokenValidator validator = TokenUtil.jwtValidator(ISSUER, " "); + + Jwt tokenWithEmptyAud = jwt(ISSUER, List.of()); + assertTrue(validator.validate(tokenWithEmptyAud).getErrors().isEmpty(), + "Empty aud should be accepted when audience is blank/whitespace"); + } + + @Test + public void testJwtValidatorWithConfiguredAudienceAcceptsMatchingToken() { + OAuth2TokenValidator validator = TokenUtil.jwtValidator(ISSUER, "https://api.example.com"); + + Jwt token = jwt(ISSUER, List.of("https://api.example.com")); + OAuth2TokenValidatorResult result = validator.validate(token); + assertTrue(result.getErrors().isEmpty(), "Token with matching aud should pass"); + } + + @Test + public void testJwtValidatorWithConfiguredAudienceRejectsNonMatchingToken() { + OAuth2TokenValidator validator = TokenUtil.jwtValidator(ISSUER, "https://api.example.com"); + + Jwt tokenWithWrongAud = jwt(ISSUER, List.of("https://other.example.com")); + OAuth2TokenValidatorResult resultWrong = validator.validate(tokenWithWrongAud); + assertFalse(resultWrong.getErrors().isEmpty(), "Token with non-matching aud should be rejected"); + + Jwt tokenWithEmptyAud = jwt(ISSUER, List.of()); + OAuth2TokenValidatorResult resultEmpty = validator.validate(tokenWithEmptyAud); + assertFalse(resultEmpty.getErrors().isEmpty(), "Token with empty aud should be rejected when audience configured"); + } + + /** + * Some IdPs (and hand-crafted tokens) omit the {@code aud} claim entirely rather than emit an + * empty array. {@link Jwt#getAudience()} returns {@code null} for that case, which previously + * caused {@code Collections.disjoint} to throw an NPE inside the audience validator. The + * validator must reject those tokens when an audience is configured, not crash. + */ + @Test + public void testJwtValidatorWithAbsentAudClaimRejectsWhenAudienceConfigured() { + OAuth2TokenValidator validator = TokenUtil.jwtValidator(ISSUER, "https://api.example.com"); + + Jwt tokenWithNoAudClaim = jwt(ISSUER, null); + OAuth2TokenValidatorResult result = validator.validate(tokenWithNoAudClaim); + assertFalse(result.getErrors().isEmpty(), + "Token with absent aud claim should be rejected (not NPE) when audience configured"); + } + + @Test + public void testJwtValidatorRejectsWrongIssuerRegardlessOfAudience() { + OAuth2TokenValidator validator = TokenUtil.jwtValidator(ISSUER, null); + + Jwt token = jwt("https://other.kinde.com", List.of()); + OAuth2TokenValidatorResult result = validator.validate(token); + assertFalse(result.getErrors().isEmpty(), "Wrong issuer should be rejected even with audience disabled"); + } + @Test public void testRootIssuer() throws Exception { assertTrue (TokenUtil.isRootOrgIssuer("https://sample.kinde.com")); assertTrue (!TokenUtil.isRootOrgIssuer("https://sample.kinde.com/oauth2/default")); } - + private static Jwt jwt(String issuer, List audiences) { + Instant now = Instant.now(); + Jwt.Builder builder = Jwt.withTokenValue("token-value") + .header("alg", "RS256") + .issuer(issuer) + .issuedAt(now) + .expiresAt(now.plus(5, ChronoUnit.MINUTES)) + .subject("test-subject"); + if (audiences != null) { + // The Spring Security Jwt builder requires a non-null List for the aud claim. An empty + // list models the real Kinde token shape (no audience configured on the dashboard). + builder.audience(audiences); + } + return builder.build(); + } } diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/WebClientUtilTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/WebClientUtilTest.java index 825f3c8f..cb9660a7 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/WebClientUtilTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/WebClientUtilTest.java @@ -1,7 +1,6 @@ package com.kinde.spring; -import com.github.tomakehurst.wiremock.junit5.WireMockTest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,6 +14,8 @@ import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.assertEquals; + public class WebClientUtilTest { @@ -45,6 +46,6 @@ public void testWebClientHeaders() { .bodyToMono(String.class) .block(); - System.out.println(headers.get().getFirst(HttpHeaders.USER_AGENT)); + assertEquals("KINDE", headers.get().getFirst(HttpHeaders.USER_AGENT)); } } diff --git a/kinde-springboot/kinde-springboot-starter/pom.xml b/kinde-springboot/kinde-springboot-starter/pom.xml index 3f2fb2f3..df057305 100644 --- a/kinde-springboot/kinde-springboot-starter/pom.xml +++ b/kinde-springboot/kinde-springboot-starter/pom.xml @@ -12,6 +12,47 @@ 2.3.0 kinde-springboot-starter http://maven.apache.org + + + 4.0.6 + 7.0.5 + 6.0.3 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.springframework.security + spring-security-bom + ${spring-security.version} + pom + import + + + org.junit.jupiter + junit-jupiter-api + ${junit-jupiter.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter.version} + + + org.junit.jupiter + junit-jupiter-params + ${junit-jupiter.version} + + + + com.kinde.spring @@ -26,18 +67,18 @@ org.springframework.security spring-security-web - 6.5.10 org.springframework.security spring-security-crypto - 6.5.10 + + + org.springframework.boot + spring-boot-starter-security-oauth2-client + + + org.springframework.boot + spring-boot-starter-security-oauth2-resource-server - - - - - - diff --git a/kinde-springboot/kinde-springboot-starter/src/main/resources/META-INF/com.kinde.spring/kinde-springboot-starter.properties b/kinde-springboot/kinde-springboot-starter/src/main/resources/META-INF/com.kinde.spring/kinde-springboot-starter.properties new file mode 100644 index 00000000..c831e4c1 --- /dev/null +++ b/kinde-springboot/kinde-springboot-starter/src/main/resources/META-INF/com.kinde.spring/kinde-springboot-starter.properties @@ -0,0 +1,4 @@ +# Minimal marker resource so the otherwise-empty starter jar contains at least +# one entry. Without it, maven-jar-plugin emits "JAR will be empty" every build. +# This module is a transitive-dependency aggregator only; no code lives here. +artifact=kinde-springboot-starter diff --git a/kinde-springboot/pom.xml b/kinde-springboot/pom.xml index b75f62ea..3bdc4543 100644 --- a/kinde-springboot/pom.xml +++ b/kinde-springboot/pom.xml @@ -20,11 +20,6 @@ test
- - - - - kinde-springboot-core kinde-springboot-starter @@ -34,10 +29,9 @@ org.apache.maven.plugins maven-compiler-plugin - 3.15.0 + 3.15.0 - 17 - 17 + 17 diff --git a/kinde-test-utils/pom.xml b/kinde-test-utils/pom.xml index 0a900561..7bfcb2ff 100644 --- a/kinde-test-utils/pom.xml +++ b/kinde-test-utils/pom.xml @@ -45,23 +45,4 @@ provided
- - - - - - org.apache.maven.plugins - maven-jar-plugin - - - test-jar - package - - test-jar - - - - - - diff --git a/playground/kinde-accounts-example/pom.xml b/playground/kinde-accounts-example/pom.xml index 960b9bda..eaa3d36f 100644 --- a/playground/kinde-accounts-example/pom.xml +++ b/playground/kinde-accounts-example/pom.xml @@ -18,9 +18,7 @@ Example application demonstrating Kinde Accounts API usage - 17 - 17 - UTF-8 + 17 @@ -69,9 +67,7 @@ maven-compiler-plugin 3.15.0 - 17 - 17 - true + 17
diff --git a/playground/kinde-core-example/.env b/playground/kinde-core-example/.env deleted file mode 100644 index 74465416..00000000 --- a/playground/kinde-core-example/.env +++ /dev/null @@ -1,6 +0,0 @@ -KINDE_DOMAIN=https://< replace >.kinde.com -KINDE_CLIENT_ID=< replace > -KINDE_CLIENT_SECRET=< replace > -KINDE_REDIRECT_URI=http://localhost:8080/kinde-j2ee-app/login -KINDE_GRANT_TYPE=CODE -KINDE_SCOPES=openid \ No newline at end of file diff --git a/playground/kinde-core-example/.env.example b/playground/kinde-core-example/.env.example new file mode 100644 index 00000000..b8c4d95b --- /dev/null +++ b/playground/kinde-core-example/.env.example @@ -0,0 +1,30 @@ +# Template for kinde-core-example .env. Copy to `.env` and fill in your Kinde tenant details. +# `.env` is gitignored; this template is committed so contributors have a starting point. +# +# Exercises the kinde-core SDK directly via KindeClientBuilder. The SDK reads +# these variables at runtime; the full list of supported keys is defined in +# com.kinde.config.KindeParameters. + +# === Required: tenant + credentials === +KINDE_DOMAIN=https://.kinde.com +KINDE_CLIENT_ID= +KINDE_CLIENT_SECRET= + +# === Required: OAuth flow === +KINDE_REDIRECT_URI=http://localhost:8080/kinde-j2ee-app/login +# AuthorizationType values: CODE | PKCE | CLIENT_CREDENTIALS | TOKEN +KINDE_GRANT_TYPE=CODE +KINDE_SCOPES=openid + +# === Optional === +# KINDE_LOGOUT_REDIRECT_URI=http://localhost:8080 +# KINDE_AUDIENCE=https://.kinde.com/api +# KINDE_LANG=en +# KINDE_ORG_CODE= +# KINDE_HAS_SUCCESS_PAGE=false + +# === Endpoint overrides (rarely needed — defaults derive from KINDE_DOMAIN) === +# KINDE_OPENID_ENDPOINT=https://.kinde.com/.well-known/openid-configuration +# KINDE_AUTHORIZATION_ENDPOINT=https://.kinde.com/oauth2/auth +# KINDE_TOKEN_ENDPOINT=https://.kinde.com/oauth2/token +# KINDE_LOGOUT_ENDPOINT=https://.kinde.com/logout diff --git a/playground/kinde-core-example/pom.xml b/playground/kinde-core-example/pom.xml index 9808222d..b436eba7 100644 --- a/playground/kinde-core-example/pom.xml +++ b/playground/kinde-core-example/pom.xml @@ -16,12 +16,6 @@ kinde-core-example http://maven.apache.org - - junit - junit - 4.13.2 - test - com.nimbusds oauth2-oidc-sdk @@ -40,10 +34,8 @@ com.google.inject guice - 7.0.0 - org.slf4j slf4j-api @@ -54,25 +46,5 @@ dotenv-java 3.2.0 - - - org.slf4j - slf4j-simple - 2.0.17 - test - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.6 - - - true - - - - diff --git a/playground/kinde-core-example/src/test/java/com/kinde/KindeCoreExampleTest.java b/playground/kinde-core-example/src/test/java/com/kinde/KindeCoreExampleTest.java deleted file mode 100644 index 75332b9a..00000000 --- a/playground/kinde-core-example/src/test/java/com/kinde/KindeCoreExampleTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.kinde; - -import com.kinde.authorization.AuthorizationUrl; -import com.kinde.token.KindeTokens; -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; -import org.junit.Ignore; - -public class KindeCoreExampleTest extends TestCase { - - public KindeCoreExampleTest(String testName) { - super(testName); - } - - public static Test suite() { - return new TestSuite(KindeCoreExampleTest.class); - } - - @Ignore - public void testApp() { - System.out.println("Test the kinde builder"); - KindeClient kindeClient = KindeClientBuilder - .builder() - .build(); - KindeClientSession kindeClientSession = kindeClient.clientSession(); - System.out.println(kindeClientSession.authorizationUrl()); - KindeTokens kindeTokens = kindeClientSession.retrieveTokens(); - KindeTokenFactory kindeTokenFactory = kindeClient.tokenFactory(); - kindeTokenFactory.parse(kindeTokens.getAccessToken().token()); - - assertNotNull(kindeClient); - assertNotNull(kindeClientSession); - assertNotNull(kindeTokens); - assertNotNull(kindeTokens.getAccessToken()); - assertNotNull(kindeTokenFactory); - assertNotNull(kindeTokenFactory.parse(kindeTokens.getAccessToken().token())); - } - - @Ignore - public void testInvitationCodeWithLogin() { - System.out.println("Test invitation code with login"); - KindeClient kindeClient = KindeClientBuilder - .builder() - .build(); - KindeClientSession kindeClientSession = kindeClient.clientSession(); - - AuthorizationUrl loginWithInvite = kindeClientSession.login("inv_example123"); - System.out.println("\nLogin with invitation code:"); - System.out.println(" URL: " + loginWithInvite.getUrl()); - - AuthorizationUrl registerWithInvite = kindeClientSession.register("inv_example456"); - System.out.println("\nRegister with invitation code:"); - System.out.println(" URL: " + registerWithInvite.getUrl()); - - AuthorizationUrl handleDirect = kindeClientSession.handleInvitation("inv_example789"); - System.out.println("\nHandle invitation directly:"); - System.out.println(" URL: " + handleDirect.getUrl()); - - AuthorizationUrl normalLogin = kindeClientSession.login(); - System.out.println("\nNormal login (no invitation):"); - System.out.println(" URL: " + normalLogin.getUrl()); - } -} diff --git a/playground/kinde-j2ee-app/pom.xml b/playground/kinde-j2ee-app/pom.xml index b1778804..a6b08ae6 100644 --- a/playground/kinde-j2ee-app/pom.xml +++ b/playground/kinde-j2ee-app/pom.xml @@ -37,10 +37,8 @@ com.google.inject guice - 7.0.0 - org.slf4j slf4j-api diff --git a/playground/kinde-management-example/.env b/playground/kinde-management-example/.env deleted file mode 100644 index 4edfb203..00000000 --- a/playground/kinde-management-example/.env +++ /dev/null @@ -1,7 +0,0 @@ -KINDE_DOMAIN=https://< replace >.kinde.com -KINDE_CLIENT_ID=< replace > -KINDE_CLIENT_SECRET=< replace > -KINDE_REDIRECT_URI=http://localhost:8080/kinde-j2ee-app/login -KINDE_GRANT_TYPE=CODE -KINDE_SCOPES= -KINDE_AUDIENCE=https://< replace >.kinde.com/api diff --git a/playground/kinde-management-example/.env.example b/playground/kinde-management-example/.env.example new file mode 100644 index 00000000..617edc2c --- /dev/null +++ b/playground/kinde-management-example/.env.example @@ -0,0 +1,32 @@ +# Template for kinde-management-example .env. Copy to `.env` and fill in your Kinde tenant details. +# `.env` is gitignored; this template is committed so contributors have a starting point. +# +# Exercises the kinde-management SDK against the Kinde Management API. Configure +# a Machine-to-Machine application in Kinde (admin → Applications → Add → M2M) +# and grant it Management API access. The SDK reads these variables at runtime; +# the full list of supported keys is defined in com.kinde.config.KindeParameters. + +# === Required: tenant + M2M credentials === +KINDE_DOMAIN=https://.kinde.com +KINDE_CLIENT_ID= +KINDE_CLIENT_SECRET= + +# === Required: target audience === +KINDE_AUDIENCE=https://.kinde.com/api + +# === OAuth flow === +# AuthorizationType values: CODE | PKCE | CLIENT_CREDENTIALS | TOKEN +# For Management API access (M2M) use CLIENT_CREDENTIALS. +KINDE_GRANT_TYPE=CLIENT_CREDENTIALS +# Typically empty for the client_credentials flow. +KINDE_SCOPES= + +# === Optional === +# Only relevant if this example also exercises the user-facing auth-code flow. +# KINDE_REDIRECT_URI=http://localhost:8080/kinde-j2ee-app/login +# KINDE_LOGOUT_REDIRECT_URI=http://localhost:8080 +# KINDE_LANG=en +# KINDE_ORG_CODE= + +# === Endpoint overrides (rarely needed — defaults derive from KINDE_DOMAIN) === +# KINDE_TOKEN_ENDPOINT=https://.kinde.com/oauth2/token diff --git a/playground/kinde-management-example/pom.xml b/playground/kinde-management-example/pom.xml index 7b4b23a0..b4ad8c1d 100644 --- a/playground/kinde-management-example/pom.xml +++ b/playground/kinde-management-example/pom.xml @@ -16,12 +16,6 @@ kinde-management-example http://maven.apache.org - - junit - junit - 4.13.2 - test - com.nimbusds oauth2-oidc-sdk @@ -45,10 +39,8 @@ com.google.inject guice - 7.0.0 - org.slf4j slf4j-api @@ -59,25 +51,5 @@ dotenv-java 3.2.0 - - - org.slf4j - slf4j-simple - 2.0.17 - test - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.6 - - - true - - - - diff --git a/playground/kinde-management-example/src/test/java/com/kinde/KindeManagementExampleTest.java b/playground/kinde-management-example/src/test/java/com/kinde/KindeManagementExampleTest.java deleted file mode 100644 index e69cd86d..00000000 --- a/playground/kinde-management-example/src/test/java/com/kinde/KindeManagementExampleTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.kinde; - -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; -import org.junit.Ignore; -import org.openapitools.client.ApiClient; -import org.openapitools.client.api.ApplicationsApi; - - - -/** - * Unit test for simple App. - */ -public class KindeManagementExampleTest - extends TestCase -{ - /** - * Create the test case - * - * @param testName name of the test case - */ - public KindeManagementExampleTest(String testName ) - { - super( testName ); - } - - /** - * @return the suite of tests being tested - */ - public static Test suite() - { - return new TestSuite( KindeManagementExampleTest.class ); - } - - /** - * Rigourous Test :-) - */ - @Ignore - public void testApp() throws Exception { - System.out.println("Test the kinde builder"); - KindeClient kindeClient = KindeClientBuilder - .builder() - .build(); - KindeAdminSession kindeAdminSession = KindeAdminSessionBuilder.builder().client(kindeClient).build(); - ApiClient apiClient = kindeAdminSession.initClient(); - ApplicationsApi applicationsApi = new ApplicationsApi(apiClient); - applicationsApi.getApplications(null,null,null); - } -} diff --git a/playground/kinde-springboot-pkce-client-example/pom.xml b/playground/kinde-springboot-pkce-client-example/pom.xml index 1a1467b4..37c81174 100644 --- a/playground/kinde-springboot-pkce-client-example/pom.xml +++ b/playground/kinde-springboot-pkce-client-example/pom.xml @@ -11,26 +11,62 @@ kinde-springboot-pkce-client-example 2.3.0 + + 4.0.6 + 7.0.5 + 6.0.3 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.springframework.security + spring-security-bom + ${spring-security.version} + pom + import + + + org.junit.jupiter + junit-jupiter-api + ${junit-jupiter.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter.version} + + + org.junit.jupiter + junit-jupiter-params + ${junit-jupiter.version} + + + + org.springframework.boot spring-boot-starter-webflux - 3.5.5 org.springframework.boot - spring-boot-starter-oauth2-client - 3.5.5 + spring-boot-starter-security-oauth2-client org.springframework.boot spring-boot-starter-thymeleaf - 3.5.5 org.springframework.boot - spring-boot-starter-oauth2-resource-server - 3.5.5 + spring-boot-starter-security-oauth2-resource-server @@ -40,9 +76,9 @@ org.springframework.boot spring-boot-maven-plugin - 3.5.5 + ${spring-boot.version} - \ No newline at end of file + diff --git a/playground/kinde-springboot-pkce-client-example/src/main/resources/application.yaml b/playground/kinde-springboot-pkce-client-example/src/main/resources/application.yaml index 623b2dd5..17e47941 100644 --- a/playground/kinde-springboot-pkce-client-example/src/main/resources/application.yaml +++ b/playground/kinde-springboot-pkce-client-example/src/main/resources/application.yaml @@ -4,10 +4,14 @@ spring: client: provider: kinde: - issuer-uri: https://burntjam.kinde.com - registration: - pkce: + issuer-uri: ${KINDE_DOMAIN:https://your-domain.kinde.com} + registration: + # Registration id intentionally set to "kinde" so Spring's default callback + # (/login/oauth2/code/kinde) lines up with the redirect URI already registered + # on the Kinde application. PKCE is force-enabled by the custom + # ServerOAuth2AuthorizationRequestResolver in OAuth2ClientConfiguration. + kinde: provider: kinde - client-id: d129efa11ffe436ca46e66aa16753e02 - client-secret: x9kxMgGeMuHD5rgs3xK4aulij5qnpqM6zRaRz4RsWwCt4Bu1e0a + client-id: ${KINDE_CLIENT_ID:your-client-id-here} + client-secret: ${KINDE_CLIENT_SECRET:your-client-secret-here} scope: openid,email diff --git a/playground/kinde-springboot-starter-example/pom.xml b/playground/kinde-springboot-starter-example/pom.xml index 1a153a49..4c3e7254 100644 --- a/playground/kinde-springboot-starter-example/pom.xml +++ b/playground/kinde-springboot-starter-example/pom.xml @@ -13,6 +13,47 @@ 2.3.0 kinde-springboot-starter http://maven.apache.org + + + 4.0.6 + 7.0.5 + 6.0.3 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.springframework.security + spring-security-bom + ${spring-security.version} + pom + import + + + org.junit.jupiter + junit-jupiter-api + ${junit-jupiter.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter.version} + + + org.junit.jupiter + junit-jupiter-params + ${junit-jupiter.version} + + + + com.kinde.spring @@ -22,42 +63,26 @@ org.springframework.boot spring-boot-starter-data-rest - 3.5.14 org.springframework.boot spring-boot-starter-web - 3.5.14 org.springframework.boot spring-boot-starter-actuator - 3.5.14 org.springframework.boot spring-boot-starter-security - 3.5.14 org.springframework.boot spring-boot-starter-thymeleaf - 3.5.6 - - - org.thymeleaf - thymeleaf - 3.1.4.RELEASE - - - org.thymeleaf - thymeleaf-spring6 - 3.1.4.RELEASE org.springframework.boot spring-boot-devtools - 3.5.14 runtime @@ -73,7 +98,6 @@ ch.qos.logback logback-classic runtime - 1.5.33 @@ -90,7 +114,7 @@ org.springframework.boot spring-boot-maven-plugin - 3.5.5 + ${spring-boot.version} diff --git a/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/KindeClientApplication.java b/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/KindeClientApplication.java index 7b5721d3..910b44c9 100644 --- a/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/KindeClientApplication.java +++ b/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/KindeClientApplication.java @@ -2,12 +2,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.web.SecurityFilterChain; @SpringBootApplication @EnableMethodSecurity(securedEnabled = true) diff --git a/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/config/SecurityConfig.java b/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/config/SecurityConfig.java index 9f4ea8a7..f3905abb 100644 --- a/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/config/SecurityConfig.java +++ b/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/config/SecurityConfig.java @@ -4,16 +4,45 @@ import com.kinde.spring.resolver.CustomAuthorizationRequestResolver; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; @Configuration @EnableWebSecurity public class SecurityConfig { + /** + * JWT-based resource server filter chain for /api/** endpoints. + * + *

Ordered before the browser-OAuth2-login chain so that requests to /api/** are handled by + * Bearer token validation instead of being redirected to the Kinde login page. The + * {@link org.springframework.security.oauth2.jwt.JwtDecoder} and + * {@link org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter} + * are auto-configured by kinde-springboot-core ({@code KindeOAuth2ResourceServerAutoConfig}). + */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/**") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .csrf(csrf -> csrf.disable()) + .sessionManagement(s -> s.sessionCreationPolicy( + org.springframework.security.config.http.SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex.authenticationEntryPoint( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(org.springframework.security.config.Customizer.withDefaults())); + + return http.build(); + } + @Bean(name = "applicationSecurityFilterChain") SecurityFilterChain securityFilterChain(HttpSecurity http, OAuth2AuthorizationRequestResolver customAuthorizationRequestResolver) throws Exception { http diff --git a/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/controllers/ApiController.java b/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/controllers/ApiController.java new file mode 100644 index 00000000..26d0baef --- /dev/null +++ b/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/controllers/ApiController.java @@ -0,0 +1,39 @@ +package com.kinde.spring.controllers; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * REST API endpoints protected by Bearer-token JWT validation. Used to exercise the + * resource-server path wired up via {@code KindeOAuth2ResourceServerAutoConfig} + * (custom {@code NimbusJwtDecoder} + {@code KindeJwtAuthenticationConverter}). + * + *

Hit these with: {@code curl -H "Authorization: Bearer " http://localhost:8080/api/me}. + */ +@RestController +@RequestMapping("/api") +public class ApiController { + + @GetMapping("/me") + public Map me(JwtAuthenticationToken authentication) { + Jwt jwt = authentication.getToken(); + Map result = new LinkedHashMap<>(); + result.put("name", authentication.getName()); + result.put("authorities", authentication.getAuthorities()); + result.put("claims", jwt.getClaims()); + result.put("headers", jwt.getHeaders()); + return result; + } + + @GetMapping("/permissions") + public Object permissions(Authentication authentication) { + return authentication.getAuthorities(); + } +} diff --git a/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/controllers/DebugController.java b/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/controllers/DebugController.java new file mode 100644 index 00000000..9808fd87 --- /dev/null +++ b/playground/kinde-springboot-starter-example/src/main/java/com/kinde/spring/controllers/DebugController.java @@ -0,0 +1,42 @@ +package com.kinde.spring.controllers; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Debug helpers protected by the browser session (NOT the Bearer-token chain). + * + *

{@code GET /debug/token} returns the OAuth2 access token + id token + refresh token for the + * currently logged-in user. Used to grab a real Kinde access token to feed into curl requests + * against {@code /api/**} for resource-server testing. + * + *

Do not ship this in a real application. + */ +@RestController +@RequestMapping("/debug") +public class DebugController { + + @GetMapping("/token") + public Map token(@AuthenticationPrincipal OidcUser user, + @RegisteredOAuth2AuthorizedClient("kinde") OAuth2AuthorizedClient client) { + Map result = new LinkedHashMap<>(); + result.put("access_token", client.getAccessToken().getTokenValue()); + result.put("id_token", user.getIdToken().getTokenValue()); + if (client.getRefreshToken() != null) { + result.put("refresh_token", client.getRefreshToken().getTokenValue()); + } + result.put("token_type", client.getAccessToken().getTokenType().getValue()); + if (client.getAccessToken().getExpiresAt() != null) { + result.put("expires_at", client.getAccessToken().getExpiresAt().toString()); + } + return result; + } +} diff --git a/playground/kinde-springboot-starter-example/src/main/resources/application.yaml b/playground/kinde-springboot-starter-example/src/main/resources/application.yaml index 8603013b..75a5eb87 100644 --- a/playground/kinde-springboot-starter-example/src/main/resources/application.yaml +++ b/playground/kinde-springboot-starter-example/src/main/resources/application.yaml @@ -4,6 +4,12 @@ kinde: client-id: ${KINDE_CLIENT_ID:your-client-id-here} client-secret: ${KINDE_CLIENT_SECRET:your-client-secret-here} scopes: openid,email,profile,offline + # Triggers OidcClientInitiatedLogoutSuccessHandler bean which Kinde's auto-config wires + # into every HttpSecurity via KindeOAuth2Configurer (registered as a global + # AbstractHttpConfigurer in spring.factories). Setting this property opts the app into + # RP-Initiated logout: a POST /logout will redirect to {KINDE_DOMAIN}/logout with + # id_token_hint and post_logout_redirect_uri, then the IdP redirects back here. + post-logout-redirect-uri: ${KINDE_LOGOUT_REDIRECT_URI:http://localhost:8080} auto-config: enabled: true diff --git a/playground/kinde-springboot-starter-example/src/main/resources/templates/home.html b/playground/kinde-springboot-starter-example/src/main/resources/templates/home.html index 98c50a18..882c88bc 100644 --- a/playground/kinde-springboot-starter-example/src/main/resources/templates/home.html +++ b/playground/kinde-springboot-starter-example/src/main/resources/templates/home.html @@ -17,7 +17,14 @@

KindeAuth

- + +
+ +
@@ -49,10 +56,5 @@

Next steps for you

- \ No newline at end of file diff --git a/playground/kinde-springboot-thymeleaf-full-example/.env b/playground/kinde-springboot-thymeleaf-full-example/.env deleted file mode 100644 index f809e84f..00000000 --- a/playground/kinde-springboot-thymeleaf-full-example/.env +++ /dev/null @@ -1,13 +0,0 @@ -KINDE_REDIRECT_URI=http://localhost:8081/login/oauth2/code/kinde -KINDE_DOMAIN=https://< replace >.kinde.com -KINDE_ISSUER_URI=https://< replace >.kinde.com -KINDE_AUTHORIZATION_URI=https://< replace >.kinde.com/oauth2/auth -KINDE_TOKEN_URI=https://< replace >.kinde.com/oauth2/token -KINDE_USER_INFO_URI=https://< replace >.kinde.com/oauth2/v2/user_profile -KINDE_JWKS_URI=https://< replace >.kinde.com/.well-known/jwks -KINDE_CLIENT_ID=< replace > -KINDE_CLIENT_SECRET=< replace > -KINDE_GRANT_TYPE=authorization_code -KINDE_SCOPES=openid,profile,email,offline -KINDE_PREFIX=< replace > -KINDE_LOGOUT_REDIRECT_URI=http://localhost:8081 \ No newline at end of file diff --git a/playground/kinde-springboot-thymeleaf-full-example/.env.example b/playground/kinde-springboot-thymeleaf-full-example/.env.example new file mode 100644 index 00000000..9dfcc185 --- /dev/null +++ b/playground/kinde-springboot-thymeleaf-full-example/.env.example @@ -0,0 +1,44 @@ +# Template for the Thymeleaf Spring Boot example's .env. +# Copy to `.env` and fill in your Kinde tenant details. `.env` is gitignored; +# this template is committed so contributors have a starting point. +# +# After filling in the values, run from this directory: +# mvn spring-boot:run +# then open http://localhost:8080 +# +# This example combines: +# - SDK env vars consumed by kinde-core at runtime +# (see com.kinde.config.KindeParameters for the full list) +# - Spring Boot's spring.security.oauth2.client.* properties referenced from +# application.properties via ${KINDE_*} placeholders + +# === Required: tenant === +# KINDE_PREFIX is the subdomain portion of your Kinde tenant URL — used by +# application.properties to construct the OAuth provider URIs. +KINDE_PREFIX= +KINDE_DOMAIN=https://.kinde.com + +# === Required: application credentials === +KINDE_CLIENT_ID= +KINDE_CLIENT_SECRET= + +# === Required: OAuth flow === +KINDE_REDIRECT_URI=http://localhost:8080/login/oauth2/code/kinde +KINDE_LOGOUT_REDIRECT_URI=http://localhost:8080 +KINDE_GRANT_TYPE=authorization_code +KINDE_SCOPES=openid,profile,email,offline + +# === OAuth provider URIs === +# application.properties derives these from ${KINDE_PREFIX} by default, so the +# entries below are only needed if you want to point at non-default endpoints. +KINDE_ISSUER_URI=https://.kinde.com +KINDE_AUTHORIZATION_URI=https://.kinde.com/oauth2/auth +KINDE_TOKEN_URI=https://.kinde.com/oauth2/token +KINDE_USER_INFO_URI=https://.kinde.com/oauth2/v2/user_profile +KINDE_JWKS_URI=https://.kinde.com/.well-known/jwks + +# === Optional SDK knobs === +# KINDE_AUDIENCE=https://.kinde.com/api +# KINDE_LANG=en +# KINDE_ORG_CODE= +# KINDE_HAS_SUCCESS_PAGE=false diff --git a/playground/kinde-springboot-thymeleaf-full-example/README.md b/playground/kinde-springboot-thymeleaf-full-example/README.md index f8ae3e9c..1ae5145f 100644 --- a/playground/kinde-springboot-thymeleaf-full-example/README.md +++ b/playground/kinde-springboot-thymeleaf-full-example/README.md @@ -2,7 +2,7 @@ This project demonstrates the integration of OAuth2 login with Kinde using Spring Boot and Spring Security. The application provides a simple web interface with authentication and role-based authorization. -Run the app, go to `http://localhost:8081` and click sign up to add your new Kinde application. You will need your new client id and secret from the Kinde portal for the `application.properties`. You will also need to configure roles and permissions. This starter uses three for demonstration purposes: `read`, `write` and `admin`. +Run the app, go to `http://localhost:8080` and click sign up to add your new Kinde application. You will need your new client id and secret from the Kinde portal for the `application.properties` (or `.env`). You will also need to configure roles and permissions. This starter demonstrates four roles: `read`, `write`, `edit`, and `admin`. ## Table of Contents @@ -18,7 +18,7 @@ Run the app, go to `http://localhost:8081` and click sign up to add your new Kin - Java 17 or later - Maven 3.6+ -- Spring Boot 3.3.3 +- Spring Boot 4.0.x (currently 4.0.6 — see `pom.xml`) ## Project Setup @@ -48,8 +48,8 @@ mvn spring-boot:run The `pom.xml` includes the following essential dependencies: - `spring-boot-starter-security`: Provides core Spring Security components. -- `spring-boot-starter-oauth2-client`: Enables OAuth2 client capabilities. -- `spring-boot-starter-oauth2-resource-server`: Supports resource server capabilities with JWT. +- `spring-boot-starter-security-oauth2-client`: Enables OAuth2 client capabilities. +- `spring-boot-starter-security-oauth2-resource-server`: Supports resource server capabilities with JWT. - `spring-boot-starter-thymeleaf`: Allows server-side rendering using Thymeleaf. - `spring-webflux`: Required for the reactive WebClient used in OAuth2 requests. - `kinde-core`: Kinde specific SDK for interacting with their API. @@ -70,10 +70,11 @@ Setup the environment to execute correctly export KINDE_DOMAIN=https://.kinde.com export KINDE_CLIENT_ID= export KINDE_CLIENT_SECRET= -export KINDE_REDIRECT_URI=http://localhost:8081/login/oauth2/code/kinde-provider +export KINDE_REDIRECT_URI=http://localhost:8080/login/oauth2/code/kinde export KINDE_GRANT_TYPE=authorization_code -export KINDE_SCOPES=openid,profile,email +export KINDE_SCOPES=openid,profile,email,offline export KINDE_PREFIX= +export KINDE_LOGOUT_REDIRECT_URI=http://localhost:8080 ``` 2. **Start the Application:** @@ -84,17 +85,20 @@ export KINDE_PREFIX= ``` 3. **Access the Application:** - Open your browser and navigate to `http://localhost:8081`. + Open your browser and navigate to `http://localhost:8080`. ## Endpoints The application provides several endpoints: - **`/home` or `/`** - Publicly accessible homepage. -- **`/admin`** - Accessible to users with the `admins` role. +- **`/dashboard`** - Authenticated; displays the user's Kinde profile data. +- **`/admin`** - Accessible to users with the `admin` role. - **`/read`** - Accessible to users with the `read` role. -- **`/write`** - Accessible to users with the `write` role. -- **`/dashboard`** - Displays the user's Kinde profile data. +- **`/edit`** - Accessible to users with the `edit` role. +- **`/write`** - Authenticated (role check is commented out in `KindeController` by default; uncomment `@PreAuthorize("hasRole('write')")` to enforce). +- **`/logout`** (POST) - RP-initiated logout. Clears the local Spring session and redirects to Kinde's end-session endpoint with `id_token_hint` + `post_logout_redirect_uri`, fully terminating the upstream SSO session. Wired via `OidcClientInitiatedLogoutSuccessHandler` in `SecurityConfig`. +- **`/generatePortalUrl`** - Authenticated; uses the SDK to exchange the user's refresh token and generate a one-time URL to the Kinde account portal. ## Security Configuration @@ -102,7 +106,7 @@ The application provides several endpoints: The home page (`/home`) and static resources (`/css/**`) are accessible without authentication. - **Authenticated Access:** - Other routes require authentication, and access is controlled by roles. For example, `/admin` requires the `admins` role. + Other routes require authentication, and access is controlled by roles. For example, `/admin` requires the `admin` role. - **JWT Processing:** The JWT `permissions` claim is used to assign roles provided from Kinde. diff --git a/playground/kinde-springboot-thymeleaf-full-example/pom.xml b/playground/kinde-springboot-thymeleaf-full-example/pom.xml index 4d860781..7aef49f8 100644 --- a/playground/kinde-springboot-thymeleaf-full-example/pom.xml +++ b/playground/kinde-springboot-thymeleaf-full-example/pom.xml @@ -21,60 +21,79 @@ 17 17 UTF-8 + 4.0.6 + 7.0.5 + 6.0.3 + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.springframework.security + spring-security-bom + ${spring-security.version} + pom + import + + + org.junit.jupiter + junit-jupiter-api + ${junit-jupiter.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter.version} + + + org.junit.jupiter + junit-jupiter-params + ${junit-jupiter.version} + + + + org.springframework.boot spring-boot-starter-actuator - 3.5.14 org.springframework.boot spring-boot-starter-security - 3.5.14 org.springframework.boot - spring-boot-starter-oauth2-client - 3.5.14 + spring-boot-starter-security-oauth2-client org.springframework.boot - spring-boot-starter-oauth2-resource-server - 3.5.14 + spring-boot-starter-security-oauth2-resource-server org.springframework.boot spring-boot-starter-web - 3.5.14 org.springframework spring-webflux - 6.2.18 org.springframework.boot spring-boot-starter-thymeleaf - 3.5.14 - - - org.thymeleaf - thymeleaf - 3.1.4.RELEASE - - - org.thymeleaf - thymeleaf-spring6 - 3.1.4.RELEASE org.springframework.boot spring-boot-devtools - 3.5.14 true @@ -93,7 +112,6 @@ org.springframework.boot spring-boot-starter-test test - 3.5.14 @@ -102,9 +120,9 @@ org.springframework.boot spring-boot-maven-plugin - 3.5.14 + ${spring-boot.version} - \ No newline at end of file + diff --git a/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/config/KindeErrorController.java b/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/config/KindeErrorController.java index 4a57ff88..12dbaa15 100644 --- a/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/config/KindeErrorController.java +++ b/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/config/KindeErrorController.java @@ -1,6 +1,6 @@ package com.kinde.oauth.config; -import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.boot.webmvc.error.ErrorController; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/config/SecurityConfig.java b/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/config/SecurityConfig.java index c422aed2..85da1e51 100644 --- a/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/config/SecurityConfig.java +++ b/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/config/SecurityConfig.java @@ -11,8 +11,11 @@ import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; /** * Security configuration class that sets up the security filters and OAuth2 login @@ -27,6 +30,9 @@ public class SecurityConfig { @Value("${jwk-set-uri}") private String issuerUri; + @Value("${KINDE_LOGOUT_REDIRECT_URI:${app.base.url}}") + private String postLogoutRedirectUri; + /** * Configures the security filter chain, setting up CORS, authorization rules, * OAuth2 resource server, and OAuth2 login with OIDC user service. @@ -40,7 +46,8 @@ public class SecurityConfig { * @throws Exception if an error occurs during configuration. */ @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, + LogoutSuccessHandler oidcLogoutSuccessHandler) throws Exception { http .cors(Customizer.withDefaults()) .authorizeHttpRequests(auth -> auth @@ -62,11 +69,35 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .userInfoEndpoint(userInfo -> userInfo .oidcUserService(new CustomOidcUserService(issuerUri)) ) + ) + // RP-initiated logout: clearing Spring's session is not enough — without this + // handler the browser keeps Kinde's SSO cookie, so the next sign-in skips the + // password prompt. The handler redirects to {issuer}/logout with id_token_hint + // + post_logout_redirect_uri, terminating the upstream session as well. + .logout(logout -> logout + .logoutSuccessHandler(oidcLogoutSuccessHandler) + .invalidateHttpSession(true) + .clearAuthentication(true) + .deleteCookies("JSESSIONID") ); return http.build(); } + /** + * RP-initiated logout success handler. Modeled on the same wiring that the Kinde Spring Boot + * starter performs in {@code KindeOAuth2AutoConfig} when {@code kinde.oauth2.post-logout-redirect-uri} + * is configured; reproduced here because this example uses raw {@code spring.security.oauth2.*} + * properties and its own {@code SecurityFilterChain}, which bypasses the starter's auto-config. + */ + @Bean + public LogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) { + OidcClientInitiatedLogoutSuccessHandler handler = + new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository); + handler.setPostLogoutRedirectUri(postLogoutRedirectUri); + return handler; + } + /** * Defines a bean for handling Access Denied (403 Forbidden) errors. * When an authenticated user tries to access a resource they do not have permission for, diff --git a/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/controller/KindeController.java b/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/controller/KindeController.java index 31e1d5d3..5b5e7b87 100644 --- a/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/controller/KindeController.java +++ b/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/controller/KindeController.java @@ -39,16 +39,6 @@ public String home() { return "home"; } - /** - * Handles requests to the logout page. - * - * @return the name of the "logout" view. - */ - @RequestMapping(path = {"/sdkLogout"}, method = RequestMethod.GET) - public String logout() throws Exception { - return kindeService.logout(); - } - /** * Handles requests to the dashboard page, loading the authenticated user's Kinde profile. * diff --git a/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/service/KindeService.java b/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/service/KindeService.java index 36308f05..77cc61da 100644 --- a/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/service/KindeService.java +++ b/playground/kinde-springboot-thymeleaf-full-example/src/main/java/com/kinde/oauth/service/KindeService.java @@ -159,14 +159,4 @@ public String generatePortalUrl(HttpSession session) { return "redirect:" + authorizationUrl.getUrl().toString(); } - public String logout() { - KindeClient kindeClient = KindeClientBuilder.builder().build(); - try { - kindeClient.clientSession().logout(); - } catch (Exception e) { - log.error("Error during logout: {}", e.getMessage()); - return "error"; - } - return "redirect:/login"; - } } \ No newline at end of file diff --git a/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/application.properties b/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/application.properties index f2717d56..bb5017ea 100644 --- a/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/application.properties +++ b/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/application.properties @@ -1,7 +1,7 @@ spring.application.name=kinde-spring-oauth -server.port=8081 -app.base.url=http://localhost:8081 +server.port=8080 +app.base.url=http://localhost:8080 spring.config.import=optional:file:.env @@ -18,6 +18,14 @@ spring.security.oauth2.client.registration.kinde.authorization-grant-type=${KIND spring.security.oauth2.client.registration.kinde.client-name=Kinde spring.security.oauth2.client.registration.kinde.provider=kinde +# issuer-uri triggers Spring's OIDC discovery against {issuer}/.well-known/openid-configuration. +# This populates ClientRegistration.providerDetails.configurationMetadata with all standard +# endpoints — crucially end_session_endpoint, which OidcClientInitiatedLogoutSuccessHandler +# requires for RP-initiated logout. Without it, logout would only clear Spring's local session +# and Kinde's SSO cookie would persist, silently re-authenticating the user on the next sign-in. +# The explicit endpoints below still take precedence over discovered values for the standard +# endpoints (authorization, token, user-info, jwks); they're kept for documentation purposes. +spring.security.oauth2.client.provider.kinde.issuer-uri=${KINDE_DOMAIN} spring.security.oauth2.client.provider.kinde.authorization-uri=https://${KINDE_PREFIX}.kinde.com/oauth2/auth spring.security.oauth2.client.provider.kinde.token-uri=https://${KINDE_PREFIX}.kinde.com/oauth2/token spring.security.oauth2.client.provider.kinde.user-info-uri=https://${KINDE_PREFIX}.kinde.com/oauth2/v2/user_profile @@ -26,9 +34,8 @@ spring.security.oauth2.client.provider.kinde.user-name-attribute=sub spring.security.oauth2.resourceserver.jwt.issuer-uri=${KINDE_DOMAIN} -jwk-set-uri=https://koman.kinde.com/.well-known/jwks -user-profile-uri=https://koman.kinde.com/oauth2/user_profile -logout-uri=https://koman.kinde.com/logout +jwk-set-uri=https://${KINDE_PREFIX}.kinde.com/.well-known/jwks +user-profile-uri=https://${KINDE_PREFIX}.kinde.com/oauth2/user_profile spring.thymeleaf.cache=false spring.thymeleaf.prefix=classpath:/templates/ diff --git a/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/static/css/index.css b/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/static/css/index.css index 29e43e5d..4862582b 100644 --- a/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/static/css/index.css +++ b/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/static/css/index.css @@ -376,3 +376,27 @@ a { display: flex; justify-content: center; /* Center the buttons */ } + +/* Inline logout form + link-styled button: keeps the dashboard's "Sign out" + action POST-based (the Spring Security 6+ best practice) while looking the + same as the original anchor link. */ +.logout-form-inline { + display: inline; + margin: 0; + padding: 0; +} + +.link-button { + background: none; + border: none; + padding: 0; + margin: 0; + color: inherit; + font: inherit; + cursor: pointer; + text-decoration: underline; +} + +.link-button:hover { + text-decoration: none; +} diff --git a/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/templates/dashboard.html b/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/templates/dashboard.html index 838890cd..772ec284 100644 --- a/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/templates/dashboard.html +++ b/playground/kinde-springboot-thymeleaf-full-example/src/main/resources/templates/dashboard.html @@ -12,7 +12,18 @@

KindeAuth with Spring Boot

- Sign out + +
+ + +
@@ -60,11 +71,6 @@

Access Token:

Parse token on JWT.io -
  • - - Logout using SDK - -
  • Account Portal diff --git a/pom.xml b/pom.xml index 65b2f804..c4ab73ee 100644 --- a/pom.xml +++ b/pom.xml @@ -13,6 +13,11 @@ https://kinde.com/docs + + UTF-8 + UTF-8 + + MIT License @@ -55,11 +60,10 @@ com.fasterxml.jackson jackson-bom - 2.20.0 + 2.21.2 pom import - com.nimbusds oauth2-oidc-sdk @@ -116,7 +120,6 @@ com.google.inject guice - 7.0.0 @@ -125,27 +128,32 @@ - com.google.guava guava 33.6.0-jre - - org.projectlombok lombok 1.18.46 provided - org.slf4j slf4j-api 2.0.17 + + + org.slf4j + slf4j-simple + 2.0.17 + test + io.github.cdimascio dotenv-java @@ -184,33 +192,6 @@ 2.3.0 - - - com.fasterxml.jackson.core - jackson-core - 2.21.1 - - - com.fasterxml.jackson.core - jackson-annotations - 2.20 - - - com.fasterxml.jackson.core - jackson-databind - 2.20.0 - - - com.fasterxml.jackson.datatype - jackson-datatype-joda - 2.20.0 - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - 2.20.0 - - org.jetbrains.kotlin @@ -252,64 +233,49 @@ - - + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.5 + + @{argLine} -XX:+EnableDynamicAgentLoading + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.5.0 + - org.apache.maven.plugins maven-compiler-plugin 3.15.0 - 17 - 17 + 17 org.projectlombok lombok - 1.18.46 + 1.18.46 - - org.apache.maven.plugins - maven-jar-plugin - - - empty-javadoc-jar - package - - jar - - - javadoc - ${basedir}/javadoc - - - - - - - - org.apache.maven.plugins - maven-source-plugin - 3.3.1 - - - attach-sources - - jar-no-fork - - - - - org.jacoco jacoco-maven-plugin @@ -333,7 +299,6 @@ **/generated/** **/thirdparty/** **/openapitools/** - @@ -348,6 +313,40 @@ release + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.5.0 + + + empty-javadoc-jar + package + + jar + + + javadoc + ${basedir}/javadoc + + + + org.apache.maven.plugins maven-gpg-plugin diff --git a/src/main/java/com/example/demo/controller/MainController.java b/src/main/java/com/example/demo/controller/MainController.java deleted file mode 100644 index e69de29b..00000000