Implement OAuth2/OIDC login (authorize redirect + callback servlet) end-to-end

This commit is contained in:
a
2026-06-13 11:02:52 +01:00
parent daf392521d
commit 33499d537e
4 changed files with 335 additions and 1 deletions
@@ -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);
}
}
@@ -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;
}
}
@@ -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<String> issuer;
@ConfigProperty(name = "security.oauth2.client.keycloak.clientId")
Optional<String> clientId;
@ConfigProperty(name = "security.oauth2.client.keycloak.clientSecret")
Optional<String> 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<String, Object> token = exchangeCode(code, redirectUri(request));
Map<String, Object> 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<String, Object> 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<String> 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<String, Object> 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<String> 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<User> 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<String, Object> 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);
}
}
@@ -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<String> issuer;
@ConfigProperty(name = "security.oauth2.client.keycloak.clientId")
Optional<String> 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);
}
}