From 33499d537edbfdb87c2b530877ce0b5a98dd6dda Mon Sep 17 00:00:00 2001 From: a Date: Sat, 13 Jun 2026 11:02:52 +0100 Subject: [PATCH] Implement OAuth2/OIDC login (authorize redirect + callback servlet) end-to-end --- .../ApplicationPropertiesConfigOverlay.java | 14 +- .../JwtBearerAuthenticationMechanism.java | 6 + .../oauth2/OAuth2CallbackServlet.java | 228 ++++++++++++++++++ .../oauth2/OAuth2LoginController.java | 88 +++++++ 4 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2CallbackServlet.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2LoginController.java diff --git a/app/common/src/main/java/stirling/software/common/configuration/ApplicationPropertiesConfigOverlay.java b/app/common/src/main/java/stirling/software/common/configuration/ApplicationPropertiesConfigOverlay.java index bb6f08a016..a441b6ec37 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/ApplicationPropertiesConfigOverlay.java +++ b/app/common/src/main/java/stirling/software/common/configuration/ApplicationPropertiesConfigOverlay.java @@ -51,12 +51,24 @@ public class ApplicationPropertiesConfigOverlay { applyString(config, "security.customGlobalAPIKey", security::setCustomGlobalAPIKey); applyBoolean(config, "storage.enabled", applicationProperties.getStorage()::setEnabled); - // SSO toggles - the detailed provider config is bound by the OIDC/SAML wiring. + // SSO toggles. The detailed OAuth2 provider config (issuer/clientId/...) is read directly + // from MicroProfile config by OAuth2LoginController; the SAML provider config likewise by + // the + // SAML SP. Only the booleans the service layer reads via ApplicationProperties are bound + // here. if (security.getSaml2() != null) { applyBoolean(config, "security.saml2.enabled", security.getSaml2()::setEnabled); + applyBoolean( + config, + "security.saml2.autoCreateUser", + security.getSaml2()::setAutoCreateUser); } if (security.getOauth2() != null) { applyBoolean(config, "security.oauth2.enabled", security.getOauth2()::setEnabled); + applyBoolean( + config, + "security.oauth2.autoCreateUser", + security.getOauth2()::setAutoCreateUser); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtBearerAuthenticationMechanism.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtBearerAuthenticationMechanism.java index 2012eb8a7a..7a0dea1483 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtBearerAuthenticationMechanism.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/identity/JwtBearerAuthenticationMechanism.java @@ -56,6 +56,12 @@ public class JwtBearerAuthenticationMechanism implements HttpAuthenticationMecha return token; } } + // Browser SSO (OAuth2/SAML) stores the issued app JWT in this cookie rather than an + // Authorization header. + io.vertx.core.http.Cookie cookie = context.request().getCookie("stirling_jwt"); + if (cookie != null && cookie.getValue() != null && !cookie.getValue().isBlank()) { + return cookie.getValue().trim(); + } return null; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2CallbackServlet.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2CallbackServlet.java new file mode 100644 index 0000000000..6737cf73cd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2CallbackServlet.java @@ -0,0 +1,228 @@ +package stirling.software.proprietary.security.oauth2; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.inject.Inject; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.SaveUserRequest; +import stirling.software.proprietary.security.service.TeamService; +import stirling.software.proprietary.security.service.UserService; + +/** + * OAuth2 / OIDC authorization-code callback. Implemented as a Jakarta {@code @WebServlet} (not + * JAX-RS) because quarkus-undertow's default servlet owns the {@code /login/*} prefix and + * intercepts the extension-less callback path before RESTEasy can route it (a registered servlet + * takes precedence over the default servlet). The authorize/initiation side lives in {@link + * OAuth2LoginController}; this finishes the flow and issues the application JWT (set as the {@code + * stirling_jwt} cookie the {@link + * stirling.software.proprietary.security.identity.JwtBearerAuthenticationMechanism} reads). + */ +@Slf4j +@WebServlet(urlPatterns = "/login/oauth2/code/*") +public class OAuth2CallbackServlet extends HttpServlet { + + private static final String REG_ID = "keycloak"; + + @ConfigProperty(name = "security.oauth2.enabled", defaultValue = "false") + boolean oauth2Enabled; + + @ConfigProperty(name = "security.oauth2.client.keycloak.issuer") + Optional issuer; + + @ConfigProperty(name = "security.oauth2.client.keycloak.clientId") + Optional clientId; + + @ConfigProperty(name = "security.oauth2.client.keycloak.clientSecret") + Optional clientSecret; + + @ConfigProperty(name = "security.oauth2.client.keycloak.useAsUsername", defaultValue = "email") + String useAsUsername; + + @Inject UserService userService; + @Inject TeamService teamService; + @Inject JwtServiceInterface jwtService; + @Inject ApplicationProperties applicationProperties; + + private final HttpClient http = HttpClient.newHttpClient(); + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws IOException { + if (!isConfigured()) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + String error = request.getParameter("error"); + if (error != null) { + log.warn("OAuth2 callback error: {}", error); + redirectToLogin(request, response, "oauth2_error"); + return; + } + String code = request.getParameter("code"); + if (code == null || code.isBlank()) { + redirectToLogin(request, response, "missing_code"); + return; + } + try { + Map token = exchangeCode(code, redirectUri(request)); + Map claims = fetchUserInfo((String) token.get("access_token")); + Object usernameClaim = claims.get(useAsUsername); + if (usernameClaim == null) { + log.error( + "OAuth2 userinfo missing '{}' claim; got {}", + useAsUsername, + claims.keySet()); + redirectToLogin(request, response, "no_username"); + return; + } + String username = usernameClaim.toString(); + User user = findOrCreateUser(username); + if (user == null) { + redirectToLogin(request, response, "registration_blocked"); + return; + } + String jwt = + jwtService.generateToken( + username, + Map.of( + "authType", AuthenticationType.OAUTH2.toString(), + "role", user.getRolesAsString())); + Cookie cookie = new Cookie("stirling_jwt", jwt); + cookie.setPath("/"); + cookie.setHttpOnly(true); + response.addCookie(cookie); + response.sendRedirect(baseUrl(request) + "/"); + } catch (Exception e) { + log.error("OAuth2 callback failed", e); + redirectToLogin(request, response, "oauth2_failed"); + } + } + + private boolean isConfigured() { + return oauth2Enabled + && issuer.isPresent() + && clientId.isPresent() + && clientSecret.isPresent(); + } + + private Map exchangeCode(String code, String redirectUri) throws Exception { + String form = + "grant_type=authorization_code" + + "&code=" + + enc(code) + + "&redirect_uri=" + + enc(redirectUri) + + "&client_id=" + + enc(clientId.get()) + + "&client_secret=" + + enc(clientSecret.get()); + HttpRequest req = + HttpRequest.newBuilder(URI.create(issuer.get() + "/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(); + HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (res.statusCode() != 200) { + throw new IllegalStateException( + "Token endpoint returned " + res.statusCode() + ": " + res.body()); + } + return parseJson(res.body()); + } + + private Map fetchUserInfo(String accessToken) throws Exception { + HttpRequest req = + HttpRequest.newBuilder( + URI.create(issuer.get() + "/protocol/openid-connect/userinfo")) + .header("Authorization", "Bearer " + accessToken) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (res.statusCode() != 200) { + throw new IllegalStateException("Userinfo endpoint returned " + res.statusCode()); + } + return parseJson(res.body()); + } + + private User findOrCreateUser(String username) { + Optional existing = userService.findByUsernameIgnoreCase(username); + if (existing.isPresent()) { + return existing.get(); + } + if (!applicationProperties.getSecurity().getOauth2().getAutoCreateUser()) { + log.warn("OAuth2 user '{}' not found and autoCreateUser is disabled", username); + return null; + } + try { + userService.saveUserCore( + SaveUserRequest.builder() + .username(username) + .authenticationType(AuthenticationType.OAUTH2) + .ssoProvider(REG_ID) + .team(teamService.getOrCreateDefaultTeam()) + .build()); + log.info("Auto-created OAuth2 user: {}", username); + return userService.findByUsernameIgnoreCase(username).orElse(null); + } catch (Exception e) { + log.error("Failed to auto-create OAuth2 user '{}'", username, e); + return null; + } + } + + @SuppressWarnings("unchecked") + private Map parseJson(String body) throws IOException { + return mapper.readValue(body, Map.class); + } + + private String redirectUri(HttpServletRequest request) { + return baseUrl(request) + "/login/oauth2/code/" + REG_ID; + } + + private String baseUrl(HttpServletRequest request) { + String backendUrl = applicationProperties.getSystem().getBackendUrl(); + if (backendUrl != null && !backendUrl.isBlank()) { + return backendUrl.replaceAll("/+$", ""); + } + String scheme = request.getScheme(); + int port = request.getServerPort(); + String host = request.getServerName(); + boolean defaultPort = + (scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443); + return scheme + "://" + host + (defaultPort ? "" : ":" + port); + } + + private void redirectToLogin( + HttpServletRequest request, HttpServletResponse response, String reason) + throws IOException { + response.sendRedirect(baseUrl(request) + "/login?error=" + enc(reason)); + } + + private static String enc(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2LoginController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2LoginController.java new file mode 100644 index 0000000000..9be2dbfa51 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2LoginController.java @@ -0,0 +1,88 @@ +package stirling.software.proprietary.security.oauth2; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.UUID; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; + +import stirling.software.common.model.ApplicationProperties; + +/** + * OAuth2 / OIDC login initiation. Serves {@code GET /oauth2/authorization/{registrationId}} (the + * Spring-compatible path the frontend and {@code testing/compose/validate-oauth-test.sh} expect) by + * redirecting to the IdP authorization endpoint. The matching callback is handled by {@link + * OAuth2CallbackServlet} (a servlet, because quarkus-undertow owns the {@code /login/*} prefix). + * Spring Security's {@code oauth2Login()} DSL was removed in the migration and {@code quarkus-oidc} + * is disabled (build-time gated), so the flow is implemented directly. + */ +@ApplicationScoped +@Path("") +public class OAuth2LoginController { + + @ConfigProperty(name = "security.oauth2.enabled", defaultValue = "false") + boolean oauth2Enabled; + + @ConfigProperty(name = "security.oauth2.client.keycloak.issuer") + Optional issuer; + + @ConfigProperty(name = "security.oauth2.client.keycloak.clientId") + Optional clientId; + + @ConfigProperty( + name = "security.oauth2.client.keycloak.scopes", + defaultValue = "openid,profile,email") + String scopes; + + @Inject ApplicationProperties applicationProperties; + + @GET + @Path("/oauth2/authorization/{registrationId}") + public Response authorize( + @PathParam("registrationId") String registrationId, @Context UriInfo uriInfo) { + if (!oauth2Enabled + || !"keycloak".equals(registrationId) + || issuer.isEmpty() + || clientId.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND) + .entity("OAuth2 login is not enabled") + .build(); + } + String redirectUri = baseUrl(uriInfo) + "/login/oauth2/code/" + registrationId; + String authorizeUrl = + issuer.get() + + "/protocol/openid-connect/auth?response_type=code" + + "&client_id=" + + enc(clientId.get()) + + "&redirect_uri=" + + enc(redirectUri) + + "&scope=" + + enc(scopes.replace(',', ' ')) + + "&state=" + + UUID.randomUUID(); + return Response.seeOther(URI.create(authorizeUrl)).build(); + } + + private String baseUrl(UriInfo uriInfo) { + String backendUrl = applicationProperties.getSystem().getBackendUrl(); + if (backendUrl != null && !backendUrl.isBlank()) { + return backendUrl.replaceAll("/+$", ""); + } + return uriInfo.getBaseUri().toString().replaceAll("/+$", ""); + } + + private static String enc(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +}