From 8b7dc6bcd9b4716161c5cfeedbb79c4a2e333b86 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Mon, 15 Jun 2026 12:05:12 +0300 Subject: [PATCH 1/3] feat(backend): add Firebase Auth middleware and protected routes Integrates Firebase Admin SDK to verify ID tokens on protected routes. Unauthenticated requests to /api/v1/* return 401; verified claims are passed downstream via Gin context. - FirebaseToken / FirebaseTokenVerifier / FirebaseAdminClient interfaces in usecase/ - FirebaseAuth Gin middleware (Bearer token extraction + verification) - GET /api/v1/me returns the authenticated user's decoded Firebase claims - Bootstrap initialises Firebase client when FIREBASE_PROJECT_ID is set; nil = auth disabled - Swagger BearerAuth security definition + FirebaseToken schema - FIREBASE_PROJECT_ID and FIREBASE_SERVICE_ACCOUNT_JSON added to .env.example - R2 storage adapter ported and fixed (StorageService interface defined locally) Closes #15 Co-Authored-By: Claude Sonnet 4.6 --- backend/.env.example | 4 +- backend/cmd/api/main.go | 5 + backend/docs/swagger/docs.go | 65 +++++++++ backend/docs/swagger/swagger.json | 65 +++++++++ backend/docs/swagger/swagger.yaml | 43 ++++++ backend/go.mod | 61 +++++++- backend/go.sum | 134 +++++++++++++++++- backend/internal/bootstrap/bootstrap.go | 55 ++++--- .../infrastructure/storage/r2/storage.go | 79 +++++++++++ backend/internal/server/server.go | 2 +- .../transport/handlers/auth_handler.go | 28 ++++ .../transport/handlers/auth_handler_test.go | 54 +++++++ backend/internal/transport/handlers/routes.go | 10 +- .../transport/handlers/swagger_types.go | 9 +- backend/internal/transport/middleware/auth.go | 36 +++++ .../transport/middleware/auth_test.go | 108 ++++++++++++++ backend/internal/usecase/auth_usecase.go | 24 ++++ backend/pkg/firebase/admin.go | 82 +++++++++++ 18 files changed, 836 insertions(+), 28 deletions(-) create mode 100644 backend/internal/infrastructure/storage/r2/storage.go create mode 100644 backend/internal/transport/handlers/auth_handler.go create mode 100644 backend/internal/transport/handlers/auth_handler_test.go create mode 100644 backend/internal/transport/middleware/auth.go create mode 100644 backend/internal/transport/middleware/auth_test.go create mode 100644 backend/internal/usecase/auth_usecase.go create mode 100644 backend/pkg/firebase/admin.go diff --git a/backend/.env.example b/backend/.env.example index be70c82..170f294 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,4 +9,6 @@ BLUEPRINT_DB_SCHEMA=public BLUEPRINT_DB_SSLMODE=disable REDIS_URL= RATE_LIMIT_RPS=100 # requests/sec per IP (omit or 0 = disabled) -RATE_LIMIT_BURST=500 # burst capacity (defaults to RPS×5) \ No newline at end of file +RATE_LIMIT_BURST=500 # burst capacity (defaults to RPS×5) +FIREBASE_PROJECT_ID= # Firebase project ID — e.g. my-app-12345 (omit to disable auth) +FIREBASE_SERVICE_ACCOUNT_JSON= # Service account key as a single-line JSON string. Get from: Firebase Console → Project Settings → Service Accounts → Generate new private key \ No newline at end of file diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 5c6257d..66e9bcd 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -39,6 +39,11 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) { // // @host localhost:8080 // @BasePath / +// +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Firebase ID token — prefix with "Bearer " func main() { // Signal-aware context so SIGINT/SIGTERM cancels bootstrap probes immediately. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) diff --git a/backend/docs/swagger/docs.go b/backend/docs/swagger/docs.go index 4eee4ed..8a74d01 100644 --- a/backend/docs/swagger/docs.go +++ b/backend/docs/swagger/docs.go @@ -37,6 +37,42 @@ const docTemplate = `{ } } }, + "/api/v1/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the authenticated Firebase user's decoded claims. Requires Authorization: Bearer \u003cfirebase-id-token\u003e.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.FirebaseToken" + } + }, + "401": { + "description": "Missing or invalid token", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, "/health": { "get": { "produces": [ @@ -64,6 +100,27 @@ const docTemplate = `{ } }, "definitions": { + "handlers.FirebaseToken": { + "type": "object", + "properties": { + "claims": { + "type": "object", + "additionalProperties": {} + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "photoUrl": { + "type": "string" + }, + "uid": { + "type": "string" + } + } + }, "handlers.HealthStats": { "type": "object", "properties": { @@ -99,6 +156,14 @@ const docTemplate = `{ } } } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Firebase ID token — prefix with \"Bearer \"", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } } }` diff --git a/backend/docs/swagger/swagger.json b/backend/docs/swagger/swagger.json index 82a2727..a9fef60 100644 --- a/backend/docs/swagger/swagger.json +++ b/backend/docs/swagger/swagger.json @@ -31,6 +31,42 @@ } } }, + "/api/v1/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the authenticated Firebase user's decoded claims. Requires Authorization: Bearer \u003cfirebase-id-token\u003e.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.FirebaseToken" + } + }, + "401": { + "description": "Missing or invalid token", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, "/health": { "get": { "produces": [ @@ -58,6 +94,27 @@ } }, "definitions": { + "handlers.FirebaseToken": { + "type": "object", + "properties": { + "claims": { + "type": "object", + "additionalProperties": {} + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "photoUrl": { + "type": "string" + }, + "uid": { + "type": "string" + } + } + }, "handlers.HealthStats": { "type": "object", "properties": { @@ -93,5 +150,13 @@ } } } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Firebase ID token — prefix with \"Bearer \"", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } } } \ No newline at end of file diff --git a/backend/docs/swagger/swagger.yaml b/backend/docs/swagger/swagger.yaml index 05be4c8..1cd566c 100644 --- a/backend/docs/swagger/swagger.yaml +++ b/backend/docs/swagger/swagger.yaml @@ -1,5 +1,19 @@ basePath: / definitions: + handlers.FirebaseToken: + properties: + claims: + additionalProperties: {} + type: object + email: + type: string + name: + type: string + photoUrl: + type: string + uid: + type: string + type: object handlers.HealthStats: properties: error: @@ -44,6 +58,29 @@ paths: summary: Hello World tags: - general + /api/v1/me: + get: + description: 'Returns the authenticated Firebase user''s decoded claims. Requires + Authorization: Bearer .' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.FirebaseToken' + "401": + description: Missing or invalid token + schema: + properties: + error: + type: string + type: object + security: + - BearerAuth: [] + summary: Get current user + tags: + - auth /health: get: produces: @@ -60,4 +97,10 @@ paths: summary: Health check tags: - ops +securityDefinitions: + BearerAuth: + description: Firebase ID token — prefix with "Bearer " + in: header + name: Authorization + type: apiKey swagger: "2.0" diff --git a/backend/go.mod b/backend/go.mod index 35d232a..912a1b1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,8 +1,13 @@ module backend -go 1.25.7 +go 1.25.8 require ( + firebase.google.com/go/v4 v4.20.0 + github.com/aws/aws-sdk-go-v2 v1.42.0 + github.com/aws/aws-sdk-go-v2/config v1.32.25 + github.com/aws/aws-sdk-go-v2/credentials v1.19.24 + github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 github.com/gin-contrib/cors v1.7.7 github.com/gin-gonic/gin v1.12.0 github.com/jackc/pgx/v5 v5.10.0 @@ -15,34 +20,67 @@ require ( github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 golang.org/x/time v0.15.0 + google.golang.org/api v0.284.0 ) require ( + cel.dev/expr v0.25.2 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/firestore v1.22.0 // indirect + cloud.google.com/go/iam v1.11.0 // indirect + cloud.google.com/go/longrunning v1.0.0 // indirect + cloud.google.com/go/monitoring v1.29.0 // indirect + cloud.google.com/go/storage v1.62.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect + github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect + github.com/aws/smithy-go v1.27.1 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.2 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.7 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect @@ -55,7 +93,12 @@ require ( github.com/go-playground/validator/v10 v10.30.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -83,13 +126,15 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect @@ -98,9 +143,13 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -108,10 +157,16 @@ require ( golang.org/x/crypto v0.53.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/tools v0.45.0 // indirect + google.golang.org/appengine/v2 v2.0.6 // indirect + google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index a124987..adc5935 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,17 +1,89 @@ +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E= +cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k= +cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= +cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= +cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8= +cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU= +firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= +github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= +github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 h1:JRseEu/vIDMaWis4bSw0QbXL+cvIGc1XnX076H5ZXLE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -28,6 +100,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -42,8 +116,9 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= @@ -54,6 +129,14 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= @@ -66,6 +149,8 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -95,12 +180,27 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -176,8 +276,11 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= @@ -200,6 +303,8 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -242,10 +347,16 @@ go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/ go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -276,6 +387,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= @@ -301,6 +414,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= @@ -313,6 +427,22 @@ golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.284.0 h1:i+cKTgeQRcRySkP7QTl5PDO7/pAm8EcMFIUMlNbk4Vc= +google.golang.org/api v0.284.0/go.mod h1:AU44fU+XVZOCcd8uLaBIa/ZgzgPf/0qqY3+m7lQaado= +google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= +google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= +google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554= +google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM= +google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= +google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 963fa97..b4d05b9 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -18,6 +18,7 @@ import ( "backend/internal/infrastructure/cache/redis" "backend/internal/infrastructure/database/postgres" "backend/internal/usecase" + "backend/pkg/firebase" "backend/pkg/logger" ) @@ -32,20 +33,23 @@ const ( // App holds all initialised, validated shared dependencies. // Constructed once by Run and passed to the HTTP server. type App struct { - DB *sql.DB - Cache usecase.CacheService // nil when REDIS_URL is not set - Config Config - Log *slog.Logger + DB *sql.DB + Cache usecase.CacheService // nil when REDIS_URL is not set + Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set + Config Config + Log *slog.Logger } // Config holds all validated configuration values read from environment variables. type Config struct { - Port int - Env string - DB postgres.DBConfig - RedisURL string - RateLimitRPS float64 - RateLimitBurst int + Port int + Env string + DB postgres.DBConfig + RedisURL string + RateLimitRPS float64 + RateLimitBurst int + FirebaseProjectID string + FirebaseServiceAccountJSON string } // ConfigError is returned when required configuration is absent or invalid. @@ -101,13 +105,24 @@ func Run(ctx context.Context) (*App, error) { cache = c } + var firebaseClient usecase.FirebaseAdminClient + if cfg.FirebaseProjectID != "" { + client, err := firebase.NewAuthClient(ctx, cfg.FirebaseProjectID, cfg.FirebaseServiceAccountJSON) + if err != nil { + return nil, fmt.Errorf("bootstrap: firebase: %w", err) + } + firebaseClient = client + log.Info("bootstrap: firebase auth client initialised", "project_id", cfg.FirebaseProjectID) + } + log.Info("bootstrap: all checks passed — ready to serve") return &App{ - DB: db, - Cache: cache, - Config: cfg, - Log: log, + DB: db, + Cache: cache, + Firebase: firebaseClient, + Config: cfg, + Log: log, }, nil } @@ -134,11 +149,13 @@ func loadConfig() Config { } return Config{ - Port: port, - Env: os.Getenv("ENV"), - RedisURL: os.Getenv("REDIS_URL"), - RateLimitRPS: rps, - RateLimitBurst: burst, + Port: port, + Env: os.Getenv("ENV"), + RedisURL: os.Getenv("REDIS_URL"), + RateLimitRPS: rps, + RateLimitBurst: burst, + FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"), + FirebaseServiceAccountJSON: os.Getenv("FIREBASE_SERVICE_ACCOUNT_JSON"), DB: postgres.DBConfig{ Host: os.Getenv("BLUEPRINT_DB_HOST"), Port: os.Getenv("BLUEPRINT_DB_PORT"), diff --git a/backend/internal/infrastructure/storage/r2/storage.go b/backend/internal/infrastructure/storage/r2/storage.go new file mode 100644 index 0000000..a70b8c9 --- /dev/null +++ b/backend/internal/infrastructure/storage/r2/storage.go @@ -0,0 +1,79 @@ +package r2 + +import ( + "context" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// StorageService is the interface satisfied by this Cloudflare R2 adapter. +type StorageService interface { + PresignUpload(ctx context.Context, key string, contentType string, ttl time.Duration) (string, error) + Delete(ctx context.Context, key string) error + PublicURL(key string) string +} + +type storageService struct { + client *s3.Client + presigner *s3.PresignClient + bucket string + publicURL string +} + +// New returns a StorageService backed by Cloudflare R2. +// R2 is S3-compatible; the custom endpoint encodes the account ID. +func New(accountID, accessKey, secretKey, bucket, publicBaseURL string) (StorageService, error) { + endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID) + + cfg, err := config.LoadDefaultConfig(context.Background(), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), + config.WithRegion("auto"), + ) + if err != nil { + return nil, fmt.Errorf("r2: load config: %w", err) + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = true + }) + + return &storageService{ + client: client, + presigner: s3.NewPresignClient(client), + bucket: bucket, + publicURL: publicBaseURL, + }, nil +} + +func (s *storageService) PresignUpload(ctx context.Context, key string, contentType string, ttl time.Duration) (string, error) { + req, err := s.presigner.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + ContentType: aws.String(contentType), + }, s3.WithPresignExpires(ttl)) + if err != nil { + return "", fmt.Errorf("r2: presign put: %w", err) + } + return req.URL, nil +} + +func (s *storageService) Delete(ctx context.Context, key string) error { + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return fmt.Errorf("r2: delete object: %w", err) + } + return nil +} + +func (s *storageService) PublicURL(key string) string { + return fmt.Sprintf("%s/%s", s.publicURL, key) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 5f9ccc5..e151622 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -32,7 +32,7 @@ func NewServer(app *bootstrap.App) *http.Server { return &http.Server{ Addr: fmt.Sprintf(":%d", app.Config.Port), - Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst), + Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Firebase), IdleTimeout: time.Minute, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, diff --git a/backend/internal/transport/handlers/auth_handler.go b/backend/internal/transport/handlers/auth_handler.go new file mode 100644 index 0000000..df6a9a1 --- /dev/null +++ b/backend/internal/transport/handlers/auth_handler.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "backend/internal/transport/middleware" + "backend/internal/usecase" +) + +// @Summary Get current user +// @Description Returns the authenticated Firebase user's decoded claims. Requires Authorization: Bearer . +// @Tags auth +// @Produce json +// @Security BearerAuth +// @Success 200 {object} FirebaseToken +// @Failure 401 {object} object{error=string} "Missing or invalid token" +// @Router /api/v1/me [get] +func (h *Handler) MeHandler(c *gin.Context) { + val, _ := c.Get(middleware.FirebaseClaimsKey) + token, ok := val.(*usecase.FirebaseToken) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + c.JSON(http.StatusOK, token) +} diff --git a/backend/internal/transport/handlers/auth_handler_test.go b/backend/internal/transport/handlers/auth_handler_test.go new file mode 100644 index 0000000..9e07a55 --- /dev/null +++ b/backend/internal/transport/handlers/auth_handler_test.go @@ -0,0 +1,54 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "backend/internal/transport/middleware" + "backend/internal/usecase" +) + +func TestMeHandler_WithClaims(t *testing.T) { + gin.SetMode(gin.TestMode) + h := &Handler{} + want := &usecase.FirebaseToken{UID: "abc123", Email: "user@example.com", Name: "Test User"} + + r := gin.New() + r.GET("/me", func(c *gin.Context) { + c.Set(middleware.FirebaseClaimsKey, want) + h.MeHandler(c) + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/me", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var got usecase.FirebaseToken + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + if got.UID != want.UID || got.Email != want.Email || got.Name != want.Name { + t.Errorf("response mismatch: got %+v, want %+v", got, *want) + } +} + +func TestMeHandler_WithoutClaims(t *testing.T) { + gin.SetMode(gin.TestMode) + h := &Handler{} + r := gin.New() + r.GET("/me", h.MeHandler) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/me", nil)) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} diff --git a/backend/internal/transport/handlers/routes.go b/backend/internal/transport/handlers/routes.go index f302c24..d6e1acb 100644 --- a/backend/internal/transport/handlers/routes.go +++ b/backend/internal/transport/handlers/routes.go @@ -10,11 +10,13 @@ import ( _ "backend/docs/swagger" "backend/internal/transport/middleware" + "backend/internal/usecase" ) // RegisterRoutes creates the Gin engine, applies middleware, and registers all routes. // rps and burst configure IP-based rate limiting; pass rps<=0 to disable. -func (h *Handler) RegisterRoutes(rps float64, burst int) http.Handler { +// verifier enables Firebase token auth on protected routes; pass nil to skip auth (dev only). +func (h *Handler) RegisterRoutes(rps float64, burst int, verifier usecase.FirebaseTokenVerifier) http.Handler { r := gin.New() // Use Gin's colorful logger locally; structured slog logger in staging/production. @@ -38,5 +40,11 @@ func (h *Handler) RegisterRoutes(rps float64, burst int) http.Handler { r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + api := r.Group("/api/v1") + if verifier != nil { + api.Use(middleware.FirebaseAuth(verifier)) + } + api.GET("/me", h.MeHandler) + return r } diff --git a/backend/internal/transport/handlers/swagger_types.go b/backend/internal/transport/handlers/swagger_types.go index dfc2bdd..13acf11 100644 --- a/backend/internal/transport/handlers/swagger_types.go +++ b/backend/internal/transport/handlers/swagger_types.go @@ -1,7 +1,14 @@ package handlers -import "backend/internal/domain" +import ( + "backend/internal/domain" + "backend/internal/usecase" +) // HealthStats is a type alias so swaggo can resolve domain.HealthStats // from within this package for annotation purposes. type HealthStats = domain.HealthStats + +// FirebaseToken is a type alias so swaggo can resolve usecase.FirebaseToken +// from within this package for annotation purposes. +type FirebaseToken = usecase.FirebaseToken diff --git a/backend/internal/transport/middleware/auth.go b/backend/internal/transport/middleware/auth.go new file mode 100644 index 0000000..1c9db76 --- /dev/null +++ b/backend/internal/transport/middleware/auth.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "backend/internal/usecase" +) + +// FirebaseClaimsKey is the Gin context key under which the verified FirebaseToken is stored. +const FirebaseClaimsKey = "firebase_claims" + +// FirebaseAuth returns a Gin middleware that validates a Firebase ID token from the +// Authorization: Bearer header. Sets FirebaseClaimsKey in the Gin context on success. +// Returns 401 when the header is missing, malformed, or the token fails verification. +func FirebaseAuth(verifier usecase.FirebaseTokenVerifier) gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid Authorization header"}) + return + } + idToken := strings.TrimPrefix(header, "Bearer ") + + claims, err := verifier.VerifyIDToken(c.Request.Context(), idToken) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + return + } + + c.Set(FirebaseClaimsKey, claims) + c.Next() + } +} diff --git a/backend/internal/transport/middleware/auth_test.go b/backend/internal/transport/middleware/auth_test.go new file mode 100644 index 0000000..8155816 --- /dev/null +++ b/backend/internal/transport/middleware/auth_test.go @@ -0,0 +1,108 @@ +package middleware_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "backend/internal/transport/middleware" + "backend/internal/usecase" +) + +type mockVerifier struct { + token *usecase.FirebaseToken + err error +} + +func (m *mockVerifier) VerifyIDToken(_ context.Context, _ string) (*usecase.FirebaseToken, error) { + return m.token, m.err +} + +func TestFirebaseAuth_MissingHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(middleware.FirebaseAuth(&mockVerifier{})) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestFirebaseAuth_NonBearerHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(middleware.FirebaseAuth(&mockVerifier{})) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestFirebaseAuth_InvalidToken(t *testing.T) { + gin.SetMode(gin.TestMode) + mv := &mockVerifier{err: errors.New("token expired")} + r := gin.New() + r.Use(middleware.FirebaseAuth(mv)) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer badtoken") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestFirebaseAuth_ValidToken(t *testing.T) { + gin.SetMode(gin.TestMode) + want := &usecase.FirebaseToken{UID: "uid123", Email: "test@example.com"} + mv := &mockVerifier{token: want} + + var capturedClaims *usecase.FirebaseToken + r := gin.New() + r.Use(middleware.FirebaseAuth(mv)) + r.GET("/", func(c *gin.Context) { + val, exists := c.Get(middleware.FirebaseClaimsKey) + if !exists { + t.Error("firebase_claims not set in context") + c.Status(http.StatusInternalServerError) + return + } + tok, ok := val.(*usecase.FirebaseToken) + if !ok { + t.Errorf("claims wrong type: %T", val) + c.Status(http.StatusInternalServerError) + return + } + capturedClaims = tok + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer validtoken") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if capturedClaims == nil || capturedClaims.UID != want.UID || capturedClaims.Email != want.Email { + t.Errorf("claims mismatch: got %+v", capturedClaims) + } +} diff --git a/backend/internal/usecase/auth_usecase.go b/backend/internal/usecase/auth_usecase.go new file mode 100644 index 0000000..d42603e --- /dev/null +++ b/backend/internal/usecase/auth_usecase.go @@ -0,0 +1,24 @@ +package usecase + +import "context" + +// FirebaseToken holds the verified claims extracted from a Firebase ID token. +type FirebaseToken struct { + UID string `json:"uid"` + Email string `json:"email"` + Name string `json:"name"` + PhotoURL string `json:"photoUrl"` + Claims map[string]any `json:"claims"` +} + +// FirebaseTokenVerifier can verify a raw Firebase ID token string. +type FirebaseTokenVerifier interface { + VerifyIDToken(ctx context.Context, idToken string) (*FirebaseToken, error) +} + +// FirebaseAdminClient extends FirebaseTokenVerifier with Firebase user-management operations. +type FirebaseAdminClient interface { + FirebaseTokenVerifier + GetUserByEmail(ctx context.Context, email string) (string, error) + UpdateUserPassword(ctx context.Context, uid, newPassword string) error +} diff --git a/backend/pkg/firebase/admin.go b/backend/pkg/firebase/admin.go new file mode 100644 index 0000000..27d7d32 --- /dev/null +++ b/backend/pkg/firebase/admin.go @@ -0,0 +1,82 @@ +package firebase + +import ( + "context" + "fmt" + + firebasesdk "firebase.google.com/go/v4" + "firebase.google.com/go/v4/auth" + "google.golang.org/api/option" + + "backend/internal/usecase" +) + +// authClientAdapter wraps the Firebase Admin auth.Client and satisfies the +// usecase.FirebaseAdminClient interface without leaking the SDK into the application layer. +type authClientAdapter struct { + client *auth.Client +} + +// NewAuthClient initialises the Firebase Admin SDK and returns a usecase.FirebaseAdminClient +// ready for injection. +// +// credentialsJSON is the raw content of a service account JSON key +// (FIREBASE_SERVICE_ACCOUNT_JSON env var). When empty the SDK falls back to +// Application Default Credentials (ADC) — appropriate for GCP-hosted deployments. +func NewAuthClient(ctx context.Context, projectID, credentialsJSON string) (usecase.FirebaseAdminClient, error) { + var opts []option.ClientOption + if credentialsJSON != "" { + opts = append(opts, option.WithCredentialsJSON([]byte(credentialsJSON))) + } + + cfg := &firebasesdk.Config{ProjectID: projectID} + app, err := firebasesdk.NewApp(ctx, cfg, opts...) + if err != nil { + return nil, fmt.Errorf("firebase: init app: %w", err) + } + + client, err := app.Auth(ctx) + if err != nil { + return nil, fmt.Errorf("firebase: init auth client: %w", err) + } + + return &authClientAdapter{client: client}, nil +} + +// VerifyIDToken implements usecase.FirebaseTokenVerifier. +func (a *authClientAdapter) VerifyIDToken(ctx context.Context, idToken string) (*usecase.FirebaseToken, error) { + tok, err := a.client.VerifyIDToken(ctx, idToken) + if err != nil { + return nil, fmt.Errorf("firebase: verify id token: %w", err) + } + + email, _ := tok.Claims["email"].(string) + name, _ := tok.Claims["name"].(string) + photoURL, _ := tok.Claims["picture"].(string) + + return &usecase.FirebaseToken{ + UID: tok.UID, + Email: email, + Name: name, + PhotoURL: photoURL, + Claims: tok.Claims, + }, nil +} + +// GetUserByEmail implements usecase.FirebaseAdminClient. +func (a *authClientAdapter) GetUserByEmail(ctx context.Context, email string) (string, error) { + user, err := a.client.GetUserByEmail(ctx, email) + if err != nil { + return "", fmt.Errorf("firebase: get user by email: %w", err) + } + return user.UID, nil +} + +// UpdateUserPassword implements usecase.FirebaseAdminClient. +func (a *authClientAdapter) UpdateUserPassword(ctx context.Context, uid, newPassword string) error { + params := (&auth.UserToUpdate{}).Password(newPassword) + if _, err := a.client.UpdateUser(ctx, uid, params); err != nil { + return fmt.Errorf("firebase: update user password: %w", err) + } + return nil +} From ad6ada8199df6c879a939acb901c56c15d1edf44 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Mon, 15 Jun 2026 12:50:24 +0300 Subject: [PATCH 2/3] fix(backend): address Firebase auth review findings - .env.example: remove spaces around = for dotenv-linter compatibility - FirebaseAuth middleware: reject nil claims from VerifyIDToken (nil, nil) - r2.New: validate all required args and return early on empty values - r2.PublicURL: url.PathEscape object key before composing URL --- backend/.env.example | 4 ++-- backend/internal/infrastructure/storage/r2/storage.go | 8 +++++++- backend/internal/transport/middleware/auth.go | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 170f294..28610c8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,5 +10,5 @@ BLUEPRINT_DB_SSLMODE=disable REDIS_URL= RATE_LIMIT_RPS=100 # requests/sec per IP (omit or 0 = disabled) RATE_LIMIT_BURST=500 # burst capacity (defaults to RPS×5) -FIREBASE_PROJECT_ID= # Firebase project ID — e.g. my-app-12345 (omit to disable auth) -FIREBASE_SERVICE_ACCOUNT_JSON= # Service account key as a single-line JSON string. Get from: Firebase Console → Project Settings → Service Accounts → Generate new private key \ No newline at end of file +FIREBASE_PROJECT_ID=# Firebase project ID — e.g. my-app-12345 (omit to disable auth) +FIREBASE_SERVICE_ACCOUNT_JSON=# Service account key as a single-line JSON string. Get from: Firebase Console → Project Settings → Service Accounts → Generate new private key \ No newline at end of file diff --git a/backend/internal/infrastructure/storage/r2/storage.go b/backend/internal/infrastructure/storage/r2/storage.go index a70b8c9..bb0371b 100644 --- a/backend/internal/infrastructure/storage/r2/storage.go +++ b/backend/internal/infrastructure/storage/r2/storage.go @@ -2,7 +2,9 @@ package r2 import ( "context" + "errors" "fmt" + "net/url" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -28,6 +30,10 @@ type storageService struct { // New returns a StorageService backed by Cloudflare R2. // R2 is S3-compatible; the custom endpoint encodes the account ID. func New(accountID, accessKey, secretKey, bucket, publicBaseURL string) (StorageService, error) { + if accountID == "" || accessKey == "" || secretKey == "" || bucket == "" || publicBaseURL == "" { + return nil, errors.New("r2: accountID, accessKey, secretKey, bucket, and publicBaseURL are all required") + } + endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID) cfg, err := config.LoadDefaultConfig(context.Background(), @@ -75,5 +81,5 @@ func (s *storageService) Delete(ctx context.Context, key string) error { } func (s *storageService) PublicURL(key string) string { - return fmt.Sprintf("%s/%s", s.publicURL, key) + return s.publicURL + "/" + url.PathEscape(key) } diff --git a/backend/internal/transport/middleware/auth.go b/backend/internal/transport/middleware/auth.go index 1c9db76..09b443a 100644 --- a/backend/internal/transport/middleware/auth.go +++ b/backend/internal/transport/middleware/auth.go @@ -25,7 +25,7 @@ func FirebaseAuth(verifier usecase.FirebaseTokenVerifier) gin.HandlerFunc { idToken := strings.TrimPrefix(header, "Bearer ") claims, err := verifier.VerifyIDToken(c.Request.Context(), idToken) - if err != nil { + if err != nil || claims == nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) return } From 8a313b7cf70d22bded248d7aab1a6a8d94aa62d8 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Mon, 15 Jun 2026 12:52:15 +0300 Subject: [PATCH 3/3] chore(docs): annotate md --- .../infrastructure/ipgeo/ipapi_client.go | 93 +++++++++++++++ .../infrastructure/ipgeo/ipapi_client_test.go | 108 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 backend/internal/infrastructure/ipgeo/ipapi_client.go create mode 100644 backend/internal/infrastructure/ipgeo/ipapi_client_test.go diff --git a/backend/internal/infrastructure/ipgeo/ipapi_client.go b/backend/internal/infrastructure/ipgeo/ipapi_client.go new file mode 100644 index 0000000..a3e84d5 --- /dev/null +++ b/backend/internal/infrastructure/ipgeo/ipapi_client.go @@ -0,0 +1,93 @@ +package ipgeo + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "time" +) + +// ErrPrivateIP is returned when the caller passes a loopback or RFC-1918 +// address. Callers should silently skip the location update in this case. +var ErrPrivateIP = errors.New("ipgeo: private or loopback IP address") + +// IPAPIClient implements domain/services.IPGeolocationService by querying +// https://ipapi.co/{ip}/json/. +type IPAPIClient struct { + httpClient *http.Client + // baseURL is overridable in tests; production code leaves it empty and the + // default "https://ipapi.co" is used. + baseURL string +} + +// NewIPAPIClient returns an IPAPIClient with a 5-second HTTP client timeout. +func NewIPAPIClient() *IPAPIClient { + return &IPAPIClient{httpClient: &http.Client{Timeout: 5 * time.Second}} +} + +// ipapiResponse matches the JSON shape returned by ipapi.co. +type ipapiResponse struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Error bool `json:"error"` + Reason string `json:"reason"` +} + +// Locate resolves ip to approximate lat/lon coordinates. +// Returns ErrPrivateIP for loopback and RFC-1918 addresses without making +// an outbound HTTP call. +func (c *IPAPIClient) Locate(ctx context.Context, ip string) (lat, lon float64, err error) { + if isPrivateIP(ip) { + return 0, 0, ErrPrivateIP + } + + base := c.baseURL + if base == "" { + base = "https://ipapi.co" + } + url := fmt.Sprintf("%s/%s/json/", base, ip) + return c.fetch(ctx, url) +} + +// fetch performs the HTTP GET and parses the response. +func (c *IPAPIClient) fetch(ctx context.Context, url string) (lat, lon float64, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return 0, 0, fmt.Errorf("ipgeo: build request: %w", err) + } + req.Header.Set("User-Agent", "gigz-backend/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, 0, fmt.Errorf("ipgeo: http request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, 0, fmt.Errorf("ipgeo: unexpected status %d", resp.StatusCode) + } + + var body ipapiResponse + if decErr := json.NewDecoder(resp.Body).Decode(&body); decErr != nil { + return 0, 0, fmt.Errorf("ipgeo: decode response: %w", decErr) + } + + if body.Error { + return 0, 0, fmt.Errorf("ipgeo: api error: %s", body.Reason) + } + + return body.Latitude, body.Longitude, nil +} + +// isPrivateIP reports whether the given IP string is a loopback or +// RFC-1918 / RFC-4193 private address. +func isPrivateIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() +} diff --git a/backend/internal/infrastructure/ipgeo/ipapi_client_test.go b/backend/internal/infrastructure/ipgeo/ipapi_client_test.go new file mode 100644 index 0000000..b348998 --- /dev/null +++ b/backend/internal/infrastructure/ipgeo/ipapi_client_test.go @@ -0,0 +1,108 @@ +package ipgeo + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +// newTestIPAPIClient builds an IPAPIClient that points at the given test +// server base URL instead of the live ipapi.co endpoint. +func newTestIPAPIClient(baseURL string) *IPAPIClient { + return &IPAPIClient{ + httpClient: &http.Client{}, + baseURL: baseURL, + } +} + +func TestIPAPIClient_Locate_ValidIP(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(ipapiResponse{ + Latitude: 0.3476, + Longitude: 32.5825, + Error: false, + }); err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer srv.Close() + + client := newTestIPAPIClient(srv.URL) + + lat, lon, err := client.Locate(context.Background(), "8.8.8.8") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if lat != 0.3476 { + t.Errorf("lat: got %v, want 0.3476", lat) + } + if lon != 32.5825 { + t.Errorf("lon: got %v, want 32.5825", lon) + } +} + +func TestIPAPIClient_Locate_PrivateIP_NoHTTPCall(t *testing.T) { + // This server should never be called; if it is, the test fails immediately. + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // Use a real NewIPAPIClient (not the test one) — private-IP check fires + // before any HTTP call regardless of baseURL. + client := NewIPAPIClient() + + privateIPs := []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "::1"} + for _, ip := range privateIPs { + t.Run(ip, func(t *testing.T) { + _, _, err := client.Locate(context.Background(), ip) + if !errors.Is(err, ErrPrivateIP) { + t.Errorf("ip %s: got error %v, want ErrPrivateIP", ip, err) + } + }) + } + + if called { + t.Error("HTTP server was called for a private IP — should have short-circuited") + } +} + +func TestIPAPIClient_Locate_APIErrorInBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(ipapiResponse{ + Error: true, + Reason: "invalid IP address", + }); err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer srv.Close() + + client := newTestIPAPIClient(srv.URL) + + _, _, err := client.Locate(context.Background(), "1.2.3.4") + if err == nil { + t.Fatal("expected error when api body has error:true, got nil") + } +} + +func TestIPAPIClient_Locate_Non200Status(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + client := newTestIPAPIClient(srv.URL) + + _, _, err := client.Locate(context.Background(), "1.2.3.4") + if err == nil { + t.Fatal("expected error for non-200 status, got nil") + } +}