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.*_