Okay, I can set up a basic example for that using JavaScript's setInterval and the fetch API.
This setup includes:
index.html: A basic HTML page to load the script and display status.script.js:
API_ENDPOINT (you'll need to change this to your actual URL).INTERVAL_MS (60000 milliseconds).async function fetchData to perform the AJAX request using fetch.setInterval to call fetchData repeatedly.fetchData once initially when the script loads./api/data endpoint in this environment, I've added a simulation block. You should replace this with the actual fetch call when using your own endpoint.Check your browser's developer console to see the logs each time the fetchData function runs.*
Okay, I'll update the fetchData function in script.js to specifically check for a 401 status code.
I've added a specific check for response.status === 401 within the commented-out real fetch logic. If a 401 error occurs, it logs a specific message and updates the output div. You can add further actions like redirecting the user or stopping the interval timer within that if block. The general !response.ok check remains to catch other HTTP errors.
Okay, testing how your Spring Boot 3.x application handles expired AWS Cognito JWT tokens is crucial. You typically don't want to wait for real tokens to expire in your automated tests. Instead, you simulate the condition.
Here are the common and effective strategies:
1. Mocking the JwtDecoder (Recommended for Component/Integration Tests)
This is often the cleanest approach within Spring Boot tests (@WebMvcTest or @SpringBootTest). You replace the real JwtDecoder bean (which Spring Security uses to validate the token) with a mock.
JwtDecoder to throw a JwtException (specifically, often a JwtValidationException indicating an expired token) when it tries to decode a specific "expired" token string you provide in your test request header.@MockBean to inject a mock JwtDecoder.spring-boot-starter-test) to define the behavior.MockMvc with an Authorization: Bearer <your_test_expired_token_string> header.Example (@WebMvcTest):
java import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.security.oauth2.jwt.JwtValidationException; // Or a more specific one if needed import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant; import java.util.Collections; import java.util.Map;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// Assuming you have a controller mapped to /api/protected @WebMvcTest(controllers = YourProtectedController.class) // Import your Security Configuration @Import(SecurityConfig.class) // Replace with your actual security config class class SecurityExpirationTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private JwtDecoder jwtDecoder; // Mock the decoder used by Spring Security
@Test
void whenTokenIsExpired_thenUnauthorized() throws Exception {
String expiredTokenString = "fake-expired-token";
String errorMessage = "Simulated token expiration";
// Configure the mock decoder to throw JwtException for ANY token string
// You could also match on the specific expiredTokenString if needed
when(jwtDecoder.decode(anyString()))
.thenThrow(new JwtValidationException(errorMessage, Collections.emptyList()));
// Or more generally: .thenThrow(new JwtException(errorMessage));
mockMvc.perform(get("/api/protected") // Your secured endpoint
.header("Authorization", "Bearer " + expiredTokenString))
.andExpect(status().isUnauthorized()); // Expect 401
}
@Test
void whenTokenIsValid_thenOk() throws Exception {
String validTokenString = "fake-valid-token";
// Configure the mock decoder to return a valid Jwt object
Jwt mockJwt = Jwt.withTokenValue(validTokenString)
.header("alg", "RS256")
.claim("sub", "user123")
.claim("scope", "read write")
.issuedAt(Instant.now().minusSeconds(60))
.expiresAt(Instant.now().plusSeconds(3600)) // Expires in the future
.build();
when(jwtDecoder.decode(validTokenString)).thenReturn(mockJwt);
mockMvc.perform(get("/api/protected")
.header("Authorization", "Bearer " + validTokenString))
.andExpect(status().isOk()); // Expect 200 OK (or whatever your endpoint returns)
}
@Autowired
private MockMvc mockMvc;
@MockBean
private JwtDecoder jwtDecoder; // Mock the decoder used by Spring Security
@Test
void whenTokenIsExpired_thenUnauthorized() throws Exception {
String expiredTokenString = "fake-expired-token";
String errorMessage = "Simulated token expiration";
// Configure the mock decoder to throw JwtException for ANY token string
// You could also match on the specific expiredTokenString if needed
when(jwtDecoder.decode(anyString()))
.thenThrow(new JwtValidationException(errorMessage, Collections.emptyList()));
// Or more generally: .thenThrow(new JwtException(errorMessage));
mockMvc.perform(get("/api/protected") // Your secured endpoint
.header("Authorization", "Bearer " + expiredTokenString))
.andExpect(status().isUnauthorized()); // Expect 401
}
@Test
void whenTokenIsValid_thenOk() throws Exception {
String validTokenString = "fake-valid-token";
// Configure the mock decoder to return a valid Jwt object
Jwt mockJwt = Jwt.withTokenValue(validTokenString)
.header("alg", "RS256")
.claim("sub", "user123")
.claim("scope", "read write")
.issuedAt(Instant.now().minusSeconds(60))
.expiresAt(Instant.now().plusSeconds(3600)) // Expires in the future
.build();
when(jwtDecoder.decode(validTokenString)).thenReturn(mockJwt);
mockMvc.perform(get("/api/protected")
.header("Authorization", "Bearer " + validTokenString))
.andExpect(status().isOk()); // Expect 200 OK (or whatever your endpoint returns)
}
}
2. Generating Test JWTs with Past Expiration (More Involved)
This approach involves creating actual JWTs within your test code, one of which has an exp (expiration) claim set to a time in the past.
nimbus-jose-jwt or jjwt) to build and sign JWTs. Configure your test environment's JwtDecoder to trust tokens signed with your test key (you'll need a test private/public key pair).application-test.properties or @TestPropertySource) to use the public key (or a JWKS URI pointing to your test public key) for validation, overriding the production Cognito JWKS URI.exp claim set to Instant.now().minusSeconds(1).Authorization header using MockMvc.Example (Conceptual Snippet using nimbus-jose-jwt):
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.*;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Date;
import java.util.UUID;
// --- Helper method to generate expired token (place in a test utility class) ---
public String generateTestToken(Instant expirationTime, RSAPrivateKey privateKey) throws JOSEException {
JWSSigner signer = new RSASSASigner(privateKey);
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.subject("test-user")
.issuer("http://localhost:9090/realms/test") // Example issuer
.jwtID(UUID.randomUUID().toString())
.issueTime(Date.from(Instant.now().minusSeconds(60)))
.expirationTime(Date.from(expirationTime)) // Key part!
.claim("scope", "read")
.build();
SignedJWT signedJWT = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("test-key-id").build(),
claimsSet);
signedJWT.sign(signer);
return signedJWT.serialize();
}
// --- In your @SpringBootTest or @WebMvcTest ---
@Test
void whenTokenIsExpiredUsingGeneratedJwt_thenUnauthorized() throws Exception {
// Assume testKeyPair is generated and privateKey is available
// Assume Spring Security is configured to validate using testKeyPair.getPublic()
RSAPrivateKey privateKey = /* get your test private key */;
Instant pastExpiration = Instant.now().minusSeconds(10); // Expired 10 seconds ago
String expiredToken = generateTestToken(pastExpiration, privateKey);
mockMvc.perform(get("/api/protected")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized());
}
@Test
void whenTokenIsValidUsingGeneratedJwt_thenOk() throws Exception {
RSAPrivateKey privateKey = /* get your test private key */;
Instant futureExpiration = Instant.now().plusSeconds(3600); // Valid for an hour
String validToken = generateTestToken(futureExpiration, privateKey);
mockMvc.perform(get("/api/protected")
.header("Authorization", "Bearer " + validToken))
.andExpect(status().isOk()); // Or expected success status
}
// --- Test Configuration (e.g., application-test.properties) ---
// spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8081/test-jwks <OR>
// spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:test-public-key.pem
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.*;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Date;
import java.util.UUID;
// --- Helper method to generate expired token (place in a test utility class) ---
public String generateTestToken(Instant expirationTime, RSAPrivateKey privateKey) throws JOSEException {
JWSSigner signer = new RSASSASigner(privateKey);
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.subject("test-user")
.issuer("http://localhost:9090/realms/test") // Example issuer
.jwtID(UUID.randomUUID().toString())
.issueTime(Date.from(Instant.now().minusSeconds(60)))
.expirationTime(Date.from(expirationTime)) // Key part!
.claim("scope", "read")
.build();
SignedJWT signedJWT = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("test-key-id").build(),
claimsSet);
signedJWT.sign(signer);
return signedJWT.serialize();
}
// --- In your @SpringBootTest or @WebMvcTest ---
@Test
void whenTokenIsExpiredUsingGeneratedJwt_thenUnauthorized() throws Exception {
// Assume testKeyPair is generated and privateKey is available
// Assume Spring Security is configured to validate using testKeyPair.getPublic()
RSAPrivateKey privateKey = /* get your test private key */;
Instant pastExpiration = Instant.now().minusSeconds(10); // Expired 10 seconds ago
String expiredToken = generateTestToken(pastExpiration, privateKey);
mockMvc.perform(get("/api/protected")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized());
}
@Test
void whenTokenIsValidUsingGeneratedJwt_thenOk() throws Exception {
RSAPrivateKey privateKey = /* get your test private key */;
Instant futureExpiration = Instant.now().plusSeconds(3600); // Valid for an hour
String validToken = generateTestToken(futureExpiration, privateKey);
mockMvc.perform(get("/api/protected")
.header("Authorization", "Bearer " + validToken))
.andExpect(status().isOk()); // Or expected success status
}
// --- Test Configuration (e.g., application-test.properties) ---
// spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8081/test-jwks <OR>
// spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:test-public-key.pem
Choosing the Right Approach:
JwtDecoder: Simpler setup, faster execution, focuses purely on how your code reacts to the decoder signaling an expired token. Usually sufficient.For most use cases, mocking the JwtDecoder (Approach 1) provides the best balance of simplicity and effectiveness for testing the expired token scenario.*
You are absolutely correct. The Pre token generation Lambda trigger in AWS Cognito is a powerful mechanism to dynamically customize the claims within both the ID token and the access token before they are issued to the user upon successful authentication.
This allows you to inject custom attributes, modify existing ones, control group membership claims, or suppress claims based on your application's logic at runtime.
Here's how you do it:
1. Create the Lambda Function
cognito-pre-token-generation-hook).cognito-idp.amazonaws.com to invoke the function (this is usually added automatically when you link the trigger in Cognito, but good to verify).2. Write the Lambda Function Code
The Lambda function receives an event object containing details about the user, the authentication context, and the claims Cognito intends to issue. Your function needs to modify the response part of this event object.
Key parts of the event object:
event.triggerSource: Identifies why the trigger was invoked (e.g., TokenGeneration_Authentication).event.userPoolId: The ID of the user pool.event.userName: The username of the user.event.request.userAttributes: An object containing the user's attributes from the Cognito user pool (e.g., email, phone_number, custom:department).event.request.groupConfiguration: Contains information about the user's groups.event.response: This is the object you modify.
event.response.claimsOverrideDetails:
claimsToAddOrOverride: An object where you specify claims to add or overwrite (e.g., { "custom:role": "admin", "department": "Sales" }).claimsToSuppress: An array of claim names (strings) you want to remove from the token (e.g., ["email"]).groupOverrideDetails: Allows you to override the groups included in the cognito:groups claim.
groupsToOverride: Array of group names to include.iamRolesToOverride: Array of IAM role ARNs to include.preferredRole: The preferred IAM role ARN.Example (Node.js): Adding a custom role based on a user attribute
javascript // lambda_function.js
exports.handler = async (event, context) => { console.log("Received event:", JSON.stringify(event, null, 2));
// Example: Add a 'custom:clearance_level' claim based on a 'custom:department' attribute
try {
const department = event.request.userAttributes['custom:department'];
let clearanceLevel = 'level_1'; // Default level
if (department === 'security') {
clearanceLevel = 'level_5';
} else if (department === 'engineering') {
clearanceLevel = 'level_3';
}
// Add or override claims in the response
event.response = {
claimsOverrideDetails: {
claimsToAddOrOverride: {
'custom:clearance_level': clearanceLevel,
// You can override standard claims too, but be careful
// 'email_verified': 'true' // Example override
},
// Example: Suppress the phone_number claim if it exists
// claimsToSuppress: ['phone_number'],
// Example: Override group membership shown in the token
// groupOverrideDetails: {
// groupsToOverride: ['admins', 'special-users'], // Only show these groups
// // iamRolesToOverride: ['arn:aws:iam::ACCOUNT_ID:role/SpecialRole'],
// // preferredRole: 'arn:aws:iam::ACCOUNT_ID:role/SpecialRole'
// }
}
};
console.log("Modified response:", JSON.stringify(event.response, null, 2));
} catch (error) {
console.error("Error processing event:", error);
// Handle errors appropriately, maybe return the event unmodified
// or throw an error to prevent token issuance if critical
}
// Return the modified event object to Cognito
return event;
// Example: Add a 'custom:clearance_level' claim based on a 'custom:department' attribute
try {
const department = event.request.userAttributes['custom:department'];
let clearanceLevel = 'level_1'; // Default level
if (department === 'security') {
clearanceLevel = 'level_5';
} else if (department === 'engineering') {
clearanceLevel = 'level_3';
}
// Add or override claims in the response
event.response = {
claimsOverrideDetails: {
claimsToAddOrOverride: {
'custom:clearance_level': clearanceLevel,
// You can override standard claims too, but be careful
// 'email_verified': 'true' // Example override
},
// Example: Suppress the phone_number claim if it exists
// claimsToSuppress: ['phone_number'],
// Example: Override group membership shown in the token
// groupOverrideDetails: {
// groupsToOverride: ['admins', 'special-users'], // Only show these groups
// // iamRolesToOverride: ['arn:aws:iam::ACCOUNT_ID:role/SpecialRole'],
// // preferredRole: 'arn:aws:iam::ACCOUNT_ID:role/SpecialRole'
// }
}
};
console.log("Modified response:", JSON.stringify(event.response, null, 2));
} catch (error) {
console.error("Error processing event:", error);
// Handle errors appropriately, maybe return the event unmodified
// or throw an error to prevent token issuance if critical
}
// Return the modified event object to Cognito
return event;
};
3. Configure the Cognito User Pool Trigger
cognito-pre-token-generation-hook).4. Test
console.log statements for debugging.Important Considerations:
custom:xyz) usually appear in both if not suppressed. Standard claims might only appear in the ID token unless specific scopes (email, profile, etc.) are requested to include them in the access token.*_