From 8be6839687169b0d147cc57844c4fbe12f71f243 Mon Sep 17 00:00:00 2001 From: samantha Date: Mon, 2 Feb 2026 12:29:43 -0700 Subject: [PATCH 1/3] feat(auth): add module token authentication for Thunder Engine Add OAuth2 client credentials flow for Thunder Engine to request module tokens from Thunder Auth. Includes custom grant type handler, token service implementation, and engine-side adapter for token management. Co-Authored-By: Claude Opus 4.5 --- docker-compose.yml | 4 + .../auth/service/MatchTokenGrantHandler.java | 199 +++++++++++ .../auth/service/ModuleTokenGrantHandler.java | 165 +++++++++ .../auth/service/ModuleTokenService.java | 104 ++++++ .../auth/service/ModuleTokenServiceImpl.java | 262 ++++++++++++++ .../auth/service/dto/ModuleTokenRequest.java | 101 ++++++ .../thunder/auth/util/SimpleJsonParser.java | 256 ++++++++++++++ .../service/MatchTokenGrantHandlerTest.java | 334 ++++++++++++++++++ .../service/ModuleTokenGrantHandlerTest.java | 244 +++++++++++++ .../service/ModuleTokenServiceImplTest.java | 195 ++++++++++ .../src/main/resources/application.properties | 11 +- .../resource/adapter/ModuleTokenAdapter.java | 284 +++++++++++++++ .../adapter/RestModuleTokenProvider.java | 182 ++++++++++ .../internal/auth/AuthClientService.java | 300 ++++++++++++++++ .../auth/module/ModuleTokenProvider.java | 96 +++++ 15 files changed, 2736 insertions(+), 1 deletion(-) create mode 100644 thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/MatchTokenGrantHandler.java create mode 100644 thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenGrantHandler.java create mode 100644 thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenService.java create mode 100644 thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenServiceImpl.java create mode 100644 thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/dto/ModuleTokenRequest.java create mode 100644 thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/util/SimpleJsonParser.java create mode 100644 thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/MatchTokenGrantHandlerTest.java create mode 100644 thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenGrantHandlerTest.java create mode 100644 thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenServiceImplTest.java create mode 100644 thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/ModuleTokenAdapter.java create mode 100644 thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/RestModuleTokenProvider.java create mode 100644 thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/AuthClientService.java create mode 100644 thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/module/ModuleTokenProvider.java diff --git a/docker-compose.yml b/docker-compose.yml index fe74c1f..1d65e58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,6 +85,7 @@ services: # OAuth2 Client Credentials (required for service-to-service auth) - OAUTH2_CONTROL_PLANE_SECRET=${OAUTH2_CONTROL_PLANE_SECRET:-control-plane-secret} - OAUTH2_GAME_SERVER_SECRET=${OAUTH2_GAME_SERVER_SECRET:-game-server-secret} + - OAUTH2_THUNDER_ENGINE_SECRET=${OAUTH2_THUNDER_ENGINE_SECRET:-thunder-engine-secret} depends_on: mongodb: condition: service_healthy @@ -168,6 +169,9 @@ services: - CONTROL_PLANE_NODE_ID=node-1 - CONTROL_PLANE_ADVERTISE_ADDRESS=http://backend:8080 - MAX_CONTAINERS=100 + # OAuth2 Client Credentials for module token requests + - OAUTH2_CLIENT_ID=thunder-engine + - OAUTH2_CLIENT_SECRET=${OAUTH2_THUNDER_ENGINE_SECRET:-thunder-engine-secret} # CORS - allow access from frontend and control plane - CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8081,http://control-plane:8081 depends_on: diff --git a/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/MatchTokenGrantHandler.java b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/MatchTokenGrantHandler.java new file mode 100644 index 0000000..bdb759d --- /dev/null +++ b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/MatchTokenGrantHandler.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.auth.service; + +import ca.samanthaireland.stormstack.thunder.auth.exception.AuthException; +import ca.samanthaireland.stormstack.thunder.auth.model.GrantType; +import ca.samanthaireland.stormstack.thunder.auth.model.MatchToken; +import ca.samanthaireland.stormstack.thunder.auth.model.OAuth2TokenResponse; +import ca.samanthaireland.stormstack.thunder.auth.model.ServiceClient; +import ca.samanthaireland.stormstack.thunder.auth.model.UserId; +import ca.samanthaireland.stormstack.thunder.auth.service.dto.IssueMatchTokenRequest; +import ca.samanthaireland.stormstack.thunder.auth.util.SimpleJsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +/** + * Handles the match_token grant type for issuing player match tokens. + * + *

This grant is used to issue JWT tokens for players to connect to + * specific matches. It replaces the legacy /api/match-tokens endpoint. + * + *

Request parameters: + *

+ * + *

Requires scope: {@code service.match-token.issue} + */ +public class MatchTokenGrantHandler implements OAuth2GrantHandler { + + private static final Logger log = LoggerFactory.getLogger(MatchTokenGrantHandler.class); + + private static final String PARAM_MATCH_ID = "match_id"; + private static final String PARAM_CONTAINER_ID = "container_id"; + private static final String PARAM_PLAYER_ID = "player_id"; + private static final String PARAM_USER_ID = "user_id"; + private static final String PARAM_PLAYER_NAME = "player_name"; + private static final String PARAM_SCOPES = "scopes"; + private static final String PARAM_VALID_FOR_HOURS = "valid_for_hours"; + + private static final String REQUIRED_SCOPE = "service.match-token.issue"; + private static final int DEFAULT_VALID_FOR_HOURS = 8; + + private final MatchTokenService matchTokenService; + + public MatchTokenGrantHandler(MatchTokenService matchTokenService) { + this.matchTokenService = matchTokenService; + } + + @Override + public GrantType getGrantType() { + return GrantType.MATCH_TOKEN; + } + + @Override + public OAuth2TokenResponse handle(ServiceClient client, Map parameters) { + if (client == null) { + log.error("match_token grant requires authenticated client"); + throw AuthException.invalidClient("Client authentication required"); + } + + // Check required scope + if (!client.allowedScopes().contains(REQUIRED_SCOPE) && !client.allowedScopes().contains("*")) { + log.warn("Client {} lacks required scope: {}", client.clientId(), REQUIRED_SCOPE); + throw AuthException.invalidScope("Client not authorized for scope: " + REQUIRED_SCOPE); + } + + String matchId = parameters.get(PARAM_MATCH_ID); + String containerId = parameters.get(PARAM_CONTAINER_ID); + String playerId = parameters.get(PARAM_PLAYER_ID); + String userIdStr = parameters.get(PARAM_USER_ID); + String playerName = parameters.get(PARAM_PLAYER_NAME); + String scopesJson = parameters.get(PARAM_SCOPES); + String validForHoursStr = parameters.get(PARAM_VALID_FOR_HOURS); + + UserId userId = userIdStr != null && !userIdStr.isBlank() ? UserId.fromString(userIdStr) : null; + Set scopes = parseScopesJson(scopesJson); + int validForHours = parseValidForHours(validForHoursStr); + + // Build the request + IssueMatchTokenRequest request = new IssueMatchTokenRequest( + matchId, + containerId, + playerId, + userId, + playerName, + scopes, + Duration.ofHours(validForHours) + ); + + // Issue the token + MatchToken token = matchTokenService.issueToken(request); + + log.info("Issued match token {} for player {} in match {} via client: {}", + token.id(), playerId, matchId, client.clientId()); + + // Calculate expires_in in seconds + int expiresIn = validForHours * 60 * 60; + + // Return OAuth2-style response with the match token JWT as access_token + return new OAuth2TokenResponse( + token.jwtToken(), + OAuth2TokenResponse.TOKEN_TYPE_BEARER, + expiresIn, + null, // No refresh token for match tokens + String.join(" ", token.scopes()) + ); + } + + @Override + public void validateRequest(Map parameters) { + if (!parameters.containsKey(PARAM_MATCH_ID) || parameters.get(PARAM_MATCH_ID).isBlank()) { + throw AuthException.invalidRequest("Missing required parameter: " + PARAM_MATCH_ID); + } + + if (!parameters.containsKey(PARAM_PLAYER_ID) || parameters.get(PARAM_PLAYER_ID).isBlank()) { + throw AuthException.invalidRequest("Missing required parameter: " + PARAM_PLAYER_ID); + } + + if (!parameters.containsKey(PARAM_PLAYER_NAME) || parameters.get(PARAM_PLAYER_NAME).isBlank()) { + throw AuthException.invalidRequest("Missing required parameter: " + PARAM_PLAYER_NAME); + } + + // Validate valid_for_hours if provided + String validForHoursStr = parameters.get(PARAM_VALID_FOR_HOURS); + if (validForHoursStr != null && !validForHoursStr.isBlank()) { + try { + int hours = Integer.parseInt(validForHoursStr); + if (hours <= 0 || hours > 168) { // Max 7 days + throw AuthException.invalidRequest( + PARAM_VALID_FOR_HOURS + " must be between 1 and 168 hours"); + } + } catch (NumberFormatException e) { + throw AuthException.invalidRequest( + PARAM_VALID_FOR_HOURS + " must be a valid integer"); + } + } + + // Validate scopes JSON if provided + String scopesJson = parameters.get(PARAM_SCOPES); + if (scopesJson != null && !scopesJson.isBlank()) { + try { + parseScopesJson(scopesJson); + } catch (IllegalArgumentException e) { + throw AuthException.invalidRequest("Invalid " + PARAM_SCOPES + ": " + e.getMessage()); + } + } + } + + private Set parseScopesJson(String json) { + if (json == null || json.isBlank()) { + return null; // Use defaults + } + + try { + return SimpleJsonParser.parseStringArray(json); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid JSON array: " + e.getMessage(), e); + } + } + + private int parseValidForHours(String value) { + if (value == null || value.isBlank()) { + return DEFAULT_VALID_FOR_HOURS; + } + return Integer.parseInt(value); + } +} diff --git a/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenGrantHandler.java b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenGrantHandler.java new file mode 100644 index 0000000..5b56e07 --- /dev/null +++ b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenGrantHandler.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.auth.service; + +import ca.samanthaireland.stormstack.thunder.auth.exception.AuthException; +import ca.samanthaireland.stormstack.thunder.auth.model.GrantType; +import ca.samanthaireland.stormstack.thunder.auth.model.OAuth2TokenResponse; +import ca.samanthaireland.stormstack.thunder.auth.model.ServiceClient; +import ca.samanthaireland.stormstack.thunder.auth.service.dto.ModuleTokenRequest; +import ca.samanthaireland.stormstack.thunder.auth.service.dto.ModuleTokenRequest.ComponentPermission; +import ca.samanthaireland.stormstack.thunder.auth.util.SimpleJsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +/** + * Handles the module_token grant type for issuing module authentication tokens. + * + *

This grant is used by Thunder Engine to request JWT tokens for loaded modules. + * The engine authenticates as a service client and provides module details. + * + *

Request parameters: + *

+ * + *

Requires scope: {@code module.token.issue} + */ +public class ModuleTokenGrantHandler implements OAuth2GrantHandler { + + private static final Logger log = LoggerFactory.getLogger(ModuleTokenGrantHandler.class); + + private static final String PARAM_MODULE_NAME = "module_name"; + private static final String PARAM_COMPONENT_PERMISSIONS = "component_permissions"; + private static final String PARAM_SUPERUSER = "superuser"; + private static final String PARAM_CONTAINER_ID = "container_id"; + private static final String REQUIRED_SCOPE = "module.token.issue"; + + private final ModuleTokenService moduleTokenService; + + public ModuleTokenGrantHandler(ModuleTokenService moduleTokenService) { + this.moduleTokenService = moduleTokenService; + } + + @Override + public GrantType getGrantType() { + return GrantType.MODULE_TOKEN; + } + + @Override + public OAuth2TokenResponse handle(ServiceClient client, Map parameters) { + if (client == null) { + log.error("module_token grant requires authenticated client"); + throw AuthException.invalidClient("Client authentication required"); + } + + // Check required scope + if (!client.allowedScopes().contains(REQUIRED_SCOPE) && !client.allowedScopes().contains("*")) { + log.warn("Client {} lacks required scope: {}", client.clientId(), REQUIRED_SCOPE); + throw AuthException.invalidScope("Client not authorized for scope: " + REQUIRED_SCOPE); + } + + String moduleName = parameters.get(PARAM_MODULE_NAME); + String permissionsJson = parameters.get(PARAM_COMPONENT_PERMISSIONS); + boolean superuser = "true".equalsIgnoreCase(parameters.get(PARAM_SUPERUSER)); + String containerId = parameters.get(PARAM_CONTAINER_ID); + + // Parse component permissions from JSON + Map componentPermissions = parseComponentPermissions(permissionsJson); + + // Build the request + ModuleTokenRequest request = new ModuleTokenRequest( + moduleName, + componentPermissions, + superuser, + containerId + ); + + // Issue the token + String moduleToken = moduleTokenService.issueToken(request); + int expiresIn = moduleTokenService.getTokenLifetimeSeconds(); + + log.info("Issued module token for module: {} via client: {}", moduleName, client.clientId()); + + // Return OAuth2-style response with the module token as access_token + return new OAuth2TokenResponse( + moduleToken, + OAuth2TokenResponse.TOKEN_TYPE_BEARER, + expiresIn, + null, // No refresh token for module tokens + REQUIRED_SCOPE + ); + } + + @Override + public void validateRequest(Map parameters) { + if (!parameters.containsKey(PARAM_MODULE_NAME) || parameters.get(PARAM_MODULE_NAME).isBlank()) { + throw AuthException.invalidRequest("Missing required parameter: " + PARAM_MODULE_NAME); + } + + if (!parameters.containsKey(PARAM_COMPONENT_PERMISSIONS) || parameters.get(PARAM_COMPONENT_PERMISSIONS).isBlank()) { + throw AuthException.invalidRequest("Missing required parameter: " + PARAM_COMPONENT_PERMISSIONS); + } + + // Validate that permissions is valid JSON + try { + parseComponentPermissions(parameters.get(PARAM_COMPONENT_PERMISSIONS)); + } catch (IllegalArgumentException e) { + throw AuthException.invalidRequest("Invalid " + PARAM_COMPONENT_PERMISSIONS + ": " + e.getMessage()); + } + } + + private Map parseComponentPermissions(String json) { + if (json == null || json.isBlank()) { + return Map.of(); + } + + try { + Map rawMap = SimpleJsonParser.parseObject(json); + Map result = new HashMap<>(); + + for (Map.Entry entry : rawMap.entrySet()) { + String permissionStr = entry.getValue().toUpperCase(); + try { + ComponentPermission permission = ComponentPermission.valueOf(permissionStr); + result.put(entry.getKey(), permission); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid permission level '" + entry.getValue() + "' for key '" + entry.getKey() + + "'. Valid values: OWNER, READ, WRITE"); + } + } + + return result; + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid JSON: " + e.getMessage(), e); + } + } +} diff --git a/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenService.java b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenService.java new file mode 100644 index 0000000..72195ad --- /dev/null +++ b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenService.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.auth.service; + +import ca.samanthaireland.stormstack.thunder.auth.service.dto.ModuleTokenRequest; + +import java.util.Map; + +/** + * Service for module token management. + * + *

Module tokens authorize ECS modules to access components within + * Thunder Engine containers. Each module receives a JWT containing + * its component permissions. + */ +public interface ModuleTokenService { + + /** + * JWT claim key for the token type. + */ + String CLAIM_TOKEN_TYPE = "token_type"; + + /** + * JWT claim key for the module name. + */ + String CLAIM_MODULE_NAME = "module_name"; + + /** + * JWT claim key for component permissions map. + */ + String CLAIM_COMPONENT_PERMISSIONS = "component_permissions"; + + /** + * JWT claim key for superuser status. + */ + String CLAIM_SUPERUSER = "superuser"; + + /** + * JWT claim key for container ID. + */ + String CLAIM_CONTAINER_ID = "container_id"; + + /** + * Token type value for module tokens. + */ + String TOKEN_TYPE_MODULE = "module"; + + /** + * Issues a new module token. + * + * @param request the token request containing module name, permissions, etc. + * @return the issued JWT token + */ + String issueToken(ModuleTokenRequest request); + + /** + * Verifies a module token and returns its claims. + * + * @param jwtToken the JWT token string + * @return the decoded claims as a map + * @throws ca.samanthaireland.stormstack.thunder.auth.exception.AuthException if the token is invalid + */ + Map verifyToken(String jwtToken); + + /** + * Refreshes a module token with updated permissions. + * + *

This re-issues a token with the same module name and superuser status, + * but with updated component permissions. + * + * @param existingToken the current module token + * @param newPermissions the updated component permissions + * @return the new JWT token + * @throws ca.samanthaireland.stormstack.thunder.auth.exception.AuthException if the existing token is invalid + */ + String refreshToken(String existingToken, Map newPermissions); + + /** + * Gets the token lifetime in seconds. + * + * @return the token lifetime + */ + int getTokenLifetimeSeconds(); +} diff --git a/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenServiceImpl.java b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenServiceImpl.java new file mode 100644 index 0000000..5d93ae7 --- /dev/null +++ b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenServiceImpl.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.auth.service; + +import ca.samanthaireland.stormstack.thunder.auth.config.AuthConfiguration; +import ca.samanthaireland.stormstack.thunder.auth.exception.AuthException; +import ca.samanthaireland.stormstack.thunder.auth.service.dto.ModuleTokenRequest; +import ca.samanthaireland.stormstack.thunder.auth.service.dto.ModuleTokenRequest.ComponentPermission; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Implementation of ModuleTokenService using JWT. + * + *

Module tokens are long-lived (365 days by default) since they are + * used for in-process authentication within Thunder Engine containers. + */ +public class ModuleTokenServiceImpl implements ModuleTokenService { + + private static final Logger log = LoggerFactory.getLogger(ModuleTokenServiceImpl.class); + private static final String MODULE_TOKEN_ISSUER_SUFFIX = "/module"; + private static final int DEFAULT_TOKEN_LIFETIME_DAYS = 365; + + private final AuthConfiguration config; + private final Algorithm algorithm; + private final JWTVerifier verifier; + private final String issuer; + private final int tokenLifetimeSeconds; + private final SecureRandom secureRandom = new SecureRandom(); + + public ModuleTokenServiceImpl(AuthConfiguration config) { + this.config = Objects.requireNonNull(config, "AuthConfiguration cannot be null"); + this.issuer = config.jwtIssuer() + MODULE_TOKEN_ISSUER_SUFFIX; + this.tokenLifetimeSeconds = DEFAULT_TOKEN_LIFETIME_DAYS * 24 * 60 * 60; + + // Prefer RSA keys if configured, fall back to HMAC + if (config.privateKeyLocation().isPresent() && config.publicKeyLocation().isPresent()) { + this.algorithm = createRsaAlgorithm( + config.publicKeyLocation().get(), + config.privateKeyLocation().get() + ); + log.info("ModuleTokenService initialized with RSA256, issuer: {}", issuer); + } else { + String secret = config.jwtSecret().orElseGet(ModuleTokenServiceImpl::generateSecretKey); + this.algorithm = Algorithm.HMAC256(secret); + log.info("ModuleTokenService initialized with HMAC256, issuer: {}", issuer); + } + + this.verifier = JWT.require(algorithm) + .withIssuer(issuer) + .build(); + } + + @Override + public String issueToken(ModuleTokenRequest request) { + Objects.requireNonNull(request, "Request cannot be null"); + + Instant now = Instant.now(); + Instant expiresAt = now.plusSeconds(tokenLifetimeSeconds); + String jti = generateTokenId(); + + // Convert permissions to strings for JWT claim + Map permissionStrings = request.componentPermissions().entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().name().toLowerCase() + )); + + var jwtBuilder = JWT.create() + .withIssuer(issuer) + .withSubject(request.moduleName()) + .withClaim(CLAIM_TOKEN_TYPE, TOKEN_TYPE_MODULE) + .withClaim(CLAIM_MODULE_NAME, request.moduleName()) + .withClaim(CLAIM_COMPONENT_PERMISSIONS, permissionStrings) + .withClaim(CLAIM_SUPERUSER, request.superuser()) + .withClaim(JwtTokenService.CLAIM_JTI, jti) + .withIssuedAt(now) + .withExpiresAt(expiresAt); + + if (request.containerId() != null) { + jwtBuilder.withClaim(CLAIM_CONTAINER_ID, request.containerId()); + } + + String jwt = jwtBuilder.sign(algorithm); + + log.info("Issued module token for module: {}, superuser: {}, jti: {}", + request.moduleName(), request.superuser(), jti); + + return jwt; + } + + @Override + public Map verifyToken(String jwtToken) { + try { + DecodedJWT decoded = verifier.verify(jwtToken); + + Map claims = new HashMap<>(); + claims.put("iss", decoded.getIssuer()); + claims.put("sub", decoded.getSubject()); + claims.put("exp", decoded.getExpiresAtAsInstant()); + claims.put("iat", decoded.getIssuedAtAsInstant()); + + // Extract custom claims + for (Map.Entry entry : decoded.getClaims().entrySet()) { + String key = entry.getKey(); + Claim claim = entry.getValue(); + + if (!claim.isNull()) { + if (key.equals(CLAIM_COMPONENT_PERMISSIONS)) { + claims.put(key, claim.asMap()); + } else if (key.equals(CLAIM_SUPERUSER)) { + claims.put(key, claim.asBoolean()); + } else if (claim.asString() != null) { + claims.put(key, claim.asString()); + } else if (claim.asInt() != null) { + claims.put(key, claim.asInt()); + } else if (claim.asBoolean() != null) { + claims.put(key, claim.asBoolean()); + } + } + } + + return claims; + + } catch (JWTVerificationException e) { + log.warn("Module token verification failed: {}", e.getMessage()); + throw AuthException.invalidToken("Module token verification failed: " + e.getMessage()); + } + } + + @Override + public String refreshToken(String existingToken, Map newPermissions) { + Map claims = verifyToken(existingToken); + + String moduleName = (String) claims.get(CLAIM_MODULE_NAME); + Boolean superuser = (Boolean) claims.get(CLAIM_SUPERUSER); + String containerId = (String) claims.get(CLAIM_CONTAINER_ID); + + if (moduleName == null) { + throw AuthException.invalidToken("Module token missing module_name claim"); + } + + ModuleTokenRequest request = new ModuleTokenRequest( + moduleName, + newPermissions, + superuser != null && superuser, + containerId + ); + + return issueToken(request); + } + + @Override + public int getTokenLifetimeSeconds() { + return tokenLifetimeSeconds; + } + + private String generateTokenId() { + byte[] bytes = new byte[16]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private Algorithm createRsaAlgorithm(String publicKeyLocation, String privateKeyLocation) { + try { + RSAPublicKey publicKey = loadPublicKey(publicKeyLocation); + RSAPrivateKey privateKey = loadPrivateKey(privateKeyLocation); + return Algorithm.RSA256(publicKey, privateKey); + } catch (Exception e) { + throw new IllegalStateException("Failed to load RSA keys for module tokens", e); + } + } + + private RSAPublicKey loadPublicKey(String location) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + String pem = readKeyFile(location); + String publicKeyPEM = pem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + + byte[] decoded = Base64.getDecoder().decode(publicKeyPEM); + X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return (RSAPublicKey) keyFactory.generatePublic(spec); + } + + private RSAPrivateKey loadPrivateKey(String location) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + String pem = readKeyFile(location); + String privateKeyPEM = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] decoded = Base64.getDecoder().decode(privateKeyPEM); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return (RSAPrivateKey) keyFactory.generatePrivate(spec); + } + + private String readKeyFile(String location) throws IOException { + if (location.startsWith("classpath:")) { + String resource = location.substring("classpath:".length()); + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resource)) { + if (is == null) { + throw new IOException("Resource not found: " + resource); + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } else { + return Files.readString(Path.of(location)); + } + } + + private static String generateSecretKey() { + byte[] keyBytes = new byte[32]; + new SecureRandom().nextBytes(keyBytes); + return Base64.getEncoder().encodeToString(keyBytes); + } +} diff --git a/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/dto/ModuleTokenRequest.java b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/dto/ModuleTokenRequest.java new file mode 100644 index 0000000..5c026ed --- /dev/null +++ b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/service/dto/ModuleTokenRequest.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.auth.service.dto; + +import java.util.Map; +import java.util.Objects; + +/** + * Request DTO for issuing a module token. + * + *

Module tokens authorize ECS modules to access components within + * Thunder Engine containers. + * + * @param moduleName the name of the module (required) + * @param componentPermissions map of "moduleName.componentName" to permission level (owner/read/write) + * @param superuser whether this module has superuser privileges (bypasses permission checks) + * @param containerId optional container ID to scope the token + */ +public record ModuleTokenRequest( + String moduleName, + Map componentPermissions, + boolean superuser, + String containerId +) { + + /** + * Permission level for a component. + */ + public enum ComponentPermission { + /** Full access - the module owns this component */ + OWNER, + /** Read-only access to another module's component */ + READ, + /** Read and write access to another module's component */ + WRITE + } + + public ModuleTokenRequest { + Objects.requireNonNull(moduleName, "Module name cannot be null"); + Objects.requireNonNull(componentPermissions, "Component permissions cannot be null"); + + if (moduleName.isBlank()) { + throw new IllegalArgumentException("Module name cannot be blank"); + } + + // Defensive copy + componentPermissions = Map.copyOf(componentPermissions); + } + + /** + * Creates a request for a regular (non-superuser) module. + * + * @param moduleName the module name + * @param componentPermissions the component permissions + * @return the request + */ + public static ModuleTokenRequest regular(String moduleName, Map componentPermissions) { + return new ModuleTokenRequest(moduleName, componentPermissions, false, null); + } + + /** + * Creates a request for a superuser module. + * + * @param moduleName the module name + * @param componentPermissions the component permissions + * @return the request + */ + public static ModuleTokenRequest superuser(String moduleName, Map componentPermissions) { + return new ModuleTokenRequest(moduleName, componentPermissions, true, null); + } + + /** + * Creates a request scoped to a specific container. + * + * @param containerId the container ID + * @return a new request with the container ID set + */ + public ModuleTokenRequest withContainerId(String containerId) { + return new ModuleTokenRequest(moduleName, componentPermissions, superuser, containerId); + } +} diff --git a/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/util/SimpleJsonParser.java b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/util/SimpleJsonParser.java new file mode 100644 index 0000000..fb7b4e9 --- /dev/null +++ b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/util/SimpleJsonParser.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.auth.util; + +import java.util.*; + +/** + * Simple JSON parser for basic object and array parsing. + * + *

This utility avoids external dependencies in the core module. + * Supports only the limited JSON subset needed for OAuth2 grant parameters. + */ +public final class SimpleJsonParser { + + private SimpleJsonParser() { + } + + /** + * Parses a JSON object into a String-to-String map. + * + *

Only supports simple key-value pairs with string values. + * + * @param json the JSON string to parse + * @return the parsed map + * @throws IllegalArgumentException if the JSON is invalid + */ + public static Map parseObject(String json) { + if (json == null || json.isBlank()) { + return Map.of(); + } + + json = json.trim(); + + if (!json.startsWith("{") || !json.endsWith("}")) { + throw new IllegalArgumentException("Expected JSON object"); + } + + String content = json.substring(1, json.length() - 1).trim(); + if (content.isEmpty()) { + return Map.of(); + } + + Map result = new HashMap<>(); + int pos = 0; + + while (pos < content.length()) { + // Skip whitespace + while (pos < content.length() && Character.isWhitespace(content.charAt(pos))) { + pos++; + } + + if (pos >= content.length()) { + break; + } + + // Parse key + if (content.charAt(pos) != '"') { + throw new IllegalArgumentException("Expected '\"' at position " + pos); + } + pos++; + int keyStart = pos; + while (pos < content.length() && content.charAt(pos) != '"') { + if (content.charAt(pos) == '\\' && pos + 1 < content.length()) { + pos++; // Skip escaped character + } + pos++; + } + if (pos >= content.length()) { + throw new IllegalArgumentException("Unterminated key string"); + } + String key = unescapeString(content.substring(keyStart, pos)); + pos++; + + // Skip whitespace + while (pos < content.length() && Character.isWhitespace(content.charAt(pos))) { + pos++; + } + + // Expect colon + if (pos >= content.length() || content.charAt(pos) != ':') { + throw new IllegalArgumentException("Expected ':' after key"); + } + pos++; + + // Skip whitespace + while (pos < content.length() && Character.isWhitespace(content.charAt(pos))) { + pos++; + } + + // Parse value + if (pos >= content.length() || content.charAt(pos) != '"') { + throw new IllegalArgumentException("Expected string value at position " + pos); + } + pos++; + int valueStart = pos; + while (pos < content.length() && content.charAt(pos) != '"') { + if (content.charAt(pos) == '\\' && pos + 1 < content.length()) { + pos++; // Skip escaped character + } + pos++; + } + if (pos >= content.length()) { + throw new IllegalArgumentException("Unterminated value string"); + } + String value = unescapeString(content.substring(valueStart, pos)); + pos++; + + result.put(key, value); + + // Skip whitespace + while (pos < content.length() && Character.isWhitespace(content.charAt(pos))) { + pos++; + } + + // Expect comma or end + if (pos < content.length()) { + if (content.charAt(pos) == ',') { + pos++; + } else if (content.charAt(pos) != '}') { + throw new IllegalArgumentException("Expected ',' or '}' at position " + pos); + } + } + } + + return result; + } + + /** + * Parses a JSON array of strings. + * + * @param json the JSON string to parse + * @return the parsed list of strings + * @throws IllegalArgumentException if the JSON is invalid + */ + public static Set parseStringArray(String json) { + if (json == null || json.isBlank()) { + return Set.of(); + } + + json = json.trim(); + + if (!json.startsWith("[") || !json.endsWith("]")) { + throw new IllegalArgumentException("Expected JSON array"); + } + + String content = json.substring(1, json.length() - 1).trim(); + if (content.isEmpty()) { + return Set.of(); + } + + Set result = new HashSet<>(); + int pos = 0; + + while (pos < content.length()) { + // Skip whitespace + while (pos < content.length() && Character.isWhitespace(content.charAt(pos))) { + pos++; + } + + if (pos >= content.length()) { + break; + } + + // Parse string value + if (content.charAt(pos) != '"') { + throw new IllegalArgumentException("Expected string value at position " + pos); + } + pos++; + int valueStart = pos; + while (pos < content.length() && content.charAt(pos) != '"') { + if (content.charAt(pos) == '\\' && pos + 1 < content.length()) { + pos++; // Skip escaped character + } + pos++; + } + if (pos >= content.length()) { + throw new IllegalArgumentException("Unterminated string"); + } + String value = unescapeString(content.substring(valueStart, pos)); + pos++; + + result.add(value); + + // Skip whitespace + while (pos < content.length() && Character.isWhitespace(content.charAt(pos))) { + pos++; + } + + // Expect comma or end + if (pos < content.length()) { + if (content.charAt(pos) == ',') { + pos++; + } else if (content.charAt(pos) != ']') { + throw new IllegalArgumentException("Expected ',' or ']' at position " + pos); + } + } + } + + return result; + } + + private static String unescapeString(String s) { + StringBuilder result = new StringBuilder(); + int i = 0; + while (i < s.length()) { + char c = s.charAt(i); + if (c == '\\' && i + 1 < s.length()) { + char next = s.charAt(i + 1); + switch (next) { + case '"': + result.append('"'); + break; + case '\\': + result.append('\\'); + break; + case 'n': + result.append('\n'); + break; + case 'r': + result.append('\r'); + break; + case 't': + result.append('\t'); + break; + default: + result.append(next); + } + i += 2; + } else { + result.append(c); + i++; + } + } + return result.toString(); + } +} diff --git a/thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/MatchTokenGrantHandlerTest.java b/thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/MatchTokenGrantHandlerTest.java new file mode 100644 index 0000000..c94de16 --- /dev/null +++ b/thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/MatchTokenGrantHandlerTest.java @@ -0,0 +1,334 @@ +/* + * Copyright (c) 2026 Samantha Ireland + */ + +package ca.samanthaireland.stormstack.thunder.auth.service; + +import ca.samanthaireland.stormstack.thunder.auth.exception.AuthException; +import ca.samanthaireland.stormstack.thunder.auth.model.GrantType; +import ca.samanthaireland.stormstack.thunder.auth.model.MatchToken; +import ca.samanthaireland.stormstack.thunder.auth.model.OAuth2TokenResponse; +import ca.samanthaireland.stormstack.thunder.auth.model.ServiceClient; +import ca.samanthaireland.stormstack.thunder.auth.service.dto.IssueMatchTokenRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MatchTokenGrantHandlerTest { + + @Mock + private MatchTokenService matchTokenService; + + private MatchTokenGrantHandler handler; + + @BeforeEach + void setUp() { + handler = new MatchTokenGrantHandler(matchTokenService); + } + + @Test + void getGrantType_returnsMatchToken() { + assertThat(handler.getGrantType()).isEqualTo(GrantType.MATCH_TOKEN); + } + + @Test + void handle_withValidRequest_issuesToken() { + ServiceClient client = ServiceClient.createConfidential( + "control-plane", + "secret-hash", + "Control Plane", + Set.of("service.match-token.issue"), + Set.of(GrantType.CLIENT_CREDENTIALS, GrantType.MATCH_TOKEN) + ); + + MatchToken issuedToken = MatchToken.createWithDefaultScopes( + "match-123", + "container-456", + "player-789", + null, + "TestPlayer", + Instant.now().plus(8, ChronoUnit.HOURS) + ).withJwt("test.match.jwt.token"); + + when(matchTokenService.issueToken(any(IssueMatchTokenRequest.class))) + .thenReturn(issuedToken); + + Map parameters = new HashMap<>(); + parameters.put("grant_type", "urn:stormstack:grant-type:match-token"); + parameters.put("match_id", "match-123"); + parameters.put("container_id", "container-456"); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + parameters.put("valid_for_hours", "8"); + + OAuth2TokenResponse response = handler.handle(client, parameters); + + assertThat(response.accessToken()).isEqualTo("test.match.jwt.token"); + assertThat(response.tokenType()).isEqualTo("Bearer"); + assertThat(response.expiresIn()).isEqualTo(8 * 60 * 60); // 8 hours in seconds + assertThat(response.refreshToken()).isNull(); + } + + @Test + void handle_withDefaultValidForHours_usesDefault() { + ServiceClient client = ServiceClient.createConfidential( + "control-plane", + "secret-hash", + "Control Plane", + Set.of("service.match-token.issue"), + Set.of(GrantType.MATCH_TOKEN) + ); + + MatchToken issuedToken = MatchToken.createWithDefaultScopes( + "match-123", + null, + "player-789", + null, + "TestPlayer", + Instant.now().plus(8, ChronoUnit.HOURS) + ).withJwt("test.jwt.token"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IssueMatchTokenRequest.class); + when(matchTokenService.issueToken(requestCaptor.capture())) + .thenReturn(issuedToken); + + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + // No valid_for_hours - should default to 8 + + OAuth2TokenResponse response = handler.handle(client, parameters); + + IssueMatchTokenRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.validFor()).isEqualTo(Duration.ofHours(8)); + } + + @Test + void handle_withoutClient_throwsInvalidClient() { + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + + assertThatThrownBy(() -> handler.handle(null, parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_CLIENT); + }); + } + + @Test + void handle_withoutRequiredScope_throwsInvalidScope() { + ServiceClient client = ServiceClient.createConfidential( + "test-client", + "secret-hash", + "Test Client", + Set.of("other.scope"), // Missing service.match-token.issue + Set.of(GrantType.MATCH_TOKEN) + ); + + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + + assertThatThrownBy(() -> handler.handle(client, parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_SCOPE); + }); + } + + @Test + void handle_withWildcardScope_succeeds() { + ServiceClient client = ServiceClient.createConfidential( + "admin-client", + "secret-hash", + "Admin Client", + Set.of("*"), // Wildcard allows all + Set.of(GrantType.MATCH_TOKEN) + ); + + MatchToken issuedToken = MatchToken.createWithDefaultScopes( + "match-123", + null, + "player-789", + null, + "TestPlayer", + Instant.now().plus(8, ChronoUnit.HOURS) + ).withJwt("admin.jwt.token"); + + when(matchTokenService.issueToken(any())).thenReturn(issuedToken); + + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + + OAuth2TokenResponse response = handler.handle(client, parameters); + + assertThat(response.accessToken()).isEqualTo("admin.jwt.token"); + } + + @Test + void validateRequest_withMissingMatchId_throwsInvalidRequest() { + Map parameters = new HashMap<>(); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + + assertThatThrownBy(() -> handler.validateRequest(parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_REQUEST); + assertThat(ae.getMessage()).contains("match_id"); + }); + } + + @Test + void validateRequest_withMissingPlayerId_throwsInvalidRequest() { + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_name", "TestPlayer"); + + assertThatThrownBy(() -> handler.validateRequest(parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_REQUEST); + assertThat(ae.getMessage()).contains("player_id"); + }); + } + + @Test + void validateRequest_withMissingPlayerName_throwsInvalidRequest() { + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_id", "player-789"); + + assertThatThrownBy(() -> handler.validateRequest(parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_REQUEST); + assertThat(ae.getMessage()).contains("player_name"); + }); + } + + @Test + void validateRequest_withInvalidValidForHours_throwsInvalidRequest() { + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + parameters.put("valid_for_hours", "0"); // Invalid - must be positive + + assertThatThrownBy(() -> handler.validateRequest(parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_REQUEST); + assertThat(ae.getMessage()).contains("valid_for_hours"); + }); + } + + @Test + void validateRequest_withTooLongValidForHours_throwsInvalidRequest() { + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + parameters.put("valid_for_hours", "200"); // Max is 168 (7 days) + + assertThatThrownBy(() -> handler.validateRequest(parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_REQUEST); + assertThat(ae.getMessage()).contains("valid_for_hours"); + }); + } + + @Test + void validateRequest_withNonNumericValidForHours_throwsInvalidRequest() { + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + parameters.put("valid_for_hours", "not-a-number"); + + assertThatThrownBy(() -> handler.validateRequest(parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_REQUEST); + assertThat(ae.getMessage()).contains("valid_for_hours"); + }); + } + + @Test + void validateRequest_withValidParameters_succeeds() { + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + parameters.put("valid_for_hours", "24"); + + // Should not throw + handler.validateRequest(parameters); + } + + @Test + void handle_withCustomScopes_passesToService() { + ServiceClient client = ServiceClient.createConfidential( + "control-plane", + "secret-hash", + "Control Plane", + Set.of("service.match-token.issue"), + Set.of(GrantType.MATCH_TOKEN) + ); + + MatchToken issuedToken = MatchToken.create( + "match-123", + null, + "player-789", + null, + "TestPlayer", + Set.of("view_snapshots"), // Only view, no submit + Instant.now().plus(8, ChronoUnit.HOURS) + ).withJwt("limited.jwt.token"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IssueMatchTokenRequest.class); + when(matchTokenService.issueToken(requestCaptor.capture())) + .thenReturn(issuedToken); + + Map parameters = new HashMap<>(); + parameters.put("match_id", "match-123"); + parameters.put("player_id", "player-789"); + parameters.put("player_name", "TestPlayer"); + parameters.put("scopes", "[\"view_snapshots\"]"); + + OAuth2TokenResponse response = handler.handle(client, parameters); + + IssueMatchTokenRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.scopes()).containsExactly("view_snapshots"); + } +} diff --git a/thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenGrantHandlerTest.java b/thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenGrantHandlerTest.java new file mode 100644 index 0000000..c1017e5 --- /dev/null +++ b/thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenGrantHandlerTest.java @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2026 Samantha Ireland + */ + +package ca.samanthaireland.stormstack.thunder.auth.service; + +import ca.samanthaireland.stormstack.thunder.auth.exception.AuthException; +import ca.samanthaireland.stormstack.thunder.auth.model.GrantType; +import ca.samanthaireland.stormstack.thunder.auth.model.OAuth2TokenResponse; +import ca.samanthaireland.stormstack.thunder.auth.model.ServiceClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ModuleTokenGrantHandlerTest { + + @Mock + private ModuleTokenService moduleTokenService; + + private ModuleTokenGrantHandler handler; + + @BeforeEach + void setUp() { + handler = new ModuleTokenGrantHandler(moduleTokenService); + } + + @Test + void getGrantType_returnsModuleToken() { + assertThat(handler.getGrantType()).isEqualTo(GrantType.MODULE_TOKEN); + } + + @Test + void handle_withValidRequest_issuesToken() { + ServiceClient client = ServiceClient.createConfidential( + "thunder-engine", + "secret-hash", + "Thunder Engine", + Set.of("module.token.issue"), + Set.of(GrantType.CLIENT_CREDENTIALS, GrantType.MODULE_TOKEN) + ); + + when(moduleTokenService.issueToken(any())).thenReturn("test.module.jwt.token"); + when(moduleTokenService.getTokenLifetimeSeconds()).thenReturn(31536000); // 1 year + + Map parameters = new HashMap<>(); + parameters.put("grant_type", "urn:stormstack:grant-type:module-token"); + parameters.put("module_name", "GridMapModule"); + parameters.put("component_permissions", "{\"GridMapModule.POSITION_X\": \"owner\", \"EntityModule.ENTITY_TYPE\": \"read\"}"); + parameters.put("superuser", "false"); + + OAuth2TokenResponse response = handler.handle(client, parameters); + + assertThat(response.accessToken()).isEqualTo("test.module.jwt.token"); + assertThat(response.tokenType()).isEqualTo("Bearer"); + assertThat(response.expiresIn()).isEqualTo(31536000); + assertThat(response.refreshToken()).isNull(); + } + + @Test + void handle_withSuperuserFlag_passesToService() { + ServiceClient client = ServiceClient.createConfidential( + "thunder-engine", + "secret-hash", + "Thunder Engine", + Set.of("module.token.issue"), + Set.of(GrantType.MODULE_TOKEN) + ); + + when(moduleTokenService.issueToken(argThat(request -> + request.superuser() && request.moduleName().equals("EntityModule")))) + .thenReturn("superuser.jwt.token"); + when(moduleTokenService.getTokenLifetimeSeconds()).thenReturn(31536000); + + Map parameters = new HashMap<>(); + parameters.put("module_name", "EntityModule"); + parameters.put("component_permissions", "{}"); + parameters.put("superuser", "true"); + + OAuth2TokenResponse response = handler.handle(client, parameters); + + assertThat(response.accessToken()).isEqualTo("superuser.jwt.token"); + } + + @Test + void handle_withoutClient_throwsInvalidClient() { + Map parameters = new HashMap<>(); + parameters.put("module_name", "TestModule"); + parameters.put("component_permissions", "{}"); + + assertThatThrownBy(() -> handler.handle(null, parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_CLIENT); + }); + } + + @Test + void handle_withoutRequiredScope_throwsInvalidScope() { + ServiceClient client = ServiceClient.createConfidential( + "test-client", + "secret-hash", + "Test Client", + Set.of("other.scope"), // Missing module.token.issue + Set.of(GrantType.MODULE_TOKEN) + ); + + Map parameters = new HashMap<>(); + parameters.put("module_name", "TestModule"); + parameters.put("component_permissions", "{}"); + + assertThatThrownBy(() -> handler.handle(client, parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_SCOPE); + }); + } + + @Test + void handle_withWildcardScope_succeeds() { + ServiceClient client = ServiceClient.createConfidential( + "admin-client", + "secret-hash", + "Admin Client", + Set.of("*"), // Wildcard allows all + Set.of(GrantType.MODULE_TOKEN) + ); + + when(moduleTokenService.issueToken(any())).thenReturn("admin.jwt.token"); + when(moduleTokenService.getTokenLifetimeSeconds()).thenReturn(31536000); + + Map parameters = new HashMap<>(); + parameters.put("module_name", "TestModule"); + parameters.put("component_permissions", "{}"); + + OAuth2TokenResponse response = handler.handle(client, parameters); + + assertThat(response.accessToken()).isEqualTo("admin.jwt.token"); + } + + @Test + void validateRequest_withMissingModuleName_throwsInvalidRequest() { + Map parameters = new HashMap<>(); + parameters.put("component_permissions", "{}"); + + assertThatThrownBy(() -> handler.validateRequest(parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_REQUEST); + assertThat(ae.getMessage()).contains("module_name"); + }); + } + + @Test + void validateRequest_withMissingPermissions_throwsInvalidRequest() { + Map parameters = new HashMap<>(); + parameters.put("module_name", "TestModule"); + + assertThatThrownBy(() -> handler.validateRequest(parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_REQUEST); + assertThat(ae.getMessage()).contains("component_permissions"); + }); + } + + @Test + void validateRequest_withInvalidJson_throwsInvalidRequest() { + Map parameters = new HashMap<>(); + parameters.put("module_name", "TestModule"); + parameters.put("component_permissions", "not valid json"); + + assertThatThrownBy(() -> handler.validateRequest(parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_REQUEST); + }); + } + + @Test + void validateRequest_withInvalidPermissionLevel_throwsInvalidRequest() { + Map parameters = new HashMap<>(); + parameters.put("module_name", "TestModule"); + parameters.put("component_permissions", "{\"SomeModule.Component\": \"invalid_level\"}"); + + assertThatThrownBy(() -> handler.validateRequest(parameters)) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_REQUEST); + assertThat(ae.getMessage()).contains("invalid_level"); + }); + } + + @Test + void validateRequest_withValidParameters_succeeds() { + Map parameters = new HashMap<>(); + parameters.put("module_name", "TestModule"); + parameters.put("component_permissions", "{\"Module.Component\": \"owner\"}"); + + // Should not throw + handler.validateRequest(parameters); + } + + @Test + void handle_withContainerId_includesInRequest() { + ServiceClient client = ServiceClient.createConfidential( + "thunder-engine", + "secret-hash", + "Thunder Engine", + Set.of("module.token.issue"), + Set.of(GrantType.MODULE_TOKEN) + ); + + when(moduleTokenService.issueToken(argThat(request -> + request.containerId() != null && request.containerId().equals("container-123")))) + .thenReturn("scoped.jwt.token"); + when(moduleTokenService.getTokenLifetimeSeconds()).thenReturn(31536000); + + Map parameters = new HashMap<>(); + parameters.put("module_name", "TestModule"); + parameters.put("component_permissions", "{}"); + parameters.put("container_id", "container-123"); + + OAuth2TokenResponse response = handler.handle(client, parameters); + + assertThat(response.accessToken()).isEqualTo("scoped.jwt.token"); + } +} diff --git a/thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenServiceImplTest.java b/thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenServiceImplTest.java new file mode 100644 index 0000000..845d722 --- /dev/null +++ b/thunder/auth/core/src/test/java/ca/samanthaireland/stormstack/thunder/auth/service/ModuleTokenServiceImplTest.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026 Samantha Ireland + */ + +package ca.samanthaireland.stormstack.thunder.auth.service; + +import ca.samanthaireland.stormstack.thunder.auth.config.AuthConfiguration; +import ca.samanthaireland.stormstack.thunder.auth.exception.AuthException; +import ca.samanthaireland.stormstack.thunder.auth.service.dto.ModuleTokenRequest; +import ca.samanthaireland.stormstack.thunder.auth.service.dto.ModuleTokenRequest.ComponentPermission; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ModuleTokenServiceImplTest { + + @Mock + private AuthConfiguration config; + + private ModuleTokenServiceImpl service; + + @BeforeEach + void setUp() { + when(config.jwtIssuer()).thenReturn("https://test.stormstack.io"); + when(config.jwtSecret()).thenReturn(Optional.of("test-secret-key-for-testing-purposes-only-32-chars")); + when(config.privateKeyLocation()).thenReturn(Optional.empty()); + // publicKeyLocation is not used when using HMAC secret, use lenient to avoid UnnecessaryStubbingException + lenient().when(config.publicKeyLocation()).thenReturn(Optional.empty()); + + service = new ModuleTokenServiceImpl(config); + } + + @Test + void issueToken_createsValidJwt() { + Map permissions = Map.of( + "GridMapModule.POSITION_X", ComponentPermission.OWNER, + "EntityModule.ENTITY_TYPE", ComponentPermission.READ + ); + + ModuleTokenRequest request = ModuleTokenRequest.regular("GridMapModule", permissions); + + String token = service.issueToken(request); + + assertThat(token).isNotNull(); + assertThat(token).contains("."); // JWT format + assertThat(token.split("\\.")).hasSize(3); // header.payload.signature + } + + @Test + void issueToken_withSuperuser_includesInClaims() { + ModuleTokenRequest request = ModuleTokenRequest.superuser("EntityModule", Map.of()); + + String token = service.issueToken(request); + Map claims = service.verifyToken(token); + + assertThat(claims.get(ModuleTokenService.CLAIM_SUPERUSER)).isEqualTo(true); + assertThat(claims.get(ModuleTokenService.CLAIM_MODULE_NAME)).isEqualTo("EntityModule"); + } + + @Test + void issueToken_withContainerId_includesInClaims() { + ModuleTokenRequest request = new ModuleTokenRequest( + "TestModule", + Map.of(), + false, + "container-123" + ); + + String token = service.issueToken(request); + Map claims = service.verifyToken(token); + + assertThat(claims.get(ModuleTokenService.CLAIM_CONTAINER_ID)).isEqualTo("container-123"); + } + + @Test + void verifyToken_withValidToken_returnsClaimsMap() { + Map permissions = Map.of( + "TestModule.COMPONENT", ComponentPermission.WRITE + ); + ModuleTokenRequest request = ModuleTokenRequest.regular("TestModule", permissions); + + String token = service.issueToken(request); + Map claims = service.verifyToken(token); + + assertThat(claims) + .containsKey(ModuleTokenService.CLAIM_MODULE_NAME) + .containsKey(ModuleTokenService.CLAIM_COMPONENT_PERMISSIONS) + .containsKey(ModuleTokenService.CLAIM_SUPERUSER) + .containsKey(ModuleTokenService.CLAIM_TOKEN_TYPE); + + assertThat(claims.get(ModuleTokenService.CLAIM_MODULE_NAME)).isEqualTo("TestModule"); + assertThat(claims.get(ModuleTokenService.CLAIM_TOKEN_TYPE)).isEqualTo(ModuleTokenService.TOKEN_TYPE_MODULE); + assertThat(claims.get(ModuleTokenService.CLAIM_SUPERUSER)).isEqualTo(false); + + @SuppressWarnings("unchecked") + Map componentPerms = (Map) claims.get(ModuleTokenService.CLAIM_COMPONENT_PERMISSIONS); + assertThat(componentPerms).containsEntry("TestModule.COMPONENT", "write"); + } + + @Test + void verifyToken_withInvalidToken_throwsException() { + assertThatThrownBy(() -> service.verifyToken("invalid.jwt.token")) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ae = (AuthException) e; + assertThat(ae.getErrorCode()).isEqualTo(AuthException.ErrorCode.INVALID_TOKEN); + }); + } + + @Test + void verifyToken_withExpiredToken_throwsException() { + // Create a token and then immediately try to verify with different issuer + // This will fail verification + String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJkaWZmZXJlbnQtaXNzdWVyIiwic3ViIjoiVGVzdE1vZHVsZSIsIm1vZHVsZV9uYW1lIjoiVGVzdE1vZHVsZSJ9.signature"; + + assertThatThrownBy(() -> service.verifyToken(token)) + .isInstanceOf(AuthException.class); + } + + @Test + void refreshToken_preservesSuperuserAndModuleName() { + ModuleTokenRequest request = ModuleTokenRequest.superuser("EntityModule", Map.of( + "EntityModule.ENTITY_TYPE", ComponentPermission.OWNER + )); + + String originalToken = service.issueToken(request); + + Map newPermissions = Map.of( + "EntityModule.ENTITY_TYPE", ComponentPermission.OWNER, + "NewModule.NEW_COMPONENT", ComponentPermission.READ + ); + + String refreshedToken = service.refreshToken(originalToken, newPermissions); + Map claims = service.verifyToken(refreshedToken); + + assertThat(claims.get(ModuleTokenService.CLAIM_MODULE_NAME)).isEqualTo("EntityModule"); + assertThat(claims.get(ModuleTokenService.CLAIM_SUPERUSER)).isEqualTo(true); + + @SuppressWarnings("unchecked") + Map componentPerms = (Map) claims.get(ModuleTokenService.CLAIM_COMPONENT_PERMISSIONS); + assertThat(componentPerms) + .containsEntry("EntityModule.ENTITY_TYPE", "owner") + .containsEntry("NewModule.NEW_COMPONENT", "read"); + } + + @Test + void refreshToken_withInvalidToken_throwsException() { + Map newPermissions = Map.of(); + + assertThatThrownBy(() -> service.refreshToken("invalid.token", newPermissions)) + .isInstanceOf(AuthException.class); + } + + @Test + void getTokenLifetimeSeconds_returns365Days() { + int lifetime = service.getTokenLifetimeSeconds(); + + // 365 days * 24 hours * 60 minutes * 60 seconds + assertThat(lifetime).isEqualTo(365 * 24 * 60 * 60); + } + + @Test + void issueToken_includesCorrectIssuer() { + ModuleTokenRequest request = ModuleTokenRequest.regular("TestModule", Map.of()); + + String token = service.issueToken(request); + Map claims = service.verifyToken(token); + + assertThat(claims.get("iss")).isEqualTo("https://test.stormstack.io/module"); + } + + @Test + void issueToken_includesUniqueJti() { + ModuleTokenRequest request = ModuleTokenRequest.regular("TestModule", Map.of()); + + String token1 = service.issueToken(request); + String token2 = service.issueToken(request); + + Map claims1 = service.verifyToken(token1); + Map claims2 = service.verifyToken(token2); + + assertThat(claims1.get(JwtTokenService.CLAIM_JTI)) + .isNotNull() + .isNotEqualTo(claims2.get(JwtTokenService.CLAIM_JTI)); + } +} diff --git a/thunder/auth/provider/src/main/resources/application.properties b/thunder/auth/provider/src/main/resources/application.properties index ced275a..7993efb 100644 --- a/thunder/auth/provider/src/main/resources/application.properties +++ b/thunder/auth/provider/src/main/resources/application.properties @@ -67,7 +67,7 @@ auth.oauth2.clients[0].client-secret=${OAUTH2_CONTROL_PLANE_SECRET:} auth.oauth2.clients[0].client-type=confidential auth.oauth2.clients[0].display-name=Lightning Control Plane auth.oauth2.clients[0].allowed-scopes=service.match-token.issue,service.match-token.validate,service.match-token.revoke,service.token.read,engine.* -auth.oauth2.clients[0].allowed-grant-types=client_credentials +auth.oauth2.clients[0].allowed-grant-types=client_credentials,urn:stormstack:grant-type:match-token auth.oauth2.clients[0].enabled=true # Game Server - service-to-service authentication for validating match tokens @@ -97,6 +97,15 @@ auth.oauth2.clients[3].allowed-scopes=* auth.oauth2.clients[3].allowed-grant-types=password,refresh_token,token_exchange auth.oauth2.clients[3].enabled=true +# Thunder Engine - service-to-service authentication for module tokens +auth.oauth2.clients[4].client-id=thunder-engine +auth.oauth2.clients[4].client-secret=${OAUTH2_THUNDER_ENGINE_SECRET:} +auth.oauth2.clients[4].client-type=confidential +auth.oauth2.clients[4].display-name=Thunder Engine +auth.oauth2.clients[4].allowed-scopes=module.token.issue,module.token.refresh,module.token.verify,service.match-token.validate +auth.oauth2.clients[4].allowed-grant-types=client_credentials,urn:stormstack:grant-type:module-token +auth.oauth2.clients[4].enabled=true + # ========================================================================= # Rate Limiting Configuration (brute force protection) # ========================================================================= diff --git a/thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/ModuleTokenAdapter.java b/thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/ModuleTokenAdapter.java new file mode 100644 index 0000000..ffaea50 --- /dev/null +++ b/thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/ModuleTokenAdapter.java @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.engine.api.resource.adapter; + +import ca.samanthaireland.stormstack.thunder.engine.api.resource.adapter.json.JsonMapper; + +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.stream.Collectors; + +/** + * REST API adapter for module token operations. + * + *

Module tokens authorize ECS modules to access components within + * Thunder Engine containers. This adapter communicates with the Thunder Auth + * service via OAuth2 token endpoint. + * + *

Example usage:

+ *
{@code
+ * ModuleTokenAdapter tokens = new ModuleTokenAdapter.HttpModuleTokenAdapter(
+ *     "http://localhost:8082",
+ *     "thunder-engine",
+ *     "secret"
+ * );
+ *
+ * // Issue a token for a module
+ * ModuleTokenResponse response = tokens.issueToken(
+ *     new IssueModuleTokenRequest("GridMapModule", permissions, false, null)
+ * );
+ *
+ * // Refresh with new permissions
+ * ModuleTokenResponse refreshed = tokens.refreshToken(response.token(), newPermissions);
+ * }
+ */ +public interface ModuleTokenAdapter { + + /** + * Issues a new module token via OAuth2 token endpoint. + * + * @param request the token issuance request + * @return the issued token response + * @throws IOException if the request fails + */ + ModuleTokenResponse issueToken(IssueModuleTokenRequest request) throws IOException; + + /** + * Refreshes a module token with new permissions. + * + * @param existingToken the current module token JWT + * @param newPermissions the updated component permissions + * @return the new token response + * @throws IOException if the request fails + */ + ModuleTokenResponse refreshToken(String existingToken, Map newPermissions) throws IOException; + + /** + * Permission level for a component. + */ + enum ComponentPermission { + /** Full access - the module owns this component */ + OWNER, + /** Read-only access to another module's component */ + READ, + /** Read and write access to another module's component */ + WRITE + } + + /** + * Request to issue a module token. + * + * @param moduleName the name of the module + * @param componentPermissions map of "moduleName.componentName" to permission level + * @param superuser whether this module has superuser privileges + * @param containerId optional container ID to scope the token + */ + record IssueModuleTokenRequest( + String moduleName, + Map componentPermissions, + boolean superuser, + String containerId + ) {} + + /** + * Module token response. + * + * @param token the JWT token + * @param tokenType the token type (always "Bearer") + * @param expiresIn lifetime in seconds + * @param scope the granted scope + */ + record ModuleTokenResponse( + String token, + String tokenType, + int expiresIn, + String scope + ) {} + + /** + * Exception thrown when module token operations fail. + */ + class ModuleTokenException extends IOException { + private final String errorCode; + + public ModuleTokenException(String message, String errorCode) { + super(message); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } + } + + /** + * HTTP-based implementation of ModuleTokenAdapter. + */ + class HttpModuleTokenAdapter implements ModuleTokenAdapter { + private static final String GRANT_TYPE = "urn:stormstack:grant-type:module-token"; + + private final HttpClient httpClient; + private final String authBaseUrl; + private final String clientId; + private final String clientSecret; + private final AdapterConfig config; + + /** + * Creates a new adapter with service account credentials. + * + * @param authBaseUrl the base URL of Thunder Auth (e.g., "http://localhost:8082") + * @param clientId the service client ID + * @param clientSecret the service client secret + */ + public HttpModuleTokenAdapter(String authBaseUrl, String clientId, String clientSecret) { + this(authBaseUrl, clientId, clientSecret, AdapterConfig.defaults()); + } + + /** + * Creates a new adapter with custom configuration. + * + * @param authBaseUrl the base URL of Thunder Auth + * @param clientId the service client ID + * @param clientSecret the service client secret + * @param config the adapter configuration + */ + public HttpModuleTokenAdapter(String authBaseUrl, String clientId, String clientSecret, AdapterConfig config) { + this.authBaseUrl = normalizeUrl(authBaseUrl); + this.clientId = clientId; + this.clientSecret = clientSecret; + this.config = config; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(config.getConnectTimeout()) + .build(); + } + + private static String normalizeUrl(String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + @Override + public ModuleTokenResponse issueToken(IssueModuleTokenRequest request) throws IOException { + // Build form-urlencoded body + StringBuilder body = new StringBuilder(); + body.append("grant_type=").append(encode(GRANT_TYPE)); + body.append("&module_name=").append(encode(request.moduleName())); + body.append("&component_permissions=").append(encode(serializePermissions(request.componentPermissions()))); + body.append("&superuser=").append(request.superuser()); + if (request.containerId() != null) { + body.append("&container_id=").append(encode(request.containerId())); + } + + return executeTokenRequest(body.toString()); + } + + @Override + public ModuleTokenResponse refreshToken(String existingToken, Map newPermissions) throws IOException { + // For refresh, we need to decode the existing token to get module info + // Then issue a new token with the same module name but new permissions + // The auth service handles this via the normal module_token grant + + // Parse module name from existing token (simplified - in production use JWT library) + // For now, we require the caller to provide the module name separately + // or use a dedicated refresh endpoint + + throw new UnsupportedOperationException( + "Use issueToken with the module name and new permissions instead. " + + "The existing token can be decoded client-side to extract module info."); + } + + private ModuleTokenResponse executeTokenRequest(String body) throws IOException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(authBaseUrl + "/oauth2/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", "Basic " + encodeBasicAuth(clientId, clientSecret)) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + return parseTokenResponse(response.body()); + } + + throw handleErrorResponse(response); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Request interrupted", e); + } + } + + private ModuleTokenResponse parseTokenResponse(String json) { + String accessToken = JsonMapper.extractString(json, "access_token"); + String tokenType = JsonMapper.extractString(json, "token_type"); + int expiresIn = (int) JsonMapper.extractLong(json, "expires_in"); + String scope = JsonMapper.extractString(json, "scope"); + + return new ModuleTokenResponse(accessToken, tokenType, expiresIn, scope); + } + + private IOException handleErrorResponse(HttpResponse response) { + String error = JsonMapper.extractString(response.body(), "error"); + String errorDescription = JsonMapper.extractString(response.body(), "error_description"); + String message = errorDescription != null ? errorDescription : "Request failed with status: " + response.statusCode(); + return new ModuleTokenException(message, error != null ? error : "UNKNOWN"); + } + + private String serializePermissions(Map permissions) { + // Convert to JSON format: {"key": "value", ...} + StringBuilder json = new StringBuilder("{"); + boolean first = true; + for (Map.Entry entry : permissions.entrySet()) { + if (!first) json.append(","); + json.append("\"").append(escapeJson(entry.getKey())).append("\":"); + json.append("\"").append(entry.getValue().name().toLowerCase()).append("\""); + first = false; + } + json.append("}"); + return json.toString(); + } + + private String escapeJson(String value) { + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private String encodeBasicAuth(String username, String password) { + String credentials = username + ":" + password; + return java.util.Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/RestModuleTokenProvider.java b/thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/RestModuleTokenProvider.java new file mode 100644 index 0000000..7bd617a --- /dev/null +++ b/thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/RestModuleTokenProvider.java @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.engine.api.resource.adapter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * REST-based provider for module token operations. + * + *

This class communicates with the Thunder Auth service via the OAuth2 token + * endpoint using the module_token grant type. It provides a factory method to + * create ModuleTokenProvider implementations suitable for use with OnDiskModuleManager. + * + *

Usage: + *

{@code
+ * // Create the provider
+ * RestModuleTokenProvider provider = new RestModuleTokenProvider(
+ *     "http://localhost:8082",
+ *     "thunder-engine",
+ *     "client-secret"
+ * );
+ *
+ * // Issue a token
+ * ModuleTokenResult result = provider.issueToken("GridMapModule", permissions, false);
+ * String jwt = result.token();
+ * }
+ * + *

To integrate with OnDiskModuleManager, use the adapter factory: + *

{@code
+ * ModuleTokenProvider engineProvider = provider.toModuleTokenProvider();
+ * new OnDiskModuleManager(scanDir, loader, ctx, registry, store, engineProvider);
+ * }
+ */ +public class RestModuleTokenProvider { + + private static final Logger log = LoggerFactory.getLogger(RestModuleTokenProvider.class); + + private final ModuleTokenAdapter.HttpModuleTokenAdapter adapter; + + /** + * Creates a new REST-based module token provider. + * + * @param authBaseUrl the base URL of Thunder Auth (e.g., "http://localhost:8082") + * @param clientId the service client ID (e.g., "thunder-engine") + * @param clientSecret the service client secret + */ + public RestModuleTokenProvider(String authBaseUrl, String clientId, String clientSecret) { + this.adapter = new ModuleTokenAdapter.HttpModuleTokenAdapter(authBaseUrl, clientId, clientSecret); + } + + /** + * Creates a new REST-based module token provider with custom configuration. + * + * @param authBaseUrl the base URL of Thunder Auth + * @param clientId the service client ID + * @param clientSecret the service client secret + * @param config the adapter configuration + */ + public RestModuleTokenProvider(String authBaseUrl, String clientId, String clientSecret, AdapterConfig config) { + this.adapter = new ModuleTokenAdapter.HttpModuleTokenAdapter(authBaseUrl, clientId, clientSecret, config); + } + + /** + * Issues a new module token via the OAuth2 token endpoint. + * + * @param moduleName the name of the module + * @param componentPermissions map of "moduleName.componentName" to permission level + * @param superuser whether this module has superuser privileges + * @return the token result containing the JWT + * @throws ModuleTokenException if token issuance fails + */ + public ModuleTokenResult issueToken( + String moduleName, + Map componentPermissions, + boolean superuser) { + Objects.requireNonNull(moduleName, "Module name cannot be null"); + Objects.requireNonNull(componentPermissions, "Component permissions cannot be null"); + + try { + ModuleTokenAdapter.IssueModuleTokenRequest request = new ModuleTokenAdapter.IssueModuleTokenRequest( + moduleName, + componentPermissions, + superuser, + null // No container scope by default + ); + + ModuleTokenAdapter.ModuleTokenResponse response = adapter.issueToken(request); + + log.debug("Issued module token via REST for module: {}, superuser: {}", moduleName, superuser); + + return new ModuleTokenResult( + moduleName, + componentPermissions, + superuser, + response.token(), + response.expiresIn() + ); + + } catch (IOException e) { + log.error("Failed to issue module token via REST for module: {}", moduleName, e); + throw new ModuleTokenException("Failed to issue module token: " + e.getMessage(), e); + } + } + + /** + * Issues a regular (non-superuser) module token. + * + * @param moduleName the name of the module + * @param componentPermissions map of component permissions + * @return the token result + */ + public ModuleTokenResult issueRegularToken( + String moduleName, + Map componentPermissions) { + return issueToken(moduleName, componentPermissions, false); + } + + /** + * Issues a superuser module token. + * + * @param moduleName the name of the module + * @param componentPermissions map of component permissions + * @return the token result + */ + public ModuleTokenResult issueSuperuserToken( + String moduleName, + Map componentPermissions) { + return issueToken(moduleName, componentPermissions, true); + } + + /** + * Result of a module token issuance. + * + * @param moduleName the module name + * @param componentPermissions the granted permissions + * @param superuser whether the module has superuser privileges + * @param token the JWT token string + * @param expiresIn token lifetime in seconds + */ + public record ModuleTokenResult( + String moduleName, + Map componentPermissions, + boolean superuser, + String token, + int expiresIn + ) {} + + /** + * Exception thrown when module token operations fail. + */ + public static class ModuleTokenException extends RuntimeException { + public ModuleTokenException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/AuthClientService.java b/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/AuthClientService.java new file mode 100644 index 0000000..fd703cd --- /dev/null +++ b/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/AuthClientService.java @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.engine.internal.auth; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Service for managing OAuth2 service account authentication. + * + *

This service handles obtaining and caching access tokens for Thunder Engine + * to communicate with Thunder Auth service. It uses the client_credentials grant + * type for service-to-service authentication. + * + *

Thread Safety: This class is thread-safe. Token caching uses a lock to + * prevent concurrent refresh attempts. + */ +public class AuthClientService { + + private static final Logger log = LoggerFactory.getLogger(AuthClientService.class); + private static final Duration TOKEN_EXPIRY_BUFFER = Duration.ofMinutes(5); + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(30); + + private final String authBaseUrl; + private final String clientId; + private final String clientSecret; + private final String scope; + private final HttpClient httpClient; + private final ReentrantLock tokenLock = new ReentrantLock(); + + private volatile CachedToken cachedToken; + + /** + * Creates a new AuthClientService. + * + * @param authBaseUrl the base URL of Thunder Auth (e.g., "http://localhost:8082") + * @param clientId the service client ID + * @param clientSecret the service client secret + * @param scope the scope to request (space-separated) + */ + public AuthClientService(String authBaseUrl, String clientId, String clientSecret, String scope) { + this.authBaseUrl = normalizeUrl(Objects.requireNonNull(authBaseUrl, "authBaseUrl cannot be null")); + this.clientId = Objects.requireNonNull(clientId, "clientId cannot be null"); + this.clientSecret = Objects.requireNonNull(clientSecret, "clientSecret cannot be null"); + this.scope = scope; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(DEFAULT_CONNECT_TIMEOUT) + .build(); + log.info("AuthClientService initialized for client: {} against {}", clientId, authBaseUrl); + } + + /** + * Creates a new AuthClientService with a custom HttpClient (for testing). + */ + AuthClientService(String authBaseUrl, String clientId, String clientSecret, String scope, HttpClient httpClient) { + this.authBaseUrl = normalizeUrl(Objects.requireNonNull(authBaseUrl, "authBaseUrl cannot be null")); + this.clientId = Objects.requireNonNull(clientId, "clientId cannot be null"); + this.clientSecret = Objects.requireNonNull(clientSecret, "clientSecret cannot be null"); + this.scope = scope; + this.httpClient = Objects.requireNonNull(httpClient, "httpClient cannot be null"); + } + + private static String normalizeUrl(String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + /** + * Gets a valid access token, refreshing if necessary. + * + *

This method returns a cached token if it's still valid (with a buffer + * before expiry), otherwise it obtains a new token from the auth service. + * + * @return a valid access token + * @throws AuthClientException if token acquisition fails + */ + public String getAccessToken() throws AuthClientException { + CachedToken token = cachedToken; + + if (token != null && !token.isExpiringSoon()) { + return token.accessToken; + } + + tokenLock.lock(); + try { + // Double-check after acquiring lock + token = cachedToken; + if (token != null && !token.isExpiringSoon()) { + return token.accessToken; + } + + // Obtain new token + cachedToken = fetchAccessToken(); + log.info("Obtained new access token for client: {}, expires in: {}s", + clientId, cachedToken.expiresIn); + return cachedToken.accessToken; + + } finally { + tokenLock.unlock(); + } + } + + /** + * Forces a refresh of the access token. + * + * @return the new access token + * @throws AuthClientException if token acquisition fails + */ + public String refreshAccessToken() throws AuthClientException { + tokenLock.lock(); + try { + cachedToken = fetchAccessToken(); + log.info("Refreshed access token for client: {}", clientId); + return cachedToken.accessToken; + } finally { + tokenLock.unlock(); + } + } + + /** + * Clears the cached token, forcing a refresh on next access. + */ + public void clearCache() { + tokenLock.lock(); + try { + cachedToken = null; + log.debug("Cleared cached token for client: {}", clientId); + } finally { + tokenLock.unlock(); + } + } + + private CachedToken fetchAccessToken() throws AuthClientException { + StringBuilder body = new StringBuilder(); + body.append("grant_type=client_credentials"); + if (scope != null && !scope.isBlank()) { + body.append("&scope=").append(URLEncoder.encode(scope, StandardCharsets.UTF_8)); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(authBaseUrl + "/oauth2/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", "Basic " + encodeBasicAuth(clientId, clientSecret)) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + return parseTokenResponse(response.body()); + } + + String errorDescription = extractJsonField(response.body(), "error_description"); + String error = extractJsonField(response.body(), "error"); + throw new AuthClientException( + "Failed to obtain access token: " + (errorDescription != null ? errorDescription : error), + response.statusCode() + ); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AuthClientException("Token request interrupted", e); + } catch (IOException e) { + throw new AuthClientException("Failed to connect to auth service: " + e.getMessage(), e); + } + } + + private CachedToken parseTokenResponse(String json) { + String accessToken = extractJsonField(json, "access_token"); + String expiresInStr = extractJsonField(json, "expires_in"); + + if (accessToken == null) { + throw new AuthClientException("Invalid token response: missing access_token", 200); + } + + int expiresIn = 3600; // Default to 1 hour + if (expiresInStr != null) { + try { + expiresIn = Integer.parseInt(expiresInStr); + } catch (NumberFormatException ignored) { + // Use default + } + } + + return new CachedToken(accessToken, expiresIn); + } + + private String extractJsonField(String json, String field) { + // Simple JSON field extraction without a full parser + String pattern = "\"" + field + "\""; + int fieldIndex = json.indexOf(pattern); + if (fieldIndex == -1) return null; + + int colonIndex = json.indexOf(':', fieldIndex); + if (colonIndex == -1) return null; + + int valueStart = colonIndex + 1; + while (valueStart < json.length() && Character.isWhitespace(json.charAt(valueStart))) { + valueStart++; + } + + if (valueStart >= json.length()) return null; + + char startChar = json.charAt(valueStart); + if (startChar == '"') { + // String value + int valueEnd = json.indexOf('"', valueStart + 1); + if (valueEnd == -1) return null; + return json.substring(valueStart + 1, valueEnd); + } else if (Character.isDigit(startChar) || startChar == '-') { + // Numeric value + int valueEnd = valueStart; + while (valueEnd < json.length() && (Character.isDigit(json.charAt(valueEnd)) || json.charAt(valueEnd) == '.')) { + valueEnd++; + } + return json.substring(valueStart, valueEnd); + } + + return null; + } + + private String encodeBasicAuth(String username, String password) { + String credentials = username + ":" + password; + return Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Cached token with expiry tracking. + */ + private static class CachedToken { + final String accessToken; + final int expiresIn; + final Instant obtainedAt; + + CachedToken(String accessToken, int expiresIn) { + this.accessToken = accessToken; + this.expiresIn = expiresIn; + this.obtainedAt = Instant.now(); + } + + boolean isExpiringSoon() { + Instant expiresAt = obtainedAt.plusSeconds(expiresIn); + return Instant.now().isAfter(expiresAt.minus(TOKEN_EXPIRY_BUFFER)); + } + } + + /** + * Exception thrown when authentication operations fail. + */ + public static class AuthClientException extends RuntimeException { + private final int statusCode; + + public AuthClientException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public AuthClientException(String message, Throwable cause) { + super(message, cause); + this.statusCode = -1; + } + + public int getStatusCode() { + return statusCode; + } + } +} diff --git a/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/module/ModuleTokenProvider.java b/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/module/ModuleTokenProvider.java new file mode 100644 index 0000000..4977376 --- /dev/null +++ b/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/module/ModuleTokenProvider.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.engine.internal.auth.module; + +import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.ModuleAuthToken.ComponentPermission; + +import java.util.Map; + +/** + * Provider interface for module token operations. + * + *

This interface abstracts the module token issuance mechanism, allowing + * different implementations: + *

    + *
  • Local implementation using internal JWT signing (for standalone/testing)
  • + *
  • Remote implementation calling Thunder Auth service (for production)
  • + *
+ * + *

The OnDiskModuleManager uses this interface to obtain tokens for loaded modules. + */ +public interface ModuleTokenProvider { + + /** + * Issues a JWT token for a module with component permissions. + * + * @param moduleName the name of the module + * @param componentPermissions map of "moduleName.componentName" to permission level + * @param superuser whether this module has superuser privileges + * @return the module auth token containing the JWT + */ + ModuleAuthToken issueToken(String moduleName, Map componentPermissions, boolean superuser); + + /** + * Issues a superuser token for system modules like EntityModule. + * + * @param moduleName the name of the system module + * @param componentPermissions map of component permissions + * @return the module auth token with superuser privileges + */ + default ModuleAuthToken issueSuperuserToken(String moduleName, Map componentPermissions) { + return issueToken(moduleName, componentPermissions, true); + } + + /** + * Issues a regular (non-superuser) token for a module. + * + * @param moduleName the name of the module + * @param componentPermissions map of component permissions + * @return the module auth token without superuser privileges + */ + default ModuleAuthToken issueRegularToken(String moduleName, Map componentPermissions) { + return issueToken(moduleName, componentPermissions, false); + } + + /** + * Refreshes a module's token with new component permissions. + * + *

This method re-issues a JWT token with updated permission claims. + * Use this when a new module is installed and existing modules need + * access to its components. + * + * @param existingToken the module's current token (used to preserve superuser status) + * @param newPermissions the updated permission claims + * @return a new auth token with the updated permissions + */ + ModuleAuthToken refreshToken(ModuleAuthToken existingToken, Map newPermissions); + + /** + * Verifies a JWT token and extracts the module auth claims. + * + * @param token the JWT token string + * @return the verified module auth token + * @throws ModuleAuthException if the token is invalid or expired + */ + ModuleAuthToken verifyToken(String token); +} From 38b3ac2cc58b1f70db1fa9ffe5641b211221f057 Mon Sep 17 00:00:00 2001 From: samantha Date: Mon, 2 Feb 2026 13:19:40 -0700 Subject: [PATCH 2/3] a --- .../thunder/auth/model/GrantType.java | 18 ++++- .../auth/provider/config/ServiceProducer.java | 44 ++++++++++- .../src/test/resources/application.properties | 8 ++ .../adapter/RestModuleTokenProvider.java | 1 - .../ext/module/OnDiskModuleManager.java | 75 ++++++++++++++++--- .../quarkus/api/config/SimulationConfig.java | 37 ++++++++- 6 files changed, 166 insertions(+), 17 deletions(-) diff --git a/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/model/GrantType.java b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/model/GrantType.java index c37ad62..e17579e 100644 --- a/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/model/GrantType.java +++ b/thunder/auth/core/src/main/java/ca/samanthaireland/stormstack/thunder/auth/model/GrantType.java @@ -59,7 +59,23 @@ public enum GrantType { *

Used to exchange one token type for another (e.g., API token * for a session JWT). */ - TOKEN_EXCHANGE("urn:ietf:params:oauth:grant-type:token-exchange"); + TOKEN_EXCHANGE("urn:ietf:params:oauth:grant-type:token-exchange"), + + /** + * Match Token Grant (StormStack extension). + * + *

Used by services to issue JWT tokens for players to connect + * to specific matches. Replaces the legacy /api/match-tokens endpoint. + */ + MATCH_TOKEN("urn:stormstack:grant-type:match-token"), + + /** + * Module Token Grant (StormStack extension). + * + *

Used by Thunder Engine to request JWT tokens for loaded modules. + * Each module receives a token containing its component permissions. + */ + MODULE_TOKEN("urn:stormstack:grant-type:module-token"); private final String value; diff --git a/thunder/auth/provider/src/main/java/ca/samanthaireland/stormstack/thunder/auth/provider/config/ServiceProducer.java b/thunder/auth/provider/src/main/java/ca/samanthaireland/stormstack/thunder/auth/provider/config/ServiceProducer.java index 91b4883..365f548 100644 --- a/thunder/auth/provider/src/main/java/ca/samanthaireland/stormstack/thunder/auth/provider/config/ServiceProducer.java +++ b/thunder/auth/provider/src/main/java/ca/samanthaireland/stormstack/thunder/auth/provider/config/ServiceProducer.java @@ -173,6 +173,18 @@ public MatchTokenService matchTokenService( return new MatchTokenServiceImpl(matchTokenRepository, config); } + /** + * Produces the ModuleTokenService. + * + * @param config the auth configuration + * @return the module token service + */ + @Produces + @Singleton + public ModuleTokenService moduleTokenService(AuthConfiguration config) { + return new ModuleTokenServiceImpl(config); + } + // ========================================================================= // OAuth2 Services // ========================================================================= @@ -321,6 +333,30 @@ public TokenExchangeGrantHandler tokenExchangeGrantHandler( return new TokenExchangeGrantHandler(apiTokenService, jwtTokenService, oauth2Config); } + /** + * Produces the MatchTokenGrantHandler. + * + * @param matchTokenService the match token service + * @return the grant handler + */ + @Produces + @Singleton + public MatchTokenGrantHandler matchTokenGrantHandler(MatchTokenService matchTokenService) { + return new MatchTokenGrantHandler(matchTokenService); + } + + /** + * Produces the ModuleTokenGrantHandler. + * + * @param moduleTokenService the module token service + * @return the grant handler + */ + @Produces + @Singleton + public ModuleTokenGrantHandler moduleTokenGrantHandler(ModuleTokenService moduleTokenService) { + return new ModuleTokenGrantHandler(moduleTokenService); + } + /** * Produces the OAuth2TokenService. * @@ -330,6 +366,8 @@ public TokenExchangeGrantHandler tokenExchangeGrantHandler( * @param passwordGrantHandler the password grant handler * @param refreshTokenGrantHandler the refresh token grant handler * @param tokenExchangeGrantHandler the token exchange grant handler + * @param matchTokenGrantHandler the match token grant handler + * @param moduleTokenGrantHandler the module token grant handler * @return the OAuth2 token service */ @Produces @@ -340,12 +378,16 @@ public OAuth2TokenService oauth2TokenService( ClientCredentialsGrantHandler clientCredentialsGrantHandler, PasswordGrantHandler passwordGrantHandler, RefreshTokenGrantHandler refreshTokenGrantHandler, - TokenExchangeGrantHandler tokenExchangeGrantHandler) { + TokenExchangeGrantHandler tokenExchangeGrantHandler, + MatchTokenGrantHandler matchTokenGrantHandler, + ModuleTokenGrantHandler moduleTokenGrantHandler) { List handlers = new ArrayList<>(); handlers.add(clientCredentialsGrantHandler); handlers.add(passwordGrantHandler); handlers.add(refreshTokenGrantHandler); handlers.add(tokenExchangeGrantHandler); + handlers.add(matchTokenGrantHandler); + handlers.add(moduleTokenGrantHandler); return new OAuth2TokenServiceImpl(clientRepository, passwordService, handlers); } } diff --git a/thunder/auth/provider/src/test/resources/application.properties b/thunder/auth/provider/src/test/resources/application.properties index e1cb816..1009908 100644 --- a/thunder/auth/provider/src/test/resources/application.properties +++ b/thunder/auth/provider/src/test/resources/application.properties @@ -56,6 +56,14 @@ auth.oauth2.clients[3].allowed-scopes=* auth.oauth2.clients[3].allowed-grant-types=password,refresh_token,token_exchange auth.oauth2.clients[3].enabled=true +auth.oauth2.clients[4].client-id=thunder-engine +auth.oauth2.clients[4].client-secret=thunder-engine-test-secret +auth.oauth2.clients[4].client-type=confidential +auth.oauth2.clients[4].display-name=Thunder Engine +auth.oauth2.clients[4].allowed-scopes=module.token.issue,module.token.refresh,module.token.verify,service.match-token.validate +auth.oauth2.clients[4].allowed-grant-types=client_credentials,urn:stormstack:grant-type:module-token +auth.oauth2.clients[4].enabled=true + # Rate limiting - disabled for faster tests auth.ratelimit.enabled=false auth.ratelimit.max-attempts-per-window=100 diff --git a/thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/RestModuleTokenProvider.java b/thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/RestModuleTokenProvider.java index 7bd617a..404a2b0 100644 --- a/thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/RestModuleTokenProvider.java +++ b/thunder/engine/adapters/web-api-adapter/src/main/java/ca/samanthaireland/stormstack/thunder/engine/api/resource/adapter/RestModuleTokenProvider.java @@ -28,7 +28,6 @@ import java.io.IOException; import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; /** * REST-based provider for module token operations. diff --git a/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/ext/module/OnDiskModuleManager.java b/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/ext/module/OnDiskModuleManager.java index 4b4a223..8385465 100644 --- a/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/ext/module/OnDiskModuleManager.java +++ b/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/ext/module/OnDiskModuleManager.java @@ -33,9 +33,10 @@ import ca.samanthaireland.stormstack.thunder.engine.ext.module.ModuleExports; import ca.samanthaireland.stormstack.thunder.engine.ext.module.ModuleFactory; import ca.samanthaireland.stormstack.thunder.engine.ext.module.ModuleIdentifier; -import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.ModuleAuthService; +import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.LocalModuleTokenProvider; import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.ModuleAuthToken; import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.ModulePermissionClaimBuilder; +import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.ModuleTokenProvider; import ca.samanthaireland.stormstack.thunder.engine.internal.core.store.ModuleScopedStore; import ca.samanthaireland.stormstack.thunder.engine.internal.ext.jar.FactoryClassloader; import lombok.extern.slf4j.Slf4j; @@ -67,7 +68,7 @@ public class OnDiskModuleManager implements ModuleManager { private final ModuleContext moduleContext; private final PermissionRegistry permissionRegistry; private final EntityComponentStore sharedStore; - private final ModuleAuthService authService; + private final ModuleTokenProvider tokenProvider; private final Map factoryCache = new ConcurrentHashMap<>(); private final Map moduleCache = new ConcurrentHashMap<>(); @@ -78,6 +79,8 @@ public class OnDiskModuleManager implements ModuleManager { /** * Create an OnDiskModuleManager with a custom directory and factory classloader. * + *

Uses local JWT signing for module authentication (suitable for testing/standalone). + * * @param scanDirectory the directory to scan for JAR files * @param factoryClassloader the classloader to use for loading module factories from JARs * @param moduleContext the module context for dependency injection @@ -90,20 +93,49 @@ public OnDiskModuleManager( ModuleContext moduleContext, PermissionRegistry permissionRegistry, EntityComponentStore sharedStore) { + this(scanDirectory, factoryClassloader, moduleContext, permissionRegistry, sharedStore, + new LocalModuleTokenProvider()); + } + + /** + * Create an OnDiskModuleManager with a custom token provider. + * + *

Use this constructor for production deployments with Thunder Auth: + *

{@code
+     * RestModuleTokenProvider restProvider = new RestModuleTokenProvider(authUrl, clientId, clientSecret);
+     * ModuleTokenProvider tokenProvider = restProvider.toModuleTokenProvider();
+     * new OnDiskModuleManager(scanDir, classloader, context, registry, store, tokenProvider);
+     * }
+ * + * @param scanDirectory the directory to scan for JAR files + * @param factoryClassloader the classloader to use for loading module factories from JARs + * @param moduleContext the module context for dependency injection + * @param permissionRegistry the registry for component permissions + * @param sharedStore the shared entity component store for all modules + * @param tokenProvider the provider for module token operations + */ + public OnDiskModuleManager( + Path scanDirectory, + FactoryClassloader factoryClassloader, + ModuleContext moduleContext, + PermissionRegistry permissionRegistry, + EntityComponentStore sharedStore, + ModuleTokenProvider tokenProvider) { this.scanDirectory = scanDirectory; this.factoryClassloader = factoryClassloader; this.moduleContext = moduleContext; this.permissionRegistry = permissionRegistry; this.sharedStore = sharedStore; - this.authService = new ModuleAuthService(); - log.info("OnDiskModuleManager initialized with JWT authentication"); + this.tokenProvider = tokenProvider; + log.info("OnDiskModuleManager initialized with {} token provider", + tokenProvider.getClass().getSimpleName()); } /** * Create an OnDiskModuleManager for a container with a custom parent classloader. * *

This constructor is designed for container isolation, where each container - * has its own classloader for module JAR loading. + * has its own classloader for module JAR loading. Uses local JWT signing. * * @param moduleContext the module context for dependency injection * @param permissionRegistry the registry for component permissions @@ -115,6 +147,26 @@ public OnDiskModuleManager( PermissionRegistry permissionRegistry, String scanDirectory, ClassLoader parentClassLoader) { + this(moduleContext, permissionRegistry, scanDirectory, parentClassLoader, new LocalModuleTokenProvider()); + } + + /** + * Create an OnDiskModuleManager for a container with a custom token provider. + * + *

This constructor is designed for container isolation with external authentication. + * + * @param moduleContext the module context for dependency injection + * @param permissionRegistry the registry for component permissions + * @param scanDirectory the directory path to scan for JAR files + * @param parentClassLoader the parent classloader for module class loading + * @param tokenProvider the provider for module token operations + */ + public OnDiskModuleManager( + ModuleContext moduleContext, + PermissionRegistry permissionRegistry, + String scanDirectory, + ClassLoader parentClassLoader, + ModuleTokenProvider tokenProvider) { this.scanDirectory = Path.of(scanDirectory); this.factoryClassloader = new ca.samanthaireland.stormstack.thunder.engine.internal.ext.jar.ModuleFactoryClassLoader<>( ModuleFactory.class, "ModuleFactory") { @@ -127,8 +179,9 @@ public List loadFactoriesFromJar(File jarFile) throws IOException this.moduleContext = moduleContext; this.permissionRegistry = permissionRegistry; this.sharedStore = moduleContext.getEntityComponentStore(); - this.authService = new ModuleAuthService(); - log.info("OnDiskModuleManager initialized for container with custom classloader"); + this.tokenProvider = tokenProvider; + log.info("OnDiskModuleManager initialized for container with {} token provider", + tokenProvider.getClass().getSimpleName()); } /** @@ -290,8 +343,8 @@ private void initializeAndCacheModule(ModuleFactory factory, String source) { // EntityModule gets superuser privileges to attach FLAG components during spawn boolean isSuperuser = ENTITY_MODULE_NAME.equals(moduleName); ModuleAuthToken authToken = isSuperuser - ? authService.issueSuperuserToken(moduleName, componentPermissions) - : authService.issueRegularToken(moduleName, componentPermissions); + ? tokenProvider.issueSuperuserToken(moduleName, componentPermissions) + : tokenProvider.issueRegularToken(moduleName, componentPermissions); // Create the final scoped store with the JWT token ModuleScopedStore finalStore = ModuleScopedStore.create( @@ -375,8 +428,8 @@ private void refreshExistingModuleTokens(String newModuleName) { .withAccessibleComponentsFrom(moduleCache.values()) .build(); - // Refresh the JWT token using the auth service (preserves superuser status) - ModuleAuthToken newAuthToken = authService.refreshToken(existingToken, componentPermissions); + // Refresh the JWT token using the token provider (preserves superuser status) + ModuleAuthToken newAuthToken = tokenProvider.refreshToken(existingToken, componentPermissions); // Update the module's scoped store with the new token ModuleScopedStore newStore = ModuleScopedStore.create( diff --git a/thunder/engine/provider/src/main/java/ca/samanthaireland/stormstack/thunder/engine/quarkus/api/config/SimulationConfig.java b/thunder/engine/provider/src/main/java/ca/samanthaireland/stormstack/thunder/engine/quarkus/api/config/SimulationConfig.java index 8af604c..8bae089 100644 --- a/thunder/engine/provider/src/main/java/ca/samanthaireland/stormstack/thunder/engine/quarkus/api/config/SimulationConfig.java +++ b/thunder/engine/provider/src/main/java/ca/samanthaireland/stormstack/thunder/engine/quarkus/api/config/SimulationConfig.java @@ -61,10 +61,13 @@ import ca.samanthaireland.stormstack.thunder.engine.internal.core.store.LockingEntityComponentStore; import ca.samanthaireland.stormstack.thunder.engine.internal.core.store.SimplePermissionRegistry; import ca.samanthaireland.stormstack.thunder.engine.internal.core.resource.OnDiskResourceManager; +import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.LocalModuleTokenProvider; +import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.ModuleTokenProvider; import ca.samanthaireland.stormstack.thunder.engine.internal.ext.jar.ModuleFactoryClassLoader; import ca.samanthaireland.stormstack.thunder.engine.internal.ext.module.DefaultInjector; import ca.samanthaireland.stormstack.thunder.engine.internal.ext.module.ModuleManager; import ca.samanthaireland.stormstack.thunder.engine.internal.ext.module.OnDiskModuleManager; +import ca.samanthaireland.stormstack.thunder.engine.api.resource.adapter.RestModuleTokenProvider; import ca.samanthaireland.stormstack.thunder.engine.internal.container.InMemoryContainerManager; import ca.samanthaireland.stormstack.thunder.engine.internal.core.session.DefaultPlayerSessionService; import ca.samanthaireland.stormstack.thunder.engine.internal.core.session.InMemoryPlayerSessionRepository; @@ -85,6 +88,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import java.nio.file.Path; +import java.util.Optional; /** * CDI configuration for simulation beans. @@ -107,6 +111,18 @@ public class SimulationConfig { @ConfigProperty(name = "storage.resources-path", defaultValue = "resources") String resourcesPath; + @ConfigProperty(name = "auth.module-token.enabled", defaultValue = "false") + boolean moduleTokenAuthEnabled; + + @ConfigProperty(name = "auth.service-url", defaultValue = "http://localhost:8082") + String authServiceUrl; + + @ConfigProperty(name = "oauth2.client-id") + Optional oauth2ClientId; + + @ConfigProperty(name = "oauth2.client-secret") + Optional oauth2ClientSecret; + // ---------- Core infrastructure ---------- @Produces @@ -132,6 +148,20 @@ public PermissionRegistry permissionRegistry() { return new SimplePermissionRegistry(); } + // ---------- Module token authentication ---------- + + @Produces + @ApplicationScoped + public ModuleTokenProvider moduleTokenProvider() { + if (moduleTokenAuthEnabled && oauth2ClientId.isPresent() && oauth2ClientSecret.isPresent()) { + RestModuleTokenProvider restProvider = new RestModuleTokenProvider( + authServiceUrl, oauth2ClientId.get(), oauth2ClientSecret.get()); + return new RestModuleTokenProviderAdapter(restProvider); + } + // Fall back to local JWT signing for testing/standalone + return new LocalModuleTokenProvider(); + } + // ---------- Container management ---------- @Produces @@ -145,14 +175,15 @@ public ContainerManager containerManager() { @Produces @ApplicationScoped public ModuleManager moduleManager(ModuleContext context, PermissionRegistry permissionRegistry, - EntityComponentStore store) { - // Pass store directly to OnDiskModuleManager to ensure it's available during module initialization + EntityComponentStore store, ModuleTokenProvider tokenProvider) { + // Pass store and token provider to OnDiskModuleManager OnDiskModuleManager manager = new OnDiskModuleManager(Path.of(modulesPath), new ModuleFactoryClassLoader<>(ModuleFactory.class, "ModuleFactory"), context, permissionRegistry, - store); + store, + tokenProvider); // Register with the injector so modules can access it via ModuleResolver context.addClass(ModuleResolver.class, manager); return manager; From c537469248e90ca363ff81843a125ce1a70eaf7b Mon Sep 17 00:00:00 2001 From: samantha Date: Mon, 2 Feb 2026 15:49:57 -0700 Subject: [PATCH 3/3] feat(auth): complete module token integration with engine - Add LocalModuleTokenProvider to wrap existing ModuleAuthService - Add RestModuleTokenProviderAdapter for production deployments - Update OnDiskModuleManager to use ModuleTokenProvider interface - Update SimulationConfig with auth configuration properties - Fix e2e test script version number Co-Authored-By: Claude Opus 4.5 --- lightning/cli/e2e/e2e-test-modules.sh | 4 +- .../auth/module/LocalModuleTokenProvider.java | 85 +++++++++++++++++ .../RestModuleTokenProviderAdapter.java | 95 +++++++++++++++++++ 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/module/LocalModuleTokenProvider.java create mode 100644 thunder/engine/provider/src/main/java/ca/samanthaireland/stormstack/thunder/engine/quarkus/api/config/RestModuleTokenProviderAdapter.java diff --git a/lightning/cli/e2e/e2e-test-modules.sh b/lightning/cli/e2e/e2e-test-modules.sh index 4aa4e47..e26a14f 100755 --- a/lightning/cli/e2e/e2e-test-modules.sh +++ b/lightning/cli/e2e/e2e-test-modules.sh @@ -176,7 +176,7 @@ if [ "$SKIP_BUILD" = false ]; then cd "$PROJECT_ROOT" mvn install -pl thunder/engine/extensions/modules/physics-module/rigid-body-module -DskipTests -q - RIGID_BODY_JAR="$PROJECT_ROOT/thunder/engine/extensions/modules/physics-module/rigid-body-module/target/rigid-body-module-0.0.3-SNAPSHOT.jar" + RIGID_BODY_JAR="$PROJECT_ROOT/thunder/engine/extensions/modules/physics-module/rigid-body-module/target/rigid-body-module-0.1.1.jar" if [ ! -f "$RIGID_BODY_JAR" ]; then log_error "Failed to build rigid body module JAR" exit 1 @@ -191,7 +191,7 @@ else fi # Check if rigid body JAR exists - RIGID_BODY_JAR="$PROJECT_ROOT/thunder/engine/extensions/modules/physics-module/rigid-body-module/target/rigid-body-module-0.0.3-SNAPSHOT.jar" + RIGID_BODY_JAR="$PROJECT_ROOT/thunder/engine/extensions/modules/physics-module/rigid-body-module/target/rigid-body-module-0.1.1.jar" if [ ! -f "$RIGID_BODY_JAR" ]; then log_warn "Rigid Body Module JAR not found. It will need to be built." fi diff --git a/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/module/LocalModuleTokenProvider.java b/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/module/LocalModuleTokenProvider.java new file mode 100644 index 0000000..0a7e31f --- /dev/null +++ b/thunder/engine/core/src/main/java/ca/samanthaireland/stormstack/thunder/engine/internal/auth/module/LocalModuleTokenProvider.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.engine.internal.auth.module; + +import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.ModuleAuthToken.ComponentPermission; + +import java.util.Map; + +/** + * Local implementation of ModuleTokenProvider using in-process JWT signing. + * + *

This provider uses the local ModuleAuthService for token operations, + * suitable for: + *

    + *
  • Testing and development environments
  • + *
  • Standalone deployments without Thunder Auth
  • + *
  • Scenarios where external auth service is unavailable
  • + *
+ * + *

For production deployments with Thunder Auth, use RestModuleTokenProvider instead. + */ +public class LocalModuleTokenProvider implements ModuleTokenProvider { + + private final ModuleAuthService authService; + + /** + * Creates a new local provider with a randomly generated secret. + */ + public LocalModuleTokenProvider() { + this.authService = new ModuleAuthService(); + } + + /** + * Creates a new local provider with a specific secret (for testing). + * + * @param secret the secret to use for signing/verifying tokens + */ + public LocalModuleTokenProvider(String secret) { + this.authService = new ModuleAuthService(secret); + } + + /** + * Creates a new local provider wrapping an existing auth service. + * + * @param authService the auth service to delegate to + */ + public LocalModuleTokenProvider(ModuleAuthService authService) { + this.authService = authService; + } + + @Override + public ModuleAuthToken issueToken(String moduleName, Map componentPermissions, boolean superuser) { + return authService.issueToken(moduleName, componentPermissions, superuser); + } + + @Override + public ModuleAuthToken refreshToken(ModuleAuthToken existingToken, Map newPermissions) { + return authService.refreshToken(existingToken, newPermissions); + } + + @Override + public ModuleAuthToken verifyToken(String token) { + return authService.verifyToken(token); + } +} diff --git a/thunder/engine/provider/src/main/java/ca/samanthaireland/stormstack/thunder/engine/quarkus/api/config/RestModuleTokenProviderAdapter.java b/thunder/engine/provider/src/main/java/ca/samanthaireland/stormstack/thunder/engine/quarkus/api/config/RestModuleTokenProviderAdapter.java new file mode 100644 index 0000000..5a34fe1 --- /dev/null +++ b/thunder/engine/provider/src/main/java/ca/samanthaireland/stormstack/thunder/engine/quarkus/api/config/RestModuleTokenProviderAdapter.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 Samantha Ireland + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ca.samanthaireland.stormstack.thunder.engine.quarkus.api.config; + +import ca.samanthaireland.stormstack.thunder.engine.api.resource.adapter.ModuleTokenAdapter; +import ca.samanthaireland.stormstack.thunder.engine.api.resource.adapter.RestModuleTokenProvider; +import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.ModuleAuthToken; +import ca.samanthaireland.stormstack.thunder.engine.internal.auth.module.ModuleTokenProvider; + +import java.util.HashMap; +import java.util.Map; + +/** + * Adapter that bridges RestModuleTokenProvider to ModuleTokenProvider. + * + *

This adapter converts between the REST adapter types (used for HTTP communication) + * and the engine core types (used for module authentication within the engine). + */ +class RestModuleTokenProviderAdapter implements ModuleTokenProvider { + + private final RestModuleTokenProvider delegate; + + RestModuleTokenProviderAdapter(RestModuleTokenProvider delegate) { + this.delegate = delegate; + } + + @Override + public ModuleAuthToken issueToken(String moduleName, + Map componentPermissions, + boolean superuser) { + // Convert engine permissions to adapter permissions + Map adapterPerms = convertToAdapterPermissions(componentPermissions); + + RestModuleTokenProvider.ModuleTokenResult result = delegate.issueToken(moduleName, adapterPerms, superuser); + + // Convert back to engine auth token + return new ModuleAuthToken( + moduleName, + componentPermissions, + superuser, + result.token() + ); + } + + @Override + public ModuleAuthToken refreshToken(ModuleAuthToken existingToken, + Map newPermissions) { + // For REST-based refresh, we re-issue the token with the new permissions + return issueToken(existingToken.moduleName(), newPermissions, existingToken.superuser()); + } + + @Override + public ModuleAuthToken verifyToken(String token) { + // Token verification happens on the auth server side + // For now, we trust tokens issued by this provider + // In production, you might want to call a verification endpoint + throw new UnsupportedOperationException( + "Token verification is handled by the auth server. " + + "Use the auth service's token introspection endpoint if needed."); + } + + private Map convertToAdapterPermissions( + Map enginePerms) { + Map result = new HashMap<>(); + for (Map.Entry entry : enginePerms.entrySet()) { + ModuleTokenAdapter.ComponentPermission adapterPerm = switch (entry.getValue()) { + case OWNER -> ModuleTokenAdapter.ComponentPermission.OWNER; + case READ -> ModuleTokenAdapter.ComponentPermission.READ; + case WRITE -> ModuleTokenAdapter.ComponentPermission.WRITE; + }; + result.put(entry.getKey(), adapterPerm); + } + return result; + } +}