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/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/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/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/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/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/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/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..404a2b0 --- /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,181 @@ +/* + * 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; + +/** + * 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/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/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); +} 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/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; + } +} 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;