A Spring Boot microservice for managing user data synchronized from AWS Cognito authentication.
This service provides user management capabilities for applications using AWS Cognito for authentication. It automatically creates and maintains user records based on JWT tokens issued by Cognito, using the Cognito user subject as the primary key.
- ✅ Cognito Integration: Verifies JWT tokens using AWS Cognito's JWKS endpoint (RS256 signature verification)
- ✅ Automatic User Sync: Creates user records on first sign-in, returns existing users on subsequent requests
- ✅ Idempotent Operations: Safe to call repeatedly with the same token
- ✅ Subject-based Identity: Uses Cognito subject claim as the stable user ID
- ✅ No Password Storage: Authentication is fully delegated to AWS Cognito
- ✅ Logout Support: Revokes Refresh Tokens via Cognito GlobalSignOut on
POST /user/logout
Flutter App → AWS Cognito (Sign-in/Sign-up)
↓ (JWT tokens)
Flutter App → User Service → Database
- User signs up/in through AWS Cognito (handled by your Flutter app)
- Cognito returns access_token and id_token
- Flutter app calls
POST /api/users/create-userwith tokens - Service verifies token signature, extracts user info
- Service creates new user (first-time) or returns existing user
- Flutter app uses the returned
useridfor subsequent API calls
Create a new user on first sign-in or return existing user.
POST /api/users/create-user
Authorization: Bearer {cognito_access_token}
X-Id-Token: {cognito_id_token} (optional)Aliases: /api/users/sync, /api/users/me
Success Response (200):
{
"userid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "user@example.com",
"preferenceId": null
}Error Response (400):
{
"error": "Token verification failed: JWT signature verification failed"
}Retrieve a user by their Cognito subject ID.
GET /api/users/{userid}Success Response (200):
{
"userid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "user@example.com",
"preferenceId": null
}Error Response (404): User not found
Delete a user by their Cognito subject ID.
Delete /api/users/{userid}Success Response (204):
No ContentError Response (404): User not found
Revoke all Refresh Tokens for the authenticated user via AWS Cognito GlobalSignOut.
POST /user/logout
Authorization: Bearer {cognito_access_token}Success Response (204): No Content
Error Responses:
400 Bad Request— Missing or malformedAuthorizationheader401 Unauthorized— Token is invalid or already expired
Important note for frontend: Upon receiving 204, the client must delete all tokens (Access Token, ID Token, Refresh Token) from local storage. The Access Token may remain technically valid for up to 1 hour after logout — this is an accepted limitation of stateless JWT. No new tokens can be issued using the revoked Refresh Token.
Update src/main/resources/application.properties:
# AWS Cognito Configuration
aws.cognito.region=ap-southeast-1
aws.cognito.userPoolId=ap-southeast-1_HxnNvwF6v
aws.cognito.jwks.url=https://cognito-idp.${aws.cognito.region}.amazonaws.com/${aws.cognito.userPoolId}/.well-known/jwks.json- User Pool ID: AWS Console → Cognito → User Pools → [Your Pool] → Pool ID at top
- Region: The AWS region where your User Pool exists (e.g.,
ap-southeast-1) - Client ID: Found under App Integration → App Clients (needed for sign-in, not this service)
- Java 21
- Spring Boot 3.3.4
- Spring Data JPA - Database access
- Nimbus JOSE+JWT - JWT signature verification
- Gradle - Build tool
- Java 21+
- Gradle (or use included wrapper)
Local Machine
↓
localhost:5432
↓
SSM Port Forwarding
↓
Bastion EC2 (inside VPC)
↓
RDS (private)
brew install awscli
aws --version
brew install session-manager-plugin
session-manager-plugin
aws configure
AWS Access Key ID: <aws-access-key>
AWS Secret Access Key: <aws-secret-key>
Default region: ap-southeast-1
Default output format: json
aws sts get-caller-identity
"Arn": "arn:aws:iam::<account-id>:user/SWE5006"
Keep this terminal open. It maintains the tunnel.
aws ssm start-session \
--target i-061983d3385eb80db \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["swe5006-nus-g3-pg-dev.clee6i664xzo.ap-southeast-1.rds.amazonaws.com"],"portNumber":["5432"],"localPortNumber":["5432"]}'
Starting session with SessionId: ...
Port 5432 opened...
- Go to Run → Edit Configurations
- Set Environment variables:
SPRING_PROFILES_ACTIVE=local
SPRING_PROFILES_ACTIVE=local ./gradlew bootRun
curl -X POST 'https://cognito-idp.ap-southeast-1.amazonaws.com/' \
--header 'X-Amz-Target: AWSCognitoIdentityProviderService.InitiateAuth' \
--header 'Content-Type: application/x-amz-json-1.1' \
--data '{
"AuthFlow": "USER_PASSWORD_AUTH",
"ClientId": "1d1jkchdvgt5tldbb0hivruird",
"AuthParameters": {
"USERNAME": "your-email@example.com",
"PASSWORD": "your-password"
}
}'ACCESS_TOKEN="<paste_access_token_here>"
curl -X POST 'http://localhost:8080/api/users/create-user' \
--header "Authorization: Bearer $ACCESS_TOKEN"USER_ID="<userid_from_previous_response>"
curl -X GET "http://localhost:8080/api/users/$USER_ID"Run the standard test suite with coverage:
./gradlew clean test jacocoTestReportReports:
- Test report:
build/reports/tests/test/index.html - Coverage report:
build/reports/jacoco/test/html/index.html
Integration tests live under src/integrationTest and run with a dedicated Gradle task:
./gradlew integrationTestIf you are running against a local PostgreSQL instance, make sure the database is reachable first. The test profile reads:
DB_HOSTDB_PORTDB_NAMEDB_USERNAMEDB_PASSWORD
with local defaults defined in src/integrationTest/resources/application-test.yaml.
Report:
- Integration test report:
build/reports/tests/integrationTest/index.html
Performance tests remain in the separate Gradle subproject performancetest and are run with Gatling:
./gradlew :performancetest:gatlingRunReport:
- Gatling report:
performancetest/build/reports/gatling/<simulation-run>/index.html
| Column | Type | Description |
|---|---|---|
| userid | VARCHAR(255) | Primary key - Cognito subject claim |
| VARCHAR(255) | User's email from JWT claims | |
| preference_id | VARCHAR(255) | Optional user preferences reference |
Note: The table name is app_user (not user) to avoid SQL reserved keyword conflicts.
- ✅ RS256 Signature Verification: Validates token signature using Cognito's public keys
- ✅ Issuer Validation: Ensures token is from your specific Cognito User Pool
- ✅ Token Use Validation: Confirms token is an access or ID token
- ✅ Expiration Check: Automatically rejects expired tokens
- ✅ JWKS Key Caching: Public keys are cached and auto-refreshed
- Always use HTTPS in production
- Implement rate limiting
- Monitor failed verification attempts
- Keep dependencies updated
- Use environment-specific configurations
- TESTING_GUIDE.md - Complete testing workflow with real tokens
- README_JWT_VERIFICATION.md - Detailed JWT verification documentation
Issue: Invalid token issuer
Solution: Verify that aws.cognito.userPoolId matches the pool that issued the token. Decode your token at jwt.io and check the iss claim.
Issue: JWT signature verification failed
Solution: Get a fresh token (tokens expire after 1 hour). Ensure your User Pool ID is correct.
Issue: Failed to fetch JWKS
Solution: Check network connectivity to AWS. Ensure outbound HTTPS traffic is allowed.
Issue: Table "USER" not found
Solution: The service auto-creates tables on startup. Check that spring.jpa.hibernate.ddl-auto=update is set.
This service is designed for containerized deployment:
- Dockerfile included for building container images
- Gradle build produces
app.jarin bootJar task - JaCoCo test coverage reports generated on build
- Harness CI/CD pipeline configuration in
.harness/directory
- PostgreSQL for production (AWS RDS)
- User profile update endpoints
- preferences-service integration