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:
+ *
+ * - grant_type=urn:stormstack:grant-type:match-token (required)
+ * - match_id (required) - the match ID
+ * - player_id (required) - the player ID
+ * - player_name (required) - the player's display name
+ * - container_id (optional) - the container ID
+ * - user_id (optional) - the auth user ID
+ * - scopes (optional) - JSON array of scope strings
+ * - valid_for_hours (optional) - token validity in hours, defaults to 8
+ *
+ *
+ * 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:
+ *
+ * - grant_type=urn:stormstack:grant-type:module-token (required)
+ * - module_name (required) - the name of the module
+ * - component_permissions (required) - JSON map of permissions
+ * - superuser (optional) - boolean, defaults to false
+ * - container_id (optional) - scope token to a specific container
+ *
+ *
+ * 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;