From 50fa38be945615558f1ee8d39dcbc5650bb33b69 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Tue, 1 Sep 2026 13:59:14 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20optional=20connection=5Fid=20?= =?UTF-8?q?to=20authorize=20URLs=20Allow=20login,=20register,=20and=20crea?= =?UTF-8?q?teOrg=20to=20pass=20connection=5Fid=20so=20custom=20sign-in=20p?= =?UTF-8?q?ages=20can=20skip=20Kinde=E2=80=99s=20identity=20picker.=20Forw?= =?UTF-8?q?ard=20the=20query=20parameter=20in=20J2EE=20and=20Spring=20the?= =?UTF-8?q?=20same=20way=20as=20invitation=5Fcode.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/kinde/KindeClientSession.java | 36 +++++ .../com/kinde/constants/KindeConstants.java | 1 + .../kinde/session/KindeClientSessionImpl.java | 24 ++++ .../kinde/session/KindeRequestParameters.java | 1 + .../session/KindeClientSessionImplTest.java | 89 +++++++++++++ .../filter/KindeAuthenticationFilter.java | 67 ++++++---- .../servlet/KindeAuthenticationServlet.java | 51 ++++++-- .../filter/KindeAuthenticationFilterTest.java | 123 ++++++++++++++++++ .../KindeAuthenticationServletTest.java | 82 ++++++++++++ .../src/main/java/com/kinde/spring/Kinde.java | 14 +- ...ndeOAuth2AuthorizationRequestResolver.java | 31 +++-- ...Auth2AuthorizationRequestResolverTest.java | 89 +++++++++++++ 12 files changed, 554 insertions(+), 54 deletions(-) diff --git a/kinde-core/src/main/java/com/kinde/KindeClientSession.java b/kinde-core/src/main/java/com/kinde/KindeClientSession.java index 8ec72f83..c73d9349 100644 --- a/kinde-core/src/main/java/com/kinde/KindeClientSession.java +++ b/kinde-core/src/main/java/com/kinde/KindeClientSession.java @@ -21,18 +21,54 @@ default AuthorizationUrl login(String invitationCode) { return login(); } + /** + * Starts a login flow, optionally including an invitation code and/or connection ID. + * A connection ID skips Kinde's identity picker and sends the user to a specific + * authentication method (for example a social or enterprise connection). + * + * @param invitationCode optional invitation code; ignored when null or blank + * @param connectionId optional connection ID; ignored when null or blank + * @return the authorization URL to redirect the user to + */ + default AuthorizationUrl login(String invitationCode, String connectionId) { + return login(invitationCode); + } + AuthorizationUrl createOrg(String orgName); default AuthorizationUrl createOrg(String orgName, String invitationCode) { return createOrg(orgName); } + /** + * Starts a create-organization flow, optionally including an invitation code and/or connection ID. + * + * @param orgName the organization name; must be non-blank + * @param invitationCode optional invitation code; ignored when null or blank + * @param connectionId optional connection ID; ignored when null or blank + * @return the authorization URL to redirect the user to + */ + default AuthorizationUrl createOrg(String orgName, String invitationCode, String connectionId) { + return createOrg(orgName, invitationCode); + } + AuthorizationUrl register(); default AuthorizationUrl register(String invitationCode) { return register(); } + /** + * Starts a registration flow, optionally including an invitation code and/or connection ID. + * + * @param invitationCode optional invitation code; ignored when null or blank + * @param connectionId optional connection ID; ignored when null or blank + * @return the authorization URL to redirect the user to + */ + default AuthorizationUrl register(String invitationCode, String connectionId) { + return register(invitationCode); + } + default AuthorizationUrl handleInvitation(String invitationCode) { throw new UnsupportedOperationException("handleInvitation is not supported by this implementation"); } diff --git a/kinde-core/src/main/java/com/kinde/constants/KindeConstants.java b/kinde-core/src/main/java/com/kinde/constants/KindeConstants.java index fdf3de02..a32e1f2a 100644 --- a/kinde-core/src/main/java/com/kinde/constants/KindeConstants.java +++ b/kinde-core/src/main/java/com/kinde/constants/KindeConstants.java @@ -8,5 +8,6 @@ public class KindeConstants { public final static String LANG = "lang"; public final static String ORG_NAME = "org_name"; public final static String INVITATION_CODE = "invitation_code"; + public final static String CONNECTION_ID = "connection_id"; public final static String SCOPE = "openid,email,profile"; } diff --git a/kinde-core/src/main/java/com/kinde/session/KindeClientSessionImpl.java b/kinde-core/src/main/java/com/kinde/session/KindeClientSessionImpl.java index 31d63a75..2e7027fe 100644 --- a/kinde-core/src/main/java/com/kinde/session/KindeClientSessionImpl.java +++ b/kinde-core/src/main/java/com/kinde/session/KindeClientSessionImpl.java @@ -255,9 +255,15 @@ public AuthorizationUrl login() { @Override public AuthorizationUrl login(String invitationCode) { + return login(invitationCode, null); + } + + @Override + public AuthorizationUrl login(String invitationCode, String connectionId) { Map parameters = new HashMap<>(); parameters.put("supports_reauth", "true"); addInvitationParams(parameters, invitationCode); + addConnectionIdParam(parameters, connectionId); return authorizationUrlWithParameters(parameters); } @@ -268,6 +274,11 @@ public AuthorizationUrl createOrg(String orgName) { @Override public AuthorizationUrl createOrg(String orgName, String invitationCode) { + return createOrg(orgName, invitationCode, null); + } + + @Override + public AuthorizationUrl createOrg(String orgName, String invitationCode, String connectionId) { if (orgName == null || orgName.isBlank()) { throw new IllegalArgumentException("createOrg requires a non-blank orgName"); } @@ -276,6 +287,7 @@ public AuthorizationUrl createOrg(String orgName, String invitationCode) { parameters.put("is_create_org", Boolean.TRUE.toString()); parameters.put("org_name", orgName.trim()); addInvitationParams(parameters, invitationCode); + addConnectionIdParam(parameters, connectionId); return authorizationUrlWithParameters(parameters); } @@ -286,10 +298,16 @@ public AuthorizationUrl register() { @Override public AuthorizationUrl register(String invitationCode) { + return register(invitationCode, null); + } + + @Override + public AuthorizationUrl register(String invitationCode, String connectionId) { Map parameters = new HashMap<>(); parameters.put("prompt", Prompt.Type.CREATE.toString()); parameters.put("supports_reauth", "true"); addInvitationParams(parameters, invitationCode); + addConnectionIdParam(parameters, connectionId); return authorizationUrlWithParameters(parameters); } @@ -310,6 +328,12 @@ private void addInvitationParams(Map parameters, String invitati } } + private void addConnectionIdParam(Map parameters, String connectionId) { + if (connectionId != null && !connectionId.isBlank()) { + parameters.put(KindeRequestParameters.CONNECTION_ID, connectionId.trim()); + } + } + public AuthorizationUrl logout() throws Exception { if (this.kindeConfig.logoutRedirectUri() == null || this.kindeConfig.logoutRedirectUri().isEmpty()) { throw new Exception("Logout url is not provided"); diff --git a/kinde-core/src/main/java/com/kinde/session/KindeRequestParameters.java b/kinde-core/src/main/java/com/kinde/session/KindeRequestParameters.java index a50aaed7..414a20b5 100644 --- a/kinde-core/src/main/java/com/kinde/session/KindeRequestParameters.java +++ b/kinde-core/src/main/java/com/kinde/session/KindeRequestParameters.java @@ -7,4 +7,5 @@ public class KindeRequestParameters { public final static String ORG_CODE = "org_code"; public final static String INVITATION_CODE = "invitation_code"; public final static String IS_INVITATION = "is_invitation"; + public final static String CONNECTION_ID = "connection_id"; } diff --git a/kinde-core/src/test/java/com/kinde/session/KindeClientSessionImplTest.java b/kinde-core/src/test/java/com/kinde/session/KindeClientSessionImplTest.java index 0941e3b1..f359be54 100644 --- a/kinde-core/src/test/java/com/kinde/session/KindeClientSessionImplTest.java +++ b/kinde-core/src/test/java/com/kinde/session/KindeClientSessionImplTest.java @@ -290,5 +290,94 @@ void createOrgTrimsOrgName() throws Exception { assertTrue(url.contains("org_name=TestOrg"), "URL should contain trimmed org_name"); assertFalse(url.contains("org_name=+"), "URL should not contain leading whitespace in org_name"); } + + @Test + @DisplayName("login with connectionId includes connection_id on the authorize URL") + void loginWithConnectionIdIncludesParam() throws Exception { + KindeClientSessionImpl session = createSessionWithOidc(); + AuthorizationUrl result = session.login(null, "conn_abc123"); + + assertNotNull(result); + String url = result.getUrl().toString(); + assertTrue(url.contains("connection_id=conn_abc123"), "URL should contain connection_id"); + assertTrue(url.contains("supports_reauth=true"), "URL should still contain supports_reauth"); + assertFalse(url.contains("invitation_code"), "URL should not contain invitation_code"); + } + + @Test + @DisplayName("login with invitationCode and connectionId includes both params") + void loginWithInvitationAndConnectionIdIncludesBothParams() throws Exception { + KindeClientSessionImpl session = createSessionWithOidc(); + AuthorizationUrl result = session.login("inv_login123", "conn_abc123"); + + assertNotNull(result); + String url = result.getUrl().toString(); + assertTrue(url.contains("invitation_code=inv_login123"), "URL should contain invitation_code"); + assertTrue(url.contains("is_invitation=true"), "URL should contain is_invitation=true"); + assertTrue(url.contains("connection_id=conn_abc123"), "URL should contain connection_id"); + assertTrue(url.contains("supports_reauth=true"), "URL should contain supports_reauth"); + } + + @Test + @DisplayName("login with whitespace-only connectionId omits connection_id") + void loginWithWhitespaceOnlyConnectionIdOmitsParam() throws Exception { + KindeClientSessionImpl session = createSessionWithOidc(); + AuthorizationUrl result = session.login(null, " "); + + assertNotNull(result); + String url = result.getUrl().toString(); + assertFalse(url.contains("connection_id"), "URL should not contain connection_id"); + assertTrue(url.contains("supports_reauth=true"), "URL should still contain supports_reauth"); + } + + @Test + @DisplayName("login trims connectionId in the generated URL") + void loginTrimsConnectionId() throws Exception { + KindeClientSessionImpl session = createSessionWithOidc(); + AuthorizationUrl result = session.login(null, " conn_abc123 "); + + assertNotNull(result); + String url = result.getUrl().toString(); + assertTrue(url.contains("connection_id=conn_abc123"), "URL should contain trimmed connection_id"); + } + + @Test + @DisplayName("register with connectionId includes connection_id and register params") + void registerWithConnectionIdIncludesParam() throws Exception { + KindeClientSessionImpl session = createSessionWithOidc(); + AuthorizationUrl result = session.register(null, "conn_reg456"); + + assertNotNull(result); + String url = result.getUrl().toString(); + assertTrue(url.contains("connection_id=conn_reg456"), "URL should contain connection_id"); + assertTrue(url.contains("prompt=create"), "URL should contain register-specific prompt=create"); + assertTrue(url.contains("supports_reauth=true"), "URL should contain supports_reauth"); + } + + @Test + @DisplayName("createOrg with connectionId includes connection_id and createOrg params") + void createOrgWithConnectionIdIncludesParam() throws Exception { + KindeClientSessionImpl session = createSessionWithOidc(); + AuthorizationUrl result = session.createOrg("TestOrg", null, "conn_org789"); + + assertNotNull(result); + String url = result.getUrl().toString(); + assertTrue(url.contains("connection_id=conn_org789"), "URL should contain connection_id"); + assertTrue(url.contains("org_name=TestOrg"), "URL should contain org_name"); + assertTrue(url.contains("is_create_org=true"), "URL should contain is_create_org"); + assertFalse(url.contains("invitation_code"), "URL should not contain invitation_code"); + } + + @Test + @DisplayName("authorizationUrlWithParameters includes connection_id from the map") + void authorizationUrlWithParametersIncludesConnectionId() throws Exception { + KindeClientSessionImpl session = createSessionWithOidc(); + AuthorizationUrl result = session.authorizationUrlWithParameters( + java.util.Map.of("connection_id", "conn_map123")); + + assertNotNull(result); + String url = result.getUrl().toString(); + assertTrue(url.contains("connection_id=conn_map123"), "URL should contain connection_id from parameters map"); + } } diff --git a/kinde-j2ee/src/main/java/com/kinde/filter/KindeAuthenticationFilter.java b/kinde-j2ee/src/main/java/com/kinde/filter/KindeAuthenticationFilter.java index 97f8ba4e..3bc032d4 100644 --- a/kinde-j2ee/src/main/java/com/kinde/filter/KindeAuthenticationFilter.java +++ b/kinde-j2ee/src/main/java/com/kinde/filter/KindeAuthenticationFilter.java @@ -51,40 +51,20 @@ protected void doFilter(ServletRequest servletRequest, ServletResponse servletRe } String code = req.getParameter("code"); - String rawInvitationCode = req.getParameter(INVITATION_CODE); - String invitationCode = (rawInvitationCode != null && !rawInvitationCode.isBlank()) ? rawInvitationCode.trim() : null; + String invitationCode = optionalQueryParam(req, INVITATION_CODE); + String connectionId = optionalQueryParam(req, CONNECTION_ID); Principal userPrincipal = (Principal) req.getSession().getAttribute(AUTHENTICATED_USER); AuthorizationUrl authorizationUrl = (AuthorizationUrl)req.getSession().getAttribute(AUTHORIZATION_URL); if (invitationCode != null) { // Invitation code always starts a new auth flow, even if already authenticated KindeClientSession kindeClientSession = createKindeClientSession(req); - if (kindeAuthenticationAction == KindeAuthenticationAction.LOGIN) { - authorizationUrl = kindeClientSession.login(invitationCode); - } else if (kindeAuthenticationAction == KindeAuthenticationAction.REGISTER) { - authorizationUrl = kindeClientSession.register(invitationCode); - } else if (kindeAuthenticationAction == KindeAuthenticationAction.CREATE_ORG) { - String orgName = req.getParameter(ORG_NAME); - if (orgName == null || orgName.isBlank()) { - throw new ServletException("Must provide org_name query parameter to create an organisation."); - } - authorizationUrl = kindeClientSession.createOrg(orgName.trim(), invitationCode); - } + authorizationUrl = startAuthorization(kindeClientSession, kindeAuthenticationAction, req, invitationCode, connectionId); req.getSession().setAttribute(AUTHORIZATION_URL, authorizationUrl); resp.sendRedirect(authorizationUrl.getUrl().toString()); } else if (userPrincipal == null || authorizationUrl == null) { // Redirect to the OAuth provider's authorization page KindeClientSession kindeClientSession = createKindeClientSession(req); - if (kindeAuthenticationAction == KindeAuthenticationAction.LOGIN) { - authorizationUrl = kindeClientSession.login(); - } else if (kindeAuthenticationAction == KindeAuthenticationAction.REGISTER) { - authorizationUrl = kindeClientSession.register(); - } else if (kindeAuthenticationAction == KindeAuthenticationAction.CREATE_ORG) { - String orgName = req.getParameter(ORG_NAME); - if (orgName == null || orgName.isBlank()) { - throw new ServletException("Must provide org_name query parameter to create an organisation."); - } - authorizationUrl = kindeClientSession.createOrg(orgName.trim()); - } + authorizationUrl = startAuthorization(kindeClientSession, kindeAuthenticationAction, req, null, connectionId); req.getSession().setAttribute(AUTHORIZATION_URL,authorizationUrl); resp.sendRedirect(authorizationUrl.getUrl().toString()); } else if (code != null) { @@ -112,6 +92,45 @@ protected void doFilter(ServletRequest servletRequest, ServletResponse servletRe } } + private static AuthorizationUrl startAuthorization( + KindeClientSession kindeClientSession, + KindeAuthenticationAction kindeAuthenticationAction, + HttpServletRequest req, + String invitationCode, + String connectionId) throws ServletException { + if (kindeAuthenticationAction == KindeAuthenticationAction.LOGIN) { + if (connectionId != null) { + return kindeClientSession.login(invitationCode, connectionId); + } + return invitationCode != null ? kindeClientSession.login(invitationCode) : kindeClientSession.login(); + } + if (kindeAuthenticationAction == KindeAuthenticationAction.REGISTER) { + if (connectionId != null) { + return kindeClientSession.register(invitationCode, connectionId); + } + return invitationCode != null ? kindeClientSession.register(invitationCode) : kindeClientSession.register(); + } + if (kindeAuthenticationAction == KindeAuthenticationAction.CREATE_ORG) { + String orgName = req.getParameter(ORG_NAME); + if (orgName == null || orgName.isBlank()) { + throw new ServletException("Must provide org_name query parameter to create an organisation."); + } + String trimmedOrgName = orgName.trim(); + if (connectionId != null) { + return kindeClientSession.createOrg(trimmedOrgName, invitationCode, connectionId); + } + return invitationCode != null + ? kindeClientSession.createOrg(trimmedOrgName, invitationCode) + : kindeClientSession.createOrg(trimmedOrgName); + } + throw new ServletException("Unknown authentication action: " + kindeAuthenticationAction); + } + + private static String optionalQueryParam(HttpServletRequest req, String name) { + String raw = req.getParameter(name); + return (raw != null && !raw.isBlank()) ? raw.trim() : null; + } + private static KindeClientSession createKindeClientSession(HttpServletRequest req) { return KindeSingleton .getInstance() diff --git a/kinde-j2ee/src/main/java/com/kinde/servlet/KindeAuthenticationServlet.java b/kinde-j2ee/src/main/java/com/kinde/servlet/KindeAuthenticationServlet.java index cccaf0b9..fe7c676b 100644 --- a/kinde-j2ee/src/main/java/com/kinde/servlet/KindeAuthenticationServlet.java +++ b/kinde-j2ee/src/main/java/com/kinde/servlet/KindeAuthenticationServlet.java @@ -46,8 +46,8 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp, KindeAuth } String code = req.getParameter("code"); - String rawInvitationCode = req.getParameter(INVITATION_CODE); - String invitationCode = (rawInvitationCode != null && !rawInvitationCode.isBlank()) ? rawInvitationCode.trim() : null; + String invitationCode = optionalQueryParam(req, INVITATION_CODE); + String connectionId = optionalQueryParam(req, CONNECTION_ID); if (code == null) { String postLoginUrl = req.getParameter(POST_LOGIN_URL); if (postLoginUrl == null) { @@ -55,18 +55,8 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp, KindeAuth } // Redirect to the OAuth provider's authorization page KindeClientSession kindeClientSession = createKindeClientSession(req); - AuthorizationUrl authorizationUrl = null; - if (kindeAuthenticationAction == KindeAuthenticationAction.LOGIN) { - authorizationUrl = kindeClientSession.login(invitationCode); - } else if (kindeAuthenticationAction == KindeAuthenticationAction.REGISTER) { - authorizationUrl = kindeClientSession.register(invitationCode); - } else if (kindeAuthenticationAction == KindeAuthenticationAction.CREATE_ORG) { - String orgName = req.getParameter(ORG_NAME); - if (orgName == null || orgName.isBlank()) { - throw new ServletException("Must provide org_name query parameter to create an organisation."); - } - authorizationUrl = kindeClientSession.createOrg(orgName.trim(), invitationCode); - } + AuthorizationUrl authorizationUrl = startAuthorization( + kindeClientSession, kindeAuthenticationAction, req, invitationCode, connectionId); req.getSession().setAttribute(AUTHORIZATION_URL,authorizationUrl); req.getSession().setAttribute(POST_LOGIN_URL,postLoginUrl); resp.sendRedirect(authorizationUrl.getUrl().toString()); @@ -92,6 +82,39 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp, KindeAuth } } + private static AuthorizationUrl startAuthorization( + KindeClientSession kindeClientSession, + KindeAuthenticationAction kindeAuthenticationAction, + HttpServletRequest req, + String invitationCode, + String connectionId) throws ServletException { + if (kindeAuthenticationAction == KindeAuthenticationAction.LOGIN) { + return connectionId != null + ? kindeClientSession.login(invitationCode, connectionId) + : kindeClientSession.login(invitationCode); + } + if (kindeAuthenticationAction == KindeAuthenticationAction.REGISTER) { + return connectionId != null + ? kindeClientSession.register(invitationCode, connectionId) + : kindeClientSession.register(invitationCode); + } + if (kindeAuthenticationAction == KindeAuthenticationAction.CREATE_ORG) { + String orgName = req.getParameter(ORG_NAME); + if (orgName == null || orgName.isBlank()) { + throw new ServletException("Must provide org_name query parameter to create an organisation."); + } + return connectionId != null + ? kindeClientSession.createOrg(orgName.trim(), invitationCode, connectionId) + : kindeClientSession.createOrg(orgName.trim(), invitationCode); + } + throw new ServletException("Unknown authentication action: " + kindeAuthenticationAction); + } + + private static String optionalQueryParam(HttpServletRequest req, String name) { + String raw = req.getParameter(name); + return (raw != null && !raw.isBlank()) ? raw.trim() : null; + } + private static KindeClientSession createKindeClientSession(HttpServletRequest req) { return KindeSingleton .getInstance() diff --git a/kinde-j2ee/src/test/java/com/kinde/filter/KindeAuthenticationFilterTest.java b/kinde-j2ee/src/test/java/com/kinde/filter/KindeAuthenticationFilterTest.java index 1c43c852..ee4cfa35 100644 --- a/kinde-j2ee/src/test/java/com/kinde/filter/KindeAuthenticationFilterTest.java +++ b/kinde-j2ee/src/test/java/com/kinde/filter/KindeAuthenticationFilterTest.java @@ -333,4 +333,127 @@ public void testCreateOrgBlankOrgNameThrowsWhenNoPrincipal() throws Exception { filter.doFilter(request, response, filterChain, KindeAuthenticationAction.CREATE_ORG); } + + @Test + public void testConnectionIdOnLoginPassesIdToLogin() throws Exception { + when(session.getAttribute(AUTHENTICATED_USER)).thenReturn(null); + when(session.getAttribute(AUTHORIZATION_URL)).thenReturn(null); + when(request.getParameter("connection_id")).thenReturn("conn_filter123"); + AuthorizationUrl connectionAuthUrl = mock(AuthorizationUrl.class); + when(connectionAuthUrl.getUrl()).thenReturn(new URL("http://auth.url?connection_id=conn_filter123")); + when(mockSession.login(null, "conn_filter123")).thenReturn(connectionAuthUrl); + + filter.doFilter(request, response, filterChain, KindeAuthenticationAction.LOGIN); + + verify(mockSession).login(null, "conn_filter123"); + verify(session).setAttribute(AUTHORIZATION_URL, connectionAuthUrl); + verify(response).sendRedirect(connectionAuthUrl.getUrl().toString()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + public void testConnectionIdOnRegisterPassesIdToRegister() throws Exception { + when(session.getAttribute(AUTHENTICATED_USER)).thenReturn(null); + when(session.getAttribute(AUTHORIZATION_URL)).thenReturn(null); + when(request.getParameter("connection_id")).thenReturn("conn_filter_reg"); + AuthorizationUrl connectionAuthUrl = mock(AuthorizationUrl.class); + when(connectionAuthUrl.getUrl()).thenReturn(new URL("http://auth.url?connection_id=conn_filter_reg")); + when(mockSession.register(null, "conn_filter_reg")).thenReturn(connectionAuthUrl); + + filter.doFilter(request, response, filterChain, KindeAuthenticationAction.REGISTER); + + verify(mockSession).register(null, "conn_filter_reg"); + verify(response).sendRedirect(connectionAuthUrl.getUrl().toString()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + public void testConnectionIdOnCreateOrgPassesIdToCreateOrg() throws Exception { + when(session.getAttribute(AUTHENTICATED_USER)).thenReturn(null); + when(session.getAttribute(AUTHORIZATION_URL)).thenReturn(null); + when(request.getParameter("connection_id")).thenReturn("conn_org_create"); + when(request.getParameter("org_name")).thenReturn("MyOrg"); + AuthorizationUrl connectionAuthUrl = mock(AuthorizationUrl.class); + when(connectionAuthUrl.getUrl()).thenReturn(new URL("http://auth.url?connection_id=conn_org_create")); + when(mockSession.createOrg("MyOrg", null, "conn_org_create")).thenReturn(connectionAuthUrl); + + filter.doFilter(request, response, filterChain, KindeAuthenticationAction.CREATE_ORG); + + verify(mockSession).createOrg("MyOrg", null, "conn_org_create"); + verify(response).sendRedirect(connectionAuthUrl.getUrl().toString()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + public void testInvitationCodeAndConnectionIdArePassedTogether() throws Exception { + when(request.getParameter("invitation_code")).thenReturn("inv_filter123"); + when(request.getParameter("connection_id")).thenReturn("conn_filter123"); + AuthorizationUrl combinedAuthUrl = mock(AuthorizationUrl.class); + when(combinedAuthUrl.getUrl()).thenReturn(new URL("http://auth.url?invitation_code=inv_filter123&connection_id=conn_filter123")); + when(mockSession.login("inv_filter123", "conn_filter123")).thenReturn(combinedAuthUrl); + + filter.doFilter(request, response, filterChain, KindeAuthenticationAction.LOGIN); + + verify(mockSession).login("inv_filter123", "conn_filter123"); + verify(response).sendRedirect(combinedAuthUrl.getUrl().toString()); + verify(filterChain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + } + + @Test + public void testEmptyConnectionIdFallsThroughToLogin() throws Exception { + when(request.getParameter("connection_id")).thenReturn(""); + when(session.getAttribute(AUTHENTICATED_USER)).thenReturn(null); + when(session.getAttribute(AUTHORIZATION_URL)).thenReturn(null); + when(mockAuthUrl.getUrl()).thenReturn(new URL("http://auth.url")); + when(mockSession.login()).thenReturn(mockAuthUrl); + + filter.doFilter(request, response, filterChain, KindeAuthenticationAction.LOGIN); + + verify(mockSession).login(); + verify(response).sendRedirect("http://auth.url"); + } + + @Test + public void testWhitespaceOnlyConnectionIdFallsThroughToLogin() throws Exception { + when(request.getParameter("connection_id")).thenReturn(" "); + when(session.getAttribute(AUTHENTICATED_USER)).thenReturn(null); + when(session.getAttribute(AUTHORIZATION_URL)).thenReturn(null); + when(mockAuthUrl.getUrl()).thenReturn(new URL("http://auth.url")); + when(mockSession.login()).thenReturn(mockAuthUrl); + + filter.doFilter(request, response, filterChain, KindeAuthenticationAction.LOGIN); + + verify(mockSession).login(); + verify(response).sendRedirect("http://auth.url"); + } + + @Test + public void testPaddedConnectionIdIsTrimmedBeforePassing() throws Exception { + when(session.getAttribute(AUTHENTICATED_USER)).thenReturn(null); + when(session.getAttribute(AUTHORIZATION_URL)).thenReturn(null); + when(request.getParameter("connection_id")).thenReturn(" conn_filter123 "); + AuthorizationUrl connectionAuthUrl = mock(AuthorizationUrl.class); + when(connectionAuthUrl.getUrl()).thenReturn(new URL("http://auth.url?connection_id=conn_filter123")); + when(mockSession.login(null, "conn_filter123")).thenReturn(connectionAuthUrl); + + filter.doFilter(request, response, filterChain, KindeAuthenticationAction.LOGIN); + + verify(mockSession).login(null, "conn_filter123"); + verify(response).sendRedirect(connectionAuthUrl.getUrl().toString()); + } + + @Test + public void testConnectionIdDoesNotOverrideExistingSession() throws Exception { + Principal principal = mock(KindePrincipal.class); + when(session.getAttribute(AUTHENTICATED_USER)).thenReturn(principal); + when(session.getAttribute(AUTHORIZATION_URL)).thenReturn(mockAuthUrl); + when(request.getParameter("connection_id")).thenReturn("conn_should_not_reauth"); + when(request.getParameter("code")).thenReturn(null); + + filter.doFilter(request, response, filterChain, KindeAuthenticationAction.LOGIN); + + verify(mockSession, never()).login(any(), any()); + verify(filterChain).doFilter(any(ServletRequest.class), any(ServletResponse.class)); + verify(response, never()).sendRedirect(anyString()); + } } \ No newline at end of file diff --git a/kinde-j2ee/src/test/java/com/kinde/servlet/KindeAuthenticationServletTest.java b/kinde-j2ee/src/test/java/com/kinde/servlet/KindeAuthenticationServletTest.java index 04d0b4dd..20b28daa 100644 --- a/kinde-j2ee/src/test/java/com/kinde/servlet/KindeAuthenticationServletTest.java +++ b/kinde-j2ee/src/test/java/com/kinde/servlet/KindeAuthenticationServletTest.java @@ -265,4 +265,86 @@ public void testDoGet_CreateOrgWithBlankOrgName_Throws() throws Exception { servlet.doGet(request, response, KindeAuthenticationAction.CREATE_ORG); } + + @Test + public void testDoGet_LoginWithConnectionId_PassesIdToLogin() throws Exception { + when(request.getParameter("connection_id")).thenReturn("conn_test123"); + when(request.getParameter(POST_LOGIN_URL)).thenReturn("http://example.com/dashboard"); + when(mockAuthUrl.getUrl()).thenReturn(new URL("http://kinde.com/oauth2/auth?connection_id=conn_test123")); + when(mockSession.login(null, "conn_test123")).thenReturn(mockAuthUrl); + + servlet.doGet(request, response, KindeAuthenticationAction.LOGIN); + + verify(mockSession).login(null, "conn_test123"); + verify(session).setAttribute(AUTHORIZATION_URL, mockAuthUrl); + verify(session).setAttribute(POST_LOGIN_URL, "http://example.com/dashboard"); + verify(response).sendRedirect(mockAuthUrl.getUrl().toString()); + } + + @Test + public void testDoGet_RegisterWithConnectionId_PassesIdToRegister() throws Exception { + when(request.getParameter("connection_id")).thenReturn("conn_reg456"); + when(request.getParameter(POST_LOGIN_URL)).thenReturn("http://example.com/dashboard"); + when(mockAuthUrl.getUrl()).thenReturn(new URL("http://kinde.com/oauth2/auth?connection_id=conn_reg456")); + when(mockSession.register(null, "conn_reg456")).thenReturn(mockAuthUrl); + + servlet.doGet(request, response, KindeAuthenticationAction.REGISTER); + + verify(mockSession).register(null, "conn_reg456"); + verify(response).sendRedirect(mockAuthUrl.getUrl().toString()); + } + + @Test + public void testDoGet_CreateOrgWithConnectionId_PassesIdToCreateOrg() throws Exception { + when(request.getParameter("connection_id")).thenReturn("conn_org789"); + when(request.getParameter(POST_LOGIN_URL)).thenReturn("http://example.com/dashboard"); + when(request.getParameter("org_name")).thenReturn("TestOrg"); + when(mockAuthUrl.getUrl()).thenReturn(new URL("http://kinde.com/oauth2/auth?connection_id=conn_org789")); + when(mockSession.createOrg("TestOrg", null, "conn_org789")).thenReturn(mockAuthUrl); + + servlet.doGet(request, response, KindeAuthenticationAction.CREATE_ORG); + + verify(mockSession).createOrg("TestOrg", null, "conn_org789"); + verify(response).sendRedirect(mockAuthUrl.getUrl().toString()); + } + + @Test + public void testDoGet_InvitationCodeAndConnectionId_ArePassedTogether() throws Exception { + when(request.getParameter("invitation_code")).thenReturn("inv_test123"); + when(request.getParameter("connection_id")).thenReturn("conn_test123"); + when(request.getParameter(POST_LOGIN_URL)).thenReturn("http://example.com/dashboard"); + when(mockAuthUrl.getUrl()).thenReturn(new URL("http://kinde.com/oauth2/auth?invitation_code=inv_test123&connection_id=conn_test123")); + when(mockSession.login("inv_test123", "conn_test123")).thenReturn(mockAuthUrl); + + servlet.doGet(request, response, KindeAuthenticationAction.LOGIN); + + verify(mockSession).login("inv_test123", "conn_test123"); + verify(response).sendRedirect(mockAuthUrl.getUrl().toString()); + } + + @Test + public void testDoGet_PaddedConnectionId_TrimsBeforePassing() throws Exception { + when(request.getParameter("connection_id")).thenReturn(" conn_test123 "); + when(request.getParameter(POST_LOGIN_URL)).thenReturn("http://example.com/dashboard"); + when(mockAuthUrl.getUrl()).thenReturn(new URL("http://kinde.com/oauth2/auth?connection_id=conn_test123")); + when(mockSession.login(null, "conn_test123")).thenReturn(mockAuthUrl); + + servlet.doGet(request, response, KindeAuthenticationAction.LOGIN); + + verify(mockSession).login(null, "conn_test123"); + verify(response).sendRedirect(mockAuthUrl.getUrl().toString()); + } + + @Test + public void testDoGet_EmptyConnectionId_FallsThrough() throws Exception { + when(request.getParameter("connection_id")).thenReturn(""); + when(request.getParameter("code")).thenReturn(null); + when(request.getParameter(POST_LOGIN_URL)).thenReturn("http://example.com"); + when(mockAuthUrl.getUrl()).thenReturn(new URL("http://test.kinde.com")); + when(mockSession.login((String) null)).thenReturn(mockAuthUrl); + + servlet.doGet(request, response, KindeAuthenticationAction.LOGIN); + + verify(mockSession).login((String) null); + } } \ No newline at end of file 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 4dd4fa25..027811c9 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 @@ -12,11 +12,14 @@ public class Kinde { /** - * Configures the {@code http} with an OAuth2 Login, that supports PKCE and invitation code handling. - * The default Spring Security implementation only enables PKCE for public clients. + * Configures the {@code http} with an OAuth2 Login, that supports PKCE, invitation code, + * and connection ID handling. The default Spring Security implementation only enables PKCE + * for public clients. *

* When the originating request contains an {@code invitation_code} query parameter, * it is forwarded to the authorization endpoint along with {@code is_invitation=true}. + * When it contains a {@code connection_id} query parameter, that value is forwarded + * so Kinde can skip the identity picker. *

* NOTE: Enabling PKCE will be required for all clients (public and confidential) in the future OAuth 2.1 spec. * @@ -39,9 +42,10 @@ public static HttpSecurity configureOAuth2WithPkce(HttpSecurity http, ClientRegi * Configures the {@code http} with an OAuth2 Login that supports PKCE. *

* NOTE: This reactive overload does not currently forward {@code invitation_code} - * parameters to the authorization endpoint. Invitation code handling is only supported - * via the servlet-based {@link #configureOAuth2WithPkce(HttpSecurity, ClientRegistrationRepository)} - * overload. If you need invitation support in a reactive application, implement a custom + * or {@code connection_id} parameters to the authorization endpoint. Those are only + * supported via the servlet-based + * {@link #configureOAuth2WithPkce(HttpSecurity, ClientRegistrationRepository)} overload. + * If you need them in a reactive application, implement a custom * {@code ServerOAuth2AuthorizationRequestResolver} that mirrors the logic in * {@link KindeOAuth2AuthorizationRequestResolver}. * diff --git a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2AuthorizationRequestResolver.java b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2AuthorizationRequestResolver.java index 77deb867..ca1aa44a 100644 --- a/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2AuthorizationRequestResolver.java +++ b/kinde-springboot/kinde-springboot-core/src/main/java/com/kinde/spring/KindeOAuth2AuthorizationRequestResolver.java @@ -14,11 +14,12 @@ import static org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers.withPkce; /** - * Custom OAuth2 authorization request resolver that adds invitation code support - * on top of the default PKCE-enabled resolver. When the originating HTTP request - * contains an {@code invitation_code} parameter, this resolver appends - * {@code invitation_code} and {@code is_invitation=true} to the authorization - * request's additional parameters. + * Custom OAuth2 authorization request resolver that adds Kinde-specific + * authorize URL parameters on top of the default PKCE-enabled resolver. + * When the originating HTTP request contains an {@code invitation_code} + * parameter, this resolver appends {@code invitation_code} and + * {@code is_invitation=true}. When it contains a {@code connection_id} + * parameter, that value is forwarded so Kinde can skip the identity picker. */ public class KindeOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { @@ -35,27 +36,35 @@ public KindeOAuth2AuthorizationRequestResolver( @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request); - return addInvitationParameters(request, authorizationRequest); + return addKindeParameters(request, authorizationRequest); } @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request, clientRegistrationId); - return addInvitationParameters(request, authorizationRequest); + return addKindeParameters(request, authorizationRequest); } - private OAuth2AuthorizationRequest addInvitationParameters( + private OAuth2AuthorizationRequest addKindeParameters( HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) { if (authorizationRequest == null) { return null; } String invitationCode = request.getParameter(KindeRequestParameters.INVITATION_CODE); - if (invitationCode == null || invitationCode.isBlank()) { + String connectionId = request.getParameter(KindeRequestParameters.CONNECTION_ID); + boolean hasInvitation = invitationCode != null && !invitationCode.isBlank(); + boolean hasConnectionId = connectionId != null && !connectionId.isBlank(); + if (!hasInvitation && !hasConnectionId) { return authorizationRequest; } Map additionalParams = new HashMap<>(authorizationRequest.getAdditionalParameters()); - additionalParams.put(KindeRequestParameters.INVITATION_CODE, invitationCode); - additionalParams.put(KindeRequestParameters.IS_INVITATION, "true"); + if (hasInvitation) { + additionalParams.put(KindeRequestParameters.INVITATION_CODE, invitationCode); + additionalParams.put(KindeRequestParameters.IS_INVITATION, "true"); + } + if (hasConnectionId && connectionId != null) { + additionalParams.put(KindeRequestParameters.CONNECTION_ID, connectionId.trim()); + } return OAuth2AuthorizationRequest.from(authorizationRequest) .additionalParameters(additionalParams) .build(); diff --git a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2AuthorizationRequestResolverTest.java b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2AuthorizationRequestResolverTest.java index 3e20390c..2257dea0 100644 --- a/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2AuthorizationRequestResolverTest.java +++ b/kinde-springboot/kinde-springboot-core/src/test/java/com/kinde/spring/KindeOAuth2AuthorizationRequestResolverTest.java @@ -156,4 +156,93 @@ void resolveWithClientRegistrationIdWithWhitespaceOnlyInvitationCodeDoesNotAddPa assertNull(authRequest.getAdditionalParameters().get("invitation_code")); assertNull(authRequest.getAdditionalParameters().get("is_invitation")); } + + @Test + @DisplayName("Resolve with connection_id adds connection_id to authorization request") + void resolveWithConnectionIdAddsParam() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/kinde"); + request.setServletPath("/oauth2/authorization/kinde"); + request.setParameter("connection_id", "conn_abc123"); + + OAuth2AuthorizationRequest authRequest = resolver.resolve(request); + + assertNotNull(authRequest); + assertEquals("conn_abc123", authRequest.getAdditionalParameters().get("connection_id")); + assertNull(authRequest.getAdditionalParameters().get("invitation_code")); + + String authUri = authRequest.getAuthorizationRequestUri(); + assertTrue(authUri.contains("connection_id=conn_abc123"), "Redirect URI should contain connection_id"); + } + + @Test + @DisplayName("Resolve with invitation_code and connection_id adds both params") + void resolveWithInvitationCodeAndConnectionIdAddsBothParams() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/kinde"); + request.setServletPath("/oauth2/authorization/kinde"); + request.setParameter("invitation_code", "inv_abc123"); + request.setParameter("connection_id", "conn_abc123"); + + OAuth2AuthorizationRequest authRequest = resolver.resolve(request); + + assertNotNull(authRequest); + assertEquals("inv_abc123", authRequest.getAdditionalParameters().get("invitation_code")); + assertEquals("true", authRequest.getAdditionalParameters().get("is_invitation")); + assertEquals("conn_abc123", authRequest.getAdditionalParameters().get("connection_id")); + + String authUri = authRequest.getAuthorizationRequestUri(); + assertTrue(authUri.contains("invitation_code=inv_abc123")); + assertTrue(authUri.contains("connection_id=conn_abc123")); + } + + @Test + @DisplayName("Resolve with empty connection_id does not add connection_id") + void resolveWithEmptyConnectionIdDoesNotAddParam() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/kinde"); + request.setServletPath("/oauth2/authorization/kinde"); + request.setParameter("connection_id", ""); + + OAuth2AuthorizationRequest authRequest = resolver.resolve(request); + + assertNotNull(authRequest); + assertNull(authRequest.getAdditionalParameters().get("connection_id")); + } + + @Test + @DisplayName("Resolve with whitespace-only connection_id does not add connection_id") + void resolveWithWhitespaceOnlyConnectionIdDoesNotAddParam() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/kinde"); + request.setServletPath("/oauth2/authorization/kinde"); + request.setParameter("connection_id", " "); + + OAuth2AuthorizationRequest authRequest = resolver.resolve(request); + + assertNotNull(authRequest); + assertNull(authRequest.getAdditionalParameters().get("connection_id")); + } + + @Test + @DisplayName("Resolve trims connection_id before adding it") + void resolveTrimsConnectionId() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/kinde"); + request.setServletPath("/oauth2/authorization/kinde"); + request.setParameter("connection_id", " conn_abc123 "); + + OAuth2AuthorizationRequest authRequest = resolver.resolve(request); + + assertNotNull(authRequest); + assertEquals("conn_abc123", authRequest.getAdditionalParameters().get("connection_id")); + } + + @Test + @DisplayName("Resolve with clientRegistrationId and connection_id adds param") + void resolveWithClientRegistrationIdAndConnectionIdAddsParam() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/kinde"); + request.setServletPath("/oauth2/authorization/kinde"); + request.setParameter("connection_id", "conn_client_reg"); + + OAuth2AuthorizationRequest authRequest = resolver.resolve(request, "kinde"); + + assertNotNull(authRequest); + assertEquals("conn_client_reg", authRequest.getAdditionalParameters().get("connection_id")); + } } From c08b241d074f12a279a166748f4ce58f8f92f906 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Tue, 1 Sep 2026 14:35:53 +0200 Subject: [PATCH 2/2] fix: fail closed on unsupported connectionId and send playground login to /home --- .../main/java/com/kinde/KindeClientSession.java | 15 +++++++++++++++ .../com/kinde/spring/config/SecurityConfig.java | 1 + 2 files changed, 16 insertions(+) diff --git a/kinde-core/src/main/java/com/kinde/KindeClientSession.java b/kinde-core/src/main/java/com/kinde/KindeClientSession.java index c73d9349..873fbfd4 100644 --- a/kinde-core/src/main/java/com/kinde/KindeClientSession.java +++ b/kinde-core/src/main/java/com/kinde/KindeClientSession.java @@ -29,8 +29,11 @@ default AuthorizationUrl login(String invitationCode) { * @param invitationCode optional invitation code; ignored when null or blank * @param connectionId optional connection ID; ignored when null or blank * @return the authorization URL to redirect the user to + * @throws UnsupportedOperationException if {@code connectionId} is non-blank and this + * implementation does not support connection IDs */ default AuthorizationUrl login(String invitationCode, String connectionId) { + requireConnectionIdSupport(connectionId); return login(invitationCode); } @@ -47,8 +50,11 @@ default AuthorizationUrl createOrg(String orgName, String invitationCode) { * @param invitationCode optional invitation code; ignored when null or blank * @param connectionId optional connection ID; ignored when null or blank * @return the authorization URL to redirect the user to + * @throws UnsupportedOperationException if {@code connectionId} is non-blank and this + * implementation does not support connection IDs */ default AuthorizationUrl createOrg(String orgName, String invitationCode, String connectionId) { + requireConnectionIdSupport(connectionId); return createOrg(orgName, invitationCode); } @@ -64,8 +70,11 @@ default AuthorizationUrl register(String invitationCode) { * @param invitationCode optional invitation code; ignored when null or blank * @param connectionId optional connection ID; ignored when null or blank * @return the authorization URL to redirect the user to + * @throws UnsupportedOperationException if {@code connectionId} is non-blank and this + * implementation does not support connection IDs */ default AuthorizationUrl register(String invitationCode, String connectionId) { + requireConnectionIdSupport(connectionId); return register(invitationCode); } @@ -106,4 +115,10 @@ default String getDomain() { default String getAccessToken() { return null; } + + private static void requireConnectionIdSupport(String connectionId) { + if (connectionId != null && !connectionId.isBlank()) { + throw new UnsupportedOperationException("connectionId is not supported by this implementation"); + } + } } 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 f3905abb..23da5e50 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 @@ -51,6 +51,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, OAuth2AuthorizationRe .requestMatchers("/","/index.css", "/registration", "/oauth2/authorization/**").permitAll() .anyRequest().authenticated()) .oauth2Login(oauth2Login -> oauth2Login + .defaultSuccessUrl("/home", true) .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint .authorizationRequestResolver(customAuthorizationRequestResolver)) );