diff --git a/docker-compose.yml b/docker-compose.yml index afe247e2..096cef2f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -463,6 +463,13 @@ services: # External service configuration CATALOGUE_BASE_URL: http://sticker-catalogue:8080 + + # OAuth/JWT configuration + # OAUTH_ISSUER: The issuer claim expected in JWT tokens (matches what user-management sets) + # Note: OpenIddict adds trailing slash to issuer, so we must include it here + # OAUTH_JWKS_URL: Internal Docker network URL to fetch JWKS for token validation + OAUTH_ISSUER: ${DEPLOYMENT_HOST_URL}/ + OAUTH_JWKS_URL: http://user-management:8080/.well-known/jwks healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] interval: 1s diff --git a/e2e/tests/api/auth-required.spec.ts b/e2e/tests/api/auth-required.spec.ts new file mode 100644 index 00000000..104cf46f --- /dev/null +++ b/e2e/tests/api/auth-required.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; + +test.describe('API Authentication Requirements', () => { + test.describe('Sticker Catalogue - Protected Endpoints', () => { + test('POST /api/stickers/v1/ requires authentication', async ({ request }) => { + const response = await request.post('/api/stickers/v1/', { + data: { + stickerName: 'Test Sticker', + stickerDescription: 'A test sticker', + }, + }); + expect(response.status()).toBe(401); + }); + + test('PUT /api/stickers/v1/{id} requires authentication', async ({ request }) => { + const response = await request.put('/api/stickers/v1/sticker-001', { + data: { + stickerName: 'Updated Sticker', + }, + }); + expect(response.status()).toBe(401); + }); + + test('DELETE /api/stickers/v1/{id} requires authentication', async ({ request }) => { + const response = await request.delete('/api/stickers/v1/sticker-001'); + expect(response.status()).toBe(401); + }); + + test('POST /api/stickers/v1/{id}/image requires authentication', async ({ request }) => { + const response = await request.post('/api/stickers/v1/sticker-001/image', { + headers: { + 'Content-Type': 'image/png', + }, + data: Buffer.from('fake-image-data'), + }); + expect(response.status()).toBe(401); + }); + }); + + test.describe('Sticker Catalogue - Public Endpoints', () => { + test('GET /api/stickers/v1/ does not require authentication', async ({ request }) => { + const response = await request.get('/api/stickers/v1/'); + expect(response.ok()).toBeTruthy(); + }); + + test('GET /api/stickers/v1/{id} does not require authentication', async ({ request }) => { + const response = await request.get('/api/stickers/v1/sticker-001'); + expect(response.ok()).toBeTruthy(); + }); + + test('GET /api/stickers/v1/{id}/image does not require authentication', async ({ request }) => { + const response = await request.get('/api/stickers/v1/sticker-001/image'); + expect(response.ok()).toBeTruthy(); + }); + }); + + test.describe('Sticker Award - Protected Endpoints', () => { + test('POST /api/awards/v1/assignments/{userId} requires authentication', async ({ request }) => { + const response = await request.post('/api/awards/v1/assignments/user-001', { + data: { + stickerId: 'sticker-001', + }, + }); + expect(response.status()).toBe(401); + }); + + test('DELETE /api/awards/v1/assignments/{userId}/{stickerId} requires authentication', async ({ request }) => { + const response = await request.delete('/api/awards/v1/assignments/user-001/sticker-001'); + expect(response.status()).toBe(401); + }); + }); + + test('GET /api/awards/v1/assignments/{userId} requires authentication', async ({ request }) => { + const response = await request.get('/api/awards/v1/assignments/user-001'); + expect(response.status()).toBe(401); + }); +}); diff --git a/e2e/tests/api/endpoints.spec.ts b/e2e/tests/api/endpoints.spec.ts index e98ce217..38a5876a 100644 --- a/e2e/tests/api/endpoints.spec.ts +++ b/e2e/tests/api/endpoints.spec.ts @@ -29,7 +29,7 @@ test.describe('API Endpoints', () => { }); test('supports pagination parameters', async ({ request }) => { - const response = await request.get('/api/stickers/v1?page=0&size=5'); + const response = await request.get('/api/stickers/v1/?page=0&size=5'); expect(response.ok()).toBeTruthy(); const data = await response.json(); @@ -43,7 +43,7 @@ test.describe('API Endpoints', () => { test('returns individual sticker by ID', async ({ request }) => { // First get a valid ID - const listResponse = await request.get('/api/stickers/v1'); + const listResponse = await request.get('/api/stickers/v1/'); const listData = await listResponse.json(); const stickerId = listData.stickers[0]?.stickerId; @@ -58,7 +58,13 @@ test.describe('API Endpoints', () => { } }); - test('returns 404 for non-existent sticker', async ({ request }) => { + // SKIPPED: CloudFront's distribution-level errorResponses intercepts API 404s and returns + // the SPA's index.html with status 200 instead. This is because errorResponses cannot be + // scoped per-behavior/origin - they apply to all origins including the API Gateway. + // The fix requires using a CloudFront Function on the S3 behavior to handle SPA routing + // instead of relying on errorResponses. + // See: https://github.com/DataDog/stickerlandia/issues/173 + test.skip('returns 404 for non-existent sticker', async ({ request }) => { const response = await request.get('/api/stickers/v1/non-existent-id-xyz'); expect(response.status()).toBe(404); }); diff --git a/e2e/tests/authenticated/catalogue.spec.ts b/e2e/tests/authenticated/catalogue.spec.ts index b220e420..e6a79d3c 100644 --- a/e2e/tests/authenticated/catalogue.spec.ts +++ b/e2e/tests/authenticated/catalogue.spec.ts @@ -199,7 +199,7 @@ test.describe('Sticker Detail Page', () => { test('is accessible via direct URL with valid ID', async ({ page, request }) => { // Get a valid sticker ID from API - const apiResponse = await request.get('/api/stickers/v1'); + const apiResponse = await request.get('/api/stickers/v1/'); const data = await apiResponse.json(); const stickerId = data.stickers[0]?.stickerId; diff --git a/scripts/test-services.sh b/scripts/test-services.sh index 74bf9aa5..0a123728 100755 --- a/scripts/test-services.sh +++ b/scripts/test-services.sh @@ -54,11 +54,12 @@ echo -e "Compose file: $COMPOSE_FILE" echo "" # Test configuration: URL, expected HTTP code, service name, description +# Note: Use health/public endpoints only - protected endpoints require authentication declare -a TESTS=( "http://localhost:8081/dashboard/,200,traefik,Traefik Dashboard" "http://localhost:8082,200,redpanda-console,Redpanda Console" - "http://localhost:8080/api/stickers/v1/sticker-001,200,sticker-catalogue,Sticker Catalogue API" - "http://localhost:8080/api/awards/v1/assignments/user-001,200,sticker-award,Sticker Award API" + "http://localhost:8080/api/stickers/v1/,200,sticker-catalogue,Sticker Catalogue API" + "http://localhost:8080/api/awards/v1/health,200,sticker-award,Sticker Award Health" "http://localhost:8080/api/users/v1/health,200,user-management,User Management Health" ) diff --git a/sticker-award/cmd/server/main.go b/sticker-award/cmd/server/main.go index 358ad617..00ec3385 100644 --- a/sticker-award/cmd/server/main.go +++ b/sticker-award/cmd/server/main.go @@ -20,6 +20,7 @@ import ( dd_logrus "github.com/DataDog/dd-trace-go/contrib/sirupsen/logrus/v2" "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer" "github.com/DataDog/dd-trace-go/v2/profiler" + "github.com/datadog/stickerlandia/sticker-award/internal/api/middleware" "github.com/datadog/stickerlandia/sticker-award/internal/api/router" "github.com/datadog/stickerlandia/sticker-award/internal/clients/catalogue" "github.com/datadog/stickerlandia/sticker-award/internal/config" @@ -132,9 +133,21 @@ func main() { assignmentService := service.NewAssigner(assignmentRepo, catalogueClient, validator.New(), producer) log.Info("Assignment service initialized") + // Initialize JWT validator for authentication + log.WithFields(log.Fields{ + "jwksUrl": cfg.Auth.JWKSUrl, + "issuer": cfg.Auth.Issuer, + }).Info("Initializing JWT validator...") + jwtValidator, err := middleware.NewJWTValidator(&cfg.Auth) + if err != nil { + log.WithFields(log.Fields{"error": err}).Fatal("Failed to initialize JWT validator") + } + defer jwtValidator.Close() + log.Info("JWT validator initialized") + // Initialize HTTP router log.Info("Setting up HTTP router...") - r := router.Setup(db, cfg, assignmentService) + r := router.Setup(db, cfg, assignmentService, jwtValidator) log.Info("HTTP router configured") // Register our **user registered** event handler. This responds to new users appearing diff --git a/sticker-award/go.mod b/sticker-award/go.mod index c57a5347..3eb5761b 100644 --- a/sticker-award/go.mod +++ b/sticker-award/go.mod @@ -10,15 +10,14 @@ require ( github.com/DataDog/dd-trace-go/contrib/sirupsen/logrus/v2 v2.2.2 github.com/DataDog/dd-trace-go/v2 v2.2.2 github.com/IBM/sarama v1.45.2 - github.com/aws/aws-cdk-go/awscdk/v2 v2.232.2 + github.com/MicahParks/keyfunc/v3 v3.3.5 github.com/aws/aws-sdk-go-v2 v1.39.5 github.com/aws/aws-sdk-go-v2/config v1.31.16 github.com/aws/aws-sdk-go-v2/service/eventbridge v1.45.9 github.com/aws/aws-sdk-go-v2/service/sqs v1.42.12 - github.com/aws/constructs-go/constructs/v10 v10.4.4 - github.com/aws/jsii-runtime-go v1.121.0 github.com/gin-gonic/gin v1.9.1 github.com/go-playground/validator/v10 v10.15.5 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-migrate/migrate/v4 v4.16.2 github.com/google/uuid v1.6.0 github.com/sirupsen/logrus v1.9.3 @@ -52,6 +51,7 @@ require ( github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.27.0 // indirect github.com/DataDog/sketches-go v1.4.7 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/MicahParks/jwkset v0.5.19 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.20 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.12 // indirect @@ -67,9 +67,6 @@ require ( github.com/aws/smithy-go v1.23.1 // indirect github.com/bytedance/sonic v1.12.0 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect - github.com/cdklabs/awscdk-asset-awscli-go/awscliv1/v2 v2.2.242 // indirect - github.com/cdklabs/awscdk-asset-node-proxy-agent-go/nodeproxyagentv6/v2 v2.1.0 // indirect - github.com/cdklabs/cloud-assembly-schema-go/awscdkcloudassemblyschema/v48 v48.20.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect @@ -91,7 +88,6 @@ require ( github.com/eapache/queue v1.1.0 // indirect github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect github.com/ebitengine/purego v0.8.4 // indirect - github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect @@ -131,7 +127,6 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -172,7 +167,6 @@ require ( github.com/tklauser/numcpus v0.9.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect - github.com/yuin/goldmark v1.4.13 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/collector/component v1.31.0 // indirect @@ -193,14 +187,12 @@ require ( golang.org/x/arch v0.4.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.36.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/grpc v1.73.0 // indirect diff --git a/sticker-award/go.sum b/sticker-award/go.sum index 63230f20..f927fc1a 100644 --- a/sticker-award/go.sum +++ b/sticker-award/go.sum @@ -96,11 +96,13 @@ github.com/IBM/sarama v1.45.2 h1:8m8LcMCu3REcwpa7fCP6v2fuPuzVwXDAM2DOv3CBrKw= github.com/IBM/sarama v1.45.2/go.mod h1:ppaoTcVdGv186/z6MEKsMm70A5fwJfRTpstI37kVn3Y= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/MicahParks/jwkset v0.5.19 h1:XZCsgJv05DBCvxEHYEHlSafqiuVn5ESG0VRB331Fxhw= +github.com/MicahParks/jwkset v0.5.19/go.mod h1:q8ptTGn/Z9c4MwbcfeCDssADeVQb3Pk7PnVxrvi+2QY= +github.com/MicahParks/keyfunc/v3 v3.3.5 h1:7ceAJLUAldnoueHDNzF8Bx06oVcQ5CfJnYwNt1U3YYo= +github.com/MicahParks/keyfunc/v3 v3.3.5/go.mod h1:SdCCyMJn/bYqWDvARspC6nCT8Sk74MjuAY22C7dCST8= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/aws/aws-cdk-go/awscdk/v2 v2.232.2 h1:pCNfP+b7ylf5AaGHOonCkfsJ1RcGuzDNHMucEvXoMY8= -github.com/aws/aws-cdk-go/awscdk/v2 v2.232.2/go.mod h1:iNg8HWCTBI6HAhvtKYTA0+RAKa01M6I3dkEEEMn7RLM= github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w= github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= github.com/aws/aws-sdk-go-v2/config v1.31.16 h1:E4Tz+tJiPc7kGnXwIfCyUj6xHJNpENlY11oKpRTgsjc= @@ -131,10 +133,6 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4 h1:tBw2Qhf0kj4ZwtsVpDiVRU3z github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4/go.mod h1:Deq4B7sRM6Awq/xyOBlxBdgW8/Z926KYNNaGMW2lrkA= github.com/aws/aws-sdk-go-v2/service/sts v1.39.0 h1:C+BRMnasSYFcgDw8o9H5hzehKzXyAb9GY5v/8bP9DUY= github.com/aws/aws-sdk-go-v2/service/sts v1.39.0/go.mod h1:4EjU+4mIx6+JqKQkruye+CaigV7alL3thVPfDd9VlMs= -github.com/aws/constructs-go/constructs/v10 v10.4.4 h1:LL7cFqtg3B4t0ut2shtGxxeubEc+uXY5DDoWoRBuLCs= -github.com/aws/constructs-go/constructs/v10 v10.4.4/go.mod h1:BhiNi267cuLnCZpYK59k68wKYh5G5AF0SoVA8f4iyGo= -github.com/aws/jsii-runtime-go v1.121.0 h1:21aE+9WvNOX/jYSToifEswBcxZElMKJxF6fdByvZzC0= -github.com/aws/jsii-runtime-go v1.121.0/go.mod h1:67f+oydH0cMr//tkmNNj9QpKk02hNEEVu4CByxkpGB0= github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bytedance/sonic v1.12.0 h1:YGPgxF9xzaCNvd/ZKdQ28yRovhfMFZQjuk6fKBzZ3ls= @@ -142,12 +140,6 @@ github.com/bytedance/sonic v1.12.0/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKz github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cdklabs/awscdk-asset-awscli-go/awscliv1/v2 v2.2.242 h1:S+uSK6PJ3gbS5imAcMT198W5a/kNbICkpLy0cpV7RO8= -github.com/cdklabs/awscdk-asset-awscli-go/awscliv1/v2 v2.2.242/go.mod h1:1FHlu1VKVvrE/Bmcow4crPddJlOWhEXde/Zi4TcUhkA= -github.com/cdklabs/awscdk-asset-node-proxy-agent-go/nodeproxyagentv6/v2 v2.1.0 h1:kElXjprC8wkpJu58vp+WFH6z0AJw4zitg5iSKJPKe3c= -github.com/cdklabs/awscdk-asset-node-proxy-agent-go/nodeproxyagentv6/v2 v2.1.0/go.mod h1:JY4UnvNa1YDGQ4H5wohXTHl6YVY3uCDUWl4JYUrQfb8= -github.com/cdklabs/cloud-assembly-schema-go/awscdkcloudassemblyschema/v48 v48.20.0 h1:xIOiSJPMXeM6SxfmqtMms+TOwaxuSii5ZvUUBdCIvvQ= -github.com/cdklabs/cloud-assembly-schema-go/awscdkcloudassemblyschema/v48 v48.20.0/go.mod h1:Mv/KtlUxCbyDI6hGu+YgEXn/nBsJ7WfQnUOw9zyBHvU= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -215,8 +207,6 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= @@ -258,6 +248,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA= github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= @@ -410,9 +402,6 @@ github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMD github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -548,7 +537,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -663,8 +651,6 @@ golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRu golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= @@ -786,7 +772,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -865,8 +850,6 @@ golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/sticker-award/infra/aws/lib/api.ts b/sticker-award/infra/aws/lib/api.ts index e5262c1c..e8b1bd8c 100644 --- a/sticker-award/infra/aws/lib/api.ts +++ b/sticker-award/infra/aws/lib/api.ts @@ -86,6 +86,7 @@ export class Api extends Construct { MESSAGING_PROVIDER: "aws", CATALOGUE_BASE_URL: `https://${props.serviceProps.cloudfrontDistribution.distributionDomainName}`, USER_REGISTERED_QUEUE_URL: userRegisteredQueue.queueUrl, + OAUTH_ISSUER: `https://${props.serviceProps.cloudfrontDistribution.distributionDomainName}/`, ...props.serviceProps.messagingConfiguration.asEnvironmentVariables(), }, secrets: secrets, diff --git a/sticker-award/internal/api/middleware/jwt.go b/sticker-award/internal/api/middleware/jwt.go new file mode 100644 index 00000000..2305d078 --- /dev/null +++ b/sticker-award/internal/api/middleware/jwt.go @@ -0,0 +1,132 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +package middleware + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/MicahParks/keyfunc/v3" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" + + "github.com/datadog/stickerlandia/sticker-award/internal/config" +) + +// Claims represents the JWT claims we expect +type Claims struct { + jwt.RegisteredClaims + Sub string `json:"sub"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` +} + +// JWTValidator handles JWT token validation using JWKS +type JWTValidator struct { + jwks keyfunc.Keyfunc + issuer string + clockSkew time.Duration + cancelFunc context.CancelFunc +} + +// NewJWTValidator creates a new JWT validator with the given configuration +func NewJWTValidator(cfg *config.AuthConfig) (*JWTValidator, error) { + // Create context with cancel for cleanup + ctx, cancel := context.WithCancel(context.Background()) + + jwksUrl := cfg.JWKSUrl() + log.WithFields(log.Fields{ + "issuer": cfg.Issuer, + "jwksUrl": jwksUrl, + }).Info("Initializing JWT validator") + + // Create JWKS keyfunc with background refresh + jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksUrl}) + if err != nil { + cancel() + log.WithError(err).WithField("jwksUrl", jwksUrl).Error("Failed to fetch JWKS") + return nil, err + } + + log.Info("JWT validator initialized successfully") + + return &JWTValidator{ + jwks: jwks, + issuer: cfg.Issuer, + clockSkew: time.Duration(cfg.ClockSkew) * time.Second, + cancelFunc: cancel, + }, nil +} + +// JWT returns a Gin middleware that validates JWT tokens +func (v *JWTValidator) JWT() gin.HandlerFunc { + return func(c *gin.Context) { + // Extract token from Authorization header + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + log.WithContext(c.Request.Context()).Debug("Missing Authorization header") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "missing authorization header", + }) + return + } + + // Check Bearer prefix + if !strings.HasPrefix(authHeader, "Bearer ") { + log.WithContext(c.Request.Context()).Debug("Invalid Authorization header format") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid authorization header format", + }) + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + // Parse and validate the token + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, v.jwks.Keyfunc, + jwt.WithIssuer(v.issuer), + jwt.WithLeeway(v.clockSkew), + ) + + if err != nil { + log.WithContext(c.Request.Context()).WithFields(log.Fields{ + "error": err.Error(), + "expectedIssuer": v.issuer, + }).Warn("Token validation failed") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid token", + }) + return + } + + if !token.Valid { + log.WithContext(c.Request.Context()).Debug("Token is not valid") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid token", + }) + return + } + + // Extract claims and store in context + if claims, ok := token.Claims.(*Claims); ok { + c.Set("user_id", claims.Sub) + c.Set("user_email", claims.Email) + c.Set("user_name", claims.Name) + c.Set("claims", claims) + } + + c.Next() + } +} + +// Close releases resources used by the validator +func (v *JWTValidator) Close() { + if v.cancelFunc != nil { + v.cancelFunc() + } +} diff --git a/sticker-award/internal/api/router/router.go b/sticker-award/internal/api/router/router.go index 0e372dac..4fb19f7c 100644 --- a/sticker-award/internal/api/router/router.go +++ b/sticker-award/internal/api/router/router.go @@ -16,7 +16,7 @@ import ( ) // Setup configures and returns the Gin router with all routes and middleware -func Setup(db *gorm.DB, cfg *config.Config, assignmentService domainservice.Assigner) *gin.Engine { +func Setup(db *gorm.DB, cfg *config.Config, assignmentService domainservice.Assigner, jwtValidator *middleware.JWTValidator) *gin.Engine { // Set Gin mode based on environment if cfg.Logging.Level == "debug" { gin.SetMode(gin.DebugMode) @@ -32,16 +32,24 @@ func Setup(db *gorm.DB, cfg *config.Config, assignmentService domainservice.Assi r.Use(middleware.Recovery()) r.Use(middleware.CORS()) - // Health check endpoint + // Health check endpoint (public) r.GET("/health", handlers.NewHealthHandler(db).Handle) // API v1 routes v1 := r.Group("/api/awards/v1") { + // Health check endpoint under API v1 (public, accessible via traefik) + v1.GET("/health", handlers.NewHealthHandler(db).Handle) + // Assignment routes assignments := v1.Group("/assignments") { assignmentHandler := handlers.NewAssignmentHandler(assignmentService) + + // All assignment endpoints require JWT authentication + if jwtValidator != nil { + assignments.Use(jwtValidator.JWT()) + } assignments.GET("/:userId", assignmentHandler.GetUserStickers) assignments.POST("/:userId", assignmentHandler.AssignSticker) assignments.DELETE("/:userId/:stickerId", assignmentHandler.RemoveSticker) diff --git a/sticker-award/internal/config/config.go b/sticker-award/internal/config/config.go index a81821fb..6a895015 100644 --- a/sticker-award/internal/config/config.go +++ b/sticker-award/internal/config/config.go @@ -32,6 +32,23 @@ type Config struct { AWS AWSConfig `mapstructure:"aws"` Catalogue CatalogueConfig `mapstructure:"catalogue"` Logging LoggingConfig `mapstructure:"logging"` + Auth AuthConfig `mapstructure:"auth"` +} + +// AuthConfig holds JWT authentication configuration +type AuthConfig struct { + Issuer string `mapstructure:"issuer"` + JwksUrl string `mapstructure:"jwks_url"` + ClockSkew int `mapstructure:"clock_skew"` +} + +// JWKSUrl returns the JWKS URL - uses explicit jwks_url if set, otherwise derives from issuer +func (a *AuthConfig) JWKSUrl() string { + if a.JwksUrl != "" { + return a.JwksUrl + } + // Normalize issuer (strip trailing slash) before appending path to avoid double slashes + return strings.TrimSuffix(a.Issuer, "/") + "/.well-known/jwks" } // ServerConfig holds HTTP server configuration @@ -220,6 +237,15 @@ func setDefaults() { // Logging defaults viper.SetDefault("logging.level", "info") viper.SetDefault("logging.format", "json") + + // Auth defaults - JWKS URL can be set separately from issuer for Docker networking + viper.SetDefault("auth.issuer", "http://user-management:8080") + viper.SetDefault("auth.jwks_url", "") // Empty means derive from issuer + viper.SetDefault("auth.clock_skew", 30) + + // Explicit env var bindings for auth config + _ = viper.BindEnv("auth.issuer", "OAUTH_ISSUER") + _ = viper.BindEnv("auth.jwks_url", "OAUTH_JWKS_URL") } // ConnectionString returns the database connection string diff --git a/sticker-award/mise.toml b/sticker-award/mise.toml index 084e6429..5e96dceb 100644 --- a/sticker-award/mise.toml +++ b/sticker-award/mise.toml @@ -33,13 +33,13 @@ run = "npx cdk deploy --require-approval never" [tasks."aws:deploy:local"] description = "Deploy to AWS (build container locally)" -depends = ["//:env:setup", "//shared:aws:deploy:local", "//user-management:aws:deploy:local"] +depends = ["//:env:setup", "//shared:aws:deploy:local"] env = { DEPLOY_MODE = "local" } run = "mise run aws:deploy" [tasks."aws:deploy:release"] description = "Deploy to AWS using prebuilt GHCR images" -depends = ["//:env:setup", "//shared:aws:deploy:release", "//user-management:aws:deploy:release"] +depends = ["//:env:setup", "//shared:aws:deploy:release"] env = { DEPLOY_MODE = "release" } run = "mise run aws:deploy" diff --git a/sticker-award/tests/integration/api_test.go b/sticker-award/tests/integration/api_test.go index cbae14f9..064dc370 100644 --- a/sticker-award/tests/integration/api_test.go +++ b/sticker-award/tests/integration/api_test.go @@ -125,7 +125,7 @@ func setupTestEnvironment(t *testing.T) *TestEnvironment { // Set up router gin.SetMode(gin.TestMode) - router := router.Setup(db, cfg, assignmentService) + router := router.Setup(db, cfg, assignmentService, nil) // Clean up function t.Cleanup(func() { diff --git a/sticker-catalogue/infra/aws/lib/api.ts b/sticker-catalogue/infra/aws/lib/api.ts index 6bfda617..17931f89 100644 --- a/sticker-catalogue/infra/aws/lib/api.ts +++ b/sticker-catalogue/infra/aws/lib/api.ts @@ -77,6 +77,7 @@ export class Api extends Construct { QUARKUS_DATASOURCE_JDBC_ACQUISITION_TIMEOUT: "30S", QUARKUS_S3_PATH_STYLE_ACCESS: "true", STICKER_IMAGES_BUCKET: props.stickerImagesBucket.bucketName, + OAUTH_ISSUER: `https://${props.serviceProps.cloudfrontDistribution.distributionDomainName}`, ...props.serviceProps.messagingProps.asEnvironmentVariables(), }, secrets: secrets, diff --git a/sticker-catalogue/mise.toml b/sticker-catalogue/mise.toml index bd338ab5..333c3940 100644 --- a/sticker-catalogue/mise.toml +++ b/sticker-catalogue/mise.toml @@ -33,13 +33,13 @@ run = "npx cdk deploy --require-approval never" [tasks."aws:deploy:local"] description = "Deploy to AWS (build container locally)" -depends = ["//:env:setup", "//shared:aws:deploy:local", "//user-management:aws:deploy:local"] +depends = ["//:env:setup", "//shared:aws:deploy:local"] env = { DEPLOY_MODE = "local" } run = "mise run aws:deploy" [tasks."aws:deploy:release"] description = "Deploy to AWS using prebuilt GHCR images" -depends = ["//:env:setup", "//shared:aws:deploy:release", "//user-management:aws:deploy:release"] +depends = ["//:env:setup", "//shared:aws:deploy:release"] env = { DEPLOY_MODE = "release" } run = "mise run aws:deploy" diff --git a/sticker-catalogue/pom.xml b/sticker-catalogue/pom.xml index 427a747b..4ce03796 100644 --- a/sticker-catalogue/pom.xml +++ b/sticker-catalogue/pom.xml @@ -105,6 +105,10 @@ io.quarkus quarkus-opentelemetry + + io.quarkus + quarkus-smallrye-jwt + @@ -119,6 +123,11 @@ quarkus-junit5 test + + io.quarkus + quarkus-test-security + test + io.rest-assured diff --git a/sticker-catalogue/src/main/java/com/datadoghq/stickerlandia/stickercatalogue/StickerResource.java b/sticker-catalogue/src/main/java/com/datadoghq/stickerlandia/stickercatalogue/StickerResource.java index aa98510c..0bfb951b 100644 --- a/sticker-catalogue/src/main/java/com/datadoghq/stickerlandia/stickercatalogue/StickerResource.java +++ b/sticker-catalogue/src/main/java/com/datadoghq/stickerlandia/stickercatalogue/StickerResource.java @@ -14,6 +14,7 @@ import com.datadoghq.stickerlandia.stickercatalogue.dto.StickerImageUploadResponse; import com.datadoghq.stickerlandia.stickercatalogue.dto.UpdateStickerRequest; import io.opentelemetry.api.trace.Span; +import io.quarkus.security.Authenticated; import io.smallrye.common.constraint.NotNull; import jakarta.inject.Inject; import jakarta.ws.rs.Consumes; @@ -67,6 +68,7 @@ public GetAllStickersResponse getAllStickers( * @return response containing the created sticker details */ @POST + @Authenticated @Produces("application/json") @Consumes("application/json") @Operation(summary = "Create a new sticker") @@ -110,6 +112,7 @@ public Response getStickerMetadata(@PathParam("stickerId") String stickerId) { * @return response containing the updated sticker details */ @PUT + @Authenticated @Path("/{stickerId}") @Produces("application/json") @Consumes("application/json") @@ -136,6 +139,7 @@ public Response updateStickerMetadata( * @return response indicating the deletion result */ @DELETE + @Authenticated @Path("/{stickerId}") @Operation(summary = "Delete a sticker from the catalog") public Response deleteSticker(@PathParam("stickerId") String stickerId) { @@ -200,6 +204,7 @@ public Response getStickerImage(@PathParam("stickerId") String stickerId) { * @return response containing the upload result */ @POST + @Authenticated @Path("/{stickerId}/image") @Consumes("image/png") @Produces("application/json") diff --git a/sticker-catalogue/src/main/java/com/datadoghq/stickerlandia/stickercatalogue/security/IssuerValidationFilter.java b/sticker-catalogue/src/main/java/com/datadoghq/stickerlandia/stickercatalogue/security/IssuerValidationFilter.java new file mode 100644 index 00000000..e1a3b0a0 --- /dev/null +++ b/sticker-catalogue/src/main/java/com/datadoghq/stickerlandia/stickercatalogue/security/IssuerValidationFilter.java @@ -0,0 +1,111 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2025-Present Datadog, Inc. + */ + +package com.datadoghq.stickerlandia.stickercatalogue.security; + +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.ext.Provider; +import java.io.IOException; +import java.util.Optional; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.jboss.logging.Logger; + +/** + * JAX-RS filter that validates JWT issuer with trailing slash normalization. This is needed because + * OpenIddict adds a trailing slash to the issuer claim, but we don't want double slashes in the + * JWKS URL derivation. + */ +@Provider +@Priority(Priorities.AUTHENTICATION + 1) // Run after JWT authentication +@ApplicationScoped +public class IssuerValidationFilter implements ContainerRequestFilter { + + private static final Logger LOG = Logger.getLogger(IssuerValidationFilter.class); + + @Inject JsonWebToken jwt; + + @ConfigProperty(name = "sticker.jwt.expected-issuer") + Optional expectedIssuer; + + @ConfigProperty(name = "sticker.jwt.issuer-validation.enabled", defaultValue = "true") + boolean issuerValidationEnabled; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + SecurityContext securityContext = requestContext.getSecurityContext(); + + // Skip if no authentication or no expected issuer configured + if (securityContext == null || securityContext.getUserPrincipal() == null) { + return; + } + + if (expectedIssuer.isEmpty()) { + LOG.debug("No expected issuer configured, skipping issuer validation"); + return; + } + + // Skip if issuer validation is explicitly disabled (test profile only) + if (!issuerValidationEnabled) { + LOG.debug("Issuer validation disabled by configuration"); + return; + } + + // If we have authentication but it's not a JWT, fail - unexpected state in production + if (!(securityContext.getUserPrincipal() instanceof JsonWebToken)) { + LOG.error("Principal is authenticated but not a JsonWebToken - unexpected state"); + requestContext.abortWith( + Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity( + "{\"error\": \"internal error: unexpected authentication state\"}") + .build()); + return; + } + + // Get the issuer from the JWT + String tokenIssuer = jwt.getIssuer(); + if (tokenIssuer == null) { + LOG.warn("JWT has no issuer claim"); + requestContext.abortWith( + Response.status(Response.Status.UNAUTHORIZED) + .entity("{\"error\": \"invalid token: missing issuer\"}") + .build()); + return; + } + + // Normalize both issuers (strip trailing slash) for comparison + String normalizedTokenIssuer = normalizeIssuer(tokenIssuer); + String normalizedExpectedIssuer = normalizeIssuer(expectedIssuer.get()); + + if (!normalizedTokenIssuer.equals(normalizedExpectedIssuer)) { + LOG.warnf( + "JWT issuer mismatch: token=%s, expected=%s", + tokenIssuer, expectedIssuer.get()); + requestContext.abortWith( + Response.status(Response.Status.UNAUTHORIZED) + .entity("{\"error\": \"invalid token: issuer mismatch\"}") + .build()); + return; + } + + LOG.debugf("JWT issuer validated: %s", tokenIssuer); + } + + private String normalizeIssuer(String issuer) { + if (issuer == null) { + return ""; + } + // Remove trailing slash for consistent comparison + return issuer.endsWith("/") ? issuer.substring(0, issuer.length() - 1) : issuer; + } +} diff --git a/sticker-catalogue/src/main/resources/application.properties b/sticker-catalogue/src/main/resources/application.properties index 7638e2bf..89272f75 100644 --- a/sticker-catalogue/src/main/resources/application.properties +++ b/sticker-catalogue/src/main/resources/application.properties @@ -32,6 +32,21 @@ MESSAGING_PROVIDER=kafka quarkus.eventbridge.aws.region=${AWS_REGION:us-east-1} quarkus.eventbridge.aws.credentials.type=default +# JWT Configuration +# Note: Issuer validation is handled by IssuerValidationFilter to support trailing slash normalization +# (OpenIddict adds trailing slash to issuer, but we don't want double slashes in JWKS URL) +mp.jwt.verify.publickey.location=${OAUTH_ISSUER:http://user-management:8080}/.well-known/jwks +smallrye.jwt.clock.skew=30 +# Expected issuer for custom validation (normalized to handle trailing slash) +sticker.jwt.expected-issuer=${OAUTH_ISSUER:http://user-management:8080} + +# Dev profile JWT overrides +%dev.mp.jwt.verify.publickey.location=http://localhost:8080/.well-known/jwks +%dev.sticker.jwt.expected-issuer=http://localhost:8080 + +# Test profile - disable issuer validation since @TestSecurity uses synthetic principals +%test.sticker.jwt.issuer-validation.enabled=false + # Kafka channel configuration - DISABLED by default # These settings are required because @Channel annotations cause build-time wiring. # The channels are disabled and won't actually connect unless enabled by a profile. diff --git a/sticker-catalogue/src/test/java/com/datadoghq/stickerlandia/stickercatalogue/StickerMessagingIntegrationTest.java b/sticker-catalogue/src/test/java/com/datadoghq/stickerlandia/stickercatalogue/StickerMessagingIntegrationTest.java index a598ffdc..759d4755 100644 --- a/sticker-catalogue/src/test/java/com/datadoghq/stickerlandia/stickercatalogue/StickerMessagingIntegrationTest.java +++ b/sticker-catalogue/src/test/java/com/datadoghq/stickerlandia/stickercatalogue/StickerMessagingIntegrationTest.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.security.TestSecurity; import io.restassured.http.ContentType; import io.restassured.response.Response; import jakarta.inject.Inject; @@ -103,6 +104,7 @@ void consumeStickerDeletedEvent(String message) { } @Test + @TestSecurity(user = "testuser", roles = "user") void testCreateStickerPublishesKafkaEvent() { CreateStickerRequest request = new CreateStickerRequest(); request.setStickerName("Test Messaging Sticker"); @@ -150,6 +152,7 @@ void testCreateStickerPublishesKafkaEvent() { } @Test + @TestSecurity(user = "testuser", roles = "user") void testUpdateStickerPublishesKafkaEvent() { // First create a sticker createTestSticker(); @@ -193,6 +196,7 @@ void testUpdateStickerPublishesKafkaEvent() { } @Test + @TestSecurity(user = "testuser", roles = "user") void testDeleteStickerPublishesKafkaEvent() { // First create a sticker createTestSticker(); diff --git a/sticker-catalogue/src/test/java/com/datadoghq/stickerlandia/stickercatalogue/StickerResourceTest.java b/sticker-catalogue/src/test/java/com/datadoghq/stickerlandia/stickercatalogue/StickerResourceTest.java index 81ab7c01..053e53f4 100644 --- a/sticker-catalogue/src/test/java/com/datadoghq/stickerlandia/stickercatalogue/StickerResourceTest.java +++ b/sticker-catalogue/src/test/java/com/datadoghq/stickerlandia/stickercatalogue/StickerResourceTest.java @@ -13,6 +13,7 @@ import com.datadoghq.stickerlandia.stickercatalogue.dto.CreateStickerRequest; import com.datadoghq.stickerlandia.stickercatalogue.entity.Sticker; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import io.restassured.http.ContentType; import jakarta.inject.Inject; import jakarta.persistence.EntityManager; @@ -50,6 +51,7 @@ void testGetAllStickers() { } @Test + @TestSecurity(user = "testuser", roles = "user") void testCreateSticker() { CreateStickerRequest request = new CreateStickerRequest(); request.setStickerName("New Test Sticker"); @@ -91,6 +93,7 @@ void testGetNonExistingStickerMetadataReturns404() { } @Test + @TestSecurity(user = "testuser", roles = "user") void testUpdateNonExistingStickerReturns404() { CreateStickerRequest updateRequest = new CreateStickerRequest(); updateRequest.setStickerName("Updated Name"); @@ -108,6 +111,7 @@ void testUpdateNonExistingStickerReturns404() { } @Test + @TestSecurity(user = "testuser", roles = "user") void testDeleteNonExistingStickerReturns404() { given().when() .delete("/api/stickers/v1/{stickerId}", NON_EXISTING_STICKER_ID) @@ -144,6 +148,7 @@ void testGetImageForStickerWithoutImageReturns404() { } @Test + @TestSecurity(user = "testuser", roles = "user") void testUploadImageForNonExistingStickerReturns404() { byte[] fakeImageData = "fake-png-data".getBytes(); diff --git a/user-management/src/Stickerlandia.UserManagement.Auth/AuthServiceExtensions.cs b/user-management/src/Stickerlandia.UserManagement.Auth/AuthServiceExtensions.cs index 44fa0e6e..c0736ff3 100644 --- a/user-management/src/Stickerlandia.UserManagement.Auth/AuthServiceExtensions.cs +++ b/user-management/src/Stickerlandia.UserManagement.Auth/AuthServiceExtensions.cs @@ -51,6 +51,12 @@ public static IServiceCollection AddCoreAuthentication(this IServiceCollection s options.AddDevelopmentEncryptionCertificate() .AddDevelopmentSigningCertificate(); + // Disable access token encryption to allow external services to validate tokens. + // This makes tokens transparent (signed JWTs) instead of encrypted JWEs. + // Required for services like sticker-award and sticker-catalogue that validate + // tokens using the JWKS endpoint. + options.DisableAccessTokenEncryption(); + // Register the ASP.NET Core host and configure the ASP.NET Core options. if (disableSsl) options.UseAspNetCore() diff --git a/web-backend/mise.toml b/web-backend/mise.toml index 4be7da44..acb68200 100644 --- a/web-backend/mise.toml +++ b/web-backend/mise.toml @@ -33,13 +33,13 @@ run = "npx cdk deploy --require-approval never" [tasks."aws:deploy:local"] description = "Deploy to AWS (build container locally)" -depends = ["//:env:setup", "//shared:aws:deploy:local", "//user-management:aws:deploy:local"] +depends = ["//:env:setup", "//shared:aws:deploy:local"] env = { DEPLOY_MODE = "local" } run = "mise run aws:deploy" [tasks."aws:deploy:release"] description = "Deploy to AWS using prebuilt GHCR images" -depends = ["//:env:setup", "//shared:aws:deploy:release", "//user-management:aws:deploy:release"] +depends = ["//:env:setup", "//shared:aws:deploy:release"] env = { DEPLOY_MODE = "release" } run = "mise run aws:deploy" diff --git a/web-frontend/mise.toml b/web-frontend/mise.toml index ae1494d7..383e6561 100644 --- a/web-frontend/mise.toml +++ b/web-frontend/mise.toml @@ -40,13 +40,13 @@ run = "npx cdk deploy --require-approval never" [tasks."aws:deploy:local"] description = "Deploy frontend to AWS (local mode)" -depends = ["//:env:setup", "//shared:aws:deploy:local", "//user-management:aws:deploy:local"] +depends = ["//:env:setup", "//shared:aws:deploy:local"] env = { DEPLOY_MODE = "local" } run = "mise run aws:deploy" [tasks."aws:deploy:release"] description = "Deploy frontend to AWS (release mode)" -depends = ["//:env:setup", "//shared:aws:deploy:release", "//user-management:aws:deploy:release"] +depends = ["//:env:setup", "//shared:aws:deploy:release"] env = { DEPLOY_MODE = "release" } run = "mise run aws:deploy" diff --git a/web-frontend/src/components/Dashboard.jsx b/web-frontend/src/components/Dashboard.jsx index 8d8d985b..1135abb0 100644 --- a/web-frontend/src/components/Dashboard.jsx +++ b/web-frontend/src/components/Dashboard.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; import { API_BASE_URL } from "../config"; +import { authFetch } from "../utils/authFetch"; import HotelClassOutlinedIcon from "@mui/icons-material/HotelClassOutlined"; import TrendingUpOutlinedIcon from "@mui/icons-material/TrendingUpOutlined"; import GroupOutlinedIcon from "@mui/icons-material/GroupOutlined"; @@ -18,7 +19,7 @@ const Dashboard = () => { const fetchStats = async () => { try { setLoading(true); - const response = await fetch(`${API_BASE_URL}/api/stickers/v1/`); + const response = await authFetch(`${API_BASE_URL}/api/stickers/v1/`); if (response.ok) { const data = await response.json(); setTotalStickers(data.stickers?.length || 0); diff --git a/web-frontend/src/components/MyCollection.jsx b/web-frontend/src/components/MyCollection.jsx index 8ab83fe7..aee44378 100644 --- a/web-frontend/src/components/MyCollection.jsx +++ b/web-frontend/src/components/MyCollection.jsx @@ -4,6 +4,7 @@ import { useAuth } from "../context/AuthContext"; import HeaderBar from "./HeaderBar"; import Sidebar from "./Sidebar"; import { API_BASE_URL } from "../config"; +import { authFetch } from "../utils/authFetch"; function MyCollection() { const { user } = useAuth(); @@ -18,7 +19,7 @@ function MyCollection() { const userId = user.sub || user.email; // Fetch user's sticker assignments - const assignmentsResponse = await fetch( + const assignmentsResponse = await authFetch( `${API_BASE_URL}/api/awards/v1/assignments/${userId}` ); @@ -33,7 +34,7 @@ function MyCollection() { const enrichedStickers = await Promise.all( assignments.map(async (assignment) => { try { - const metadataResponse = await fetch( + const metadataResponse = await authFetch( `${API_BASE_URL}/api/stickers/v1/${assignment.stickerId}` ); diff --git a/web-frontend/src/components/StickerDetail.jsx b/web-frontend/src/components/StickerDetail.jsx index 846e5930..a2d8bc3d 100644 --- a/web-frontend/src/components/StickerDetail.jsx +++ b/web-frontend/src/components/StickerDetail.jsx @@ -3,6 +3,7 @@ import { useParams, Link } from "react-router"; import HeaderBar from "./HeaderBar"; import Sidebar from "./Sidebar"; import { API_BASE_URL } from "../config"; +import { authFetch } from "../utils/authFetch"; function StickerDetail() { const { id } = useParams(); @@ -14,7 +15,7 @@ function StickerDetail() { const fetchSticker = async () => { try { setLoading(true); - const response = await fetch( + const response = await authFetch( `${API_BASE_URL}/api/stickers/v1/${id}` ); diff --git a/web-frontend/src/components/StickerList.jsx b/web-frontend/src/components/StickerList.jsx index a366c418..9763a45d 100644 --- a/web-frontend/src/components/StickerList.jsx +++ b/web-frontend/src/components/StickerList.jsx @@ -3,6 +3,7 @@ import { Link } from 'react-router' import { API_BASE_URL } from '../config' import HeaderBar from './HeaderBar' import Sidebar from './Sidebar' +import { authFetch } from '../utils/authFetch' const StickerList = () => { const [stickers, setStickers] = useState([]) @@ -17,10 +18,9 @@ const StickerList = () => { const fetchStickers = async () => { try { setLoading(true) - const response = await fetch( - `${API_BASE_URL}/api/stickers/v1?page=${page}&size=${pageSize}` + const response = await authFetch( + `${API_BASE_URL}/api/stickers/v1/?page=${page}&size=${pageSize}` ) - if (!response.ok) { throw new Error(`Failed to fetch stickers: ${response.status}`) } diff --git a/web-frontend/src/components/UserProfile.jsx b/web-frontend/src/components/UserProfile.jsx index 8fbfaced..c3da441f 100644 --- a/web-frontend/src/components/UserProfile.jsx +++ b/web-frontend/src/components/UserProfile.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react"; import { useAuth } from "../context/AuthContext"; import AuthService from "../services/AuthService"; import { API_BASE_URL } from "../config"; +import { authFetch } from "../utils/authFetch"; import Sidebar from "./Sidebar"; import HotelClassOutlinedIcon from '@mui/icons-material/HotelClassOutlined'; @@ -17,7 +18,7 @@ const UserProfile = () => { setLoading(true); // Use sub (subject) as the unique user identifier from OIDC const userId = user.sub || user.email; - const response = await fetch( + const response = await authFetch( `${API_BASE_URL}/api/awards/v1/assignments/${userId}` ); diff --git a/web-frontend/src/utils/authFetch.js b/web-frontend/src/utils/authFetch.js new file mode 100644 index 00000000..444deecd --- /dev/null +++ b/web-frontend/src/utils/authFetch.js @@ -0,0 +1,26 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2025-Present Datadog, Inc. + */ + +import AuthService from '../services/AuthService'; + +/** + * Wrapper around fetch that automatically adds the Authorization header + * with the JWT token if the user is authenticated. + * + * @param {string} url - The URL to fetch + * @param {RequestInit} options - Fetch options + * @returns {Promise} - The fetch response + */ +export async function authFetch(url, options = {}) { + const tokenData = AuthService.getStoredToken(); + const headers = new Headers(options.headers || {}); + + if (tokenData?.access_token && AuthService.isTokenValid()) { + headers.set('Authorization', `Bearer ${tokenData.access_token}`); + } + + return fetch(url, { ...options, headers }); +}