diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4cc55df..3185b4e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,20 +8,23 @@ on: branches: - master +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest strategy: matrix: - go-version: [1.21.x] + go-version: [ 1.25.x ] steps: - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v6 - - name: Test - run: go test ./... + - name: Test + run: go test ./... diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c9361c5..a4a1141 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,10 +18,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Log in to the Container registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -29,14 +35,15 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: context: . + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 6ed9d42..1d402a7 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -3,7 +3,7 @@ name: golangci-lint on: push: branches: - - master + - '**' pull_request: permissions: @@ -15,14 +15,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - name: Get go version from go.mod + run: | + echo "GO_VERSION=$(grep '^go ' go.mod | cut -d " " -f 2)" >> $GITHUB_ENV + + - name: Setup-go + uses: actions/setup-go@v6 with: - go-version: '1.22' - cache: false - - - name: golangci-lint - uses: golangci/golangci-lint-action@v4 + go-version: ${{ env.GO_VERSION }} + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 with: - version: v1.56 + version: v2.7.2 diff --git a/.golangci.yml b/.golangci.yml index c115364..aed84ab 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,34 +1,62 @@ +version: "2" +run: + timeout: 2m linters: - disable-all: true + default: none enable: - dupl - errcheck - errorlint - - exportloopref - funlen - - gci - goconst - gocritic - gocyclo - - gofmt - - goimports - - gosimple + - gosec - govet - ineffassign - lll - misspell + - nolintlint - prealloc - revive - staticcheck - - stylecheck - - typecheck + - thelper + - tparallel - unconvert - unparam - unused - -issues: - exclude-rules: - - path: _test\.go - linters: - - unparam - - funlen + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - funlen + - unparam + path: _test\.go + - linters: + - revive + path: internal/http/ + text: "var-naming.*package names" + - linters: + - revive + path: internal/util/hash/ + text: "var-naming.*package names" + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gci + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/Dockerfile b/Dockerfile index bf8e023..00f8ed0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,21 @@ -# syntax=docker/dockerfile:1.2 -FROM golang:alpine3.19 AS build +# syntax=docker/dockerfile:1.4 +FROM golang:1.25-alpine AS build RUN apk --no-cache add gcc g++ make git WORKDIR /go/src/app +COPY go.mod go.sum ./ +RUN go mod download COPY . . -RUN go get ./... -WORKDIR /go/src/app/cmd/auth -RUN GOOS=linux go build -ldflags="-s -w" -o ./bin/auth +RUN GOOS=linux go build -ldflags="-s -w" -o ./bin/auth ./cmd/auth -FROM alpine:3.19.1 +FROM alpine:3.23 +RUN addgroup -g 1000 appgroup && adduser -u 1000 -G appgroup -D appuser WORKDIR /app -COPY --from=build /go/src/app/cmd/auth/bin /app +COPY --from=build /go/src/app/bin/auth /app COPY --from=build /go/src/app/config /app/ COPY ./secrets ./secrets +RUN chown -R appuser:appgroup /app ENV AUTH_SERVER_LOCAL_CONFIG_PATH=local_repository_config.yml +USER appuser EXPOSE 8081 ENTRYPOINT ["/app/auth", "-c", "service_config.yml"] diff --git a/README.md b/README.md index 6660bd2..ca94411 100644 --- a/README.md +++ b/README.md @@ -36,19 +36,19 @@ The `auth-server` project aims to address these concerns by serving as a transpa 2. The proxy server routes this request to `auth-server` to issue a token. Response body: - `{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODg5MzMyNTIsImlhdCI6MTU4ODkyOTY1MiwidXNlciI6ImFkbWluIiwicm9sZSI6MX0.LUx9EYsfBZGwbEsofBTT_5Lo3Y_3lk7T8pWLv3bw-XKVOqb_GhaRkVE90QR_sI-bWTkYCFIG9cPYmMXzmPLyjbofgsqTOzH6OaXi3IqxwZRtRGFtuqMoqXkakX5n38mvI3XkIOwFkNosHrpMtIq-HdqB3tfiDJc3YMsYfPbqyRBnBYJu2K51NslGQSiqKSnS_4KeLeaqqdpC7Zdb9Fo-r7EMn3FFuyPEab1iBsrcUYG3qnsKkvDhaq_jEGHflao7dEPEWaiGvJywXWaKR6XyyGtVx0H-OPfgvh1vUCLUUci2K3xE-IxjfRrHx3dSzdqFgJq_n4bVXpO9iNVYOZLccQ","token_type":"Bearer","expires_in":3600000}` + `{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...","token_type":"Bearer","expires_in":3600000}` 3. The user sends an authenticated request to the proxy server: ``` GET /foo HTTP/1.1 Host: localhost:8081 - Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODg5MzMyNTIsImlhdCI6MTU4ODkyOTY1MiwidXNlciI6ImFkbWluIiwicm9sZSI6MX0.LUx9EYsfBZGwbEsofBTT_5Lo3Y_3lk7T8pWLv3bw-XKVOqb_GhaRkVE90QR_sI-bWTkYCFIG9cPYmMXzmPLyjbofgsqTOzH6OaXi3IqxwZRtRGFtuqMoqXkakX5n38mvI3XkIOwFkNosHrpMtIq-HdqB3tfiDJc3YMsYfPbqyRBnBYJu2K51NslGQSiqKSnS_4KeLeaqqdpC7Zdb9Fo-r7EMn3FFuyPEab1iBsrcUYG3qnsKkvDhaq_jEGHflao7dEPEWaiGvJywXWaKR6XyyGtVx0H-OPfgvh1vUCLUUci2K3xE-IxjfRrHx3dSzdqFgJq_n4bVXpO9iNVYOZLccQ + Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... ``` 4. Proxy invokes `auth-server` as an authentication/authorization middleware. In case the token was successfully authenticated/authorized, the request will be routed to the target service. Otherwise, an auth error code will be returned to the client. ## Installation and Prerequisites -* `auth-server` is written in Golang. +* `auth-server` is written in Go (1.24+). To install the latest stable version of Go, visit the [releases page](https://golang.org/dl/). * Read the following [instructions](./secrets/README.md) to generate keys required to sign the token. Specify the location of the generated certificates in the service configuration file. An example of the configuration file can be found [here](config/service_config.yml). @@ -60,15 +60,50 @@ To install the latest stable version of Go, visit the [releases page](https://go * To run the project using Docker, visit their [page](https://www.docker.com/get-started) to get started. Docker images are available under the [GitHub Packages](https://github.com/reugn/auth-server/packages). -* Install `docker-compose` to get started with the examples. +* Install Docker to get started with the examples. + +## Configuration + +### Proxy Providers + +The `proxy` setting in your configuration determines how `auth-server` parses incoming requests to extract the original method and URI. This is important when running behind a reverse proxy that may modify or forward request details via headers. + +| Provider | Description | Headers Used | +|----------|-------------|--------------| +| `direct` | No proxy, use actual request values | None | +| `nginx` | Nginx with `auth_request` module | `X-Forwarded-Method`, `X-Forwarded-Uri` | +| `traefik` | Traefik with ForwardAuth middleware | `X-Forwarded-Method`, `X-Forwarded-Uri`, `X-Forwarded-Prefix` | +| `envoy` | Envoy with ext_authz filter | `X-Original-*`, `X-Envoy-Original-*`, `X-Forwarded-*` (priority order) | +| `haproxy` | HAProxy with external auth | `X-Forwarded-Method`, `X-Forwarded-Uri`, `X-Original-URI` | +| `kong` | Kong API Gateway | `X-Forwarded-Method`, `X-Forwarded-Path`, `X-Forwarded-Prefix` | + +Example configuration: +```yaml +proxy: direct # or nginx, traefik, envoy, haproxy, kong +``` ## Examples Examples are available under the [examples](examples) folder. -To run `auth-server` as a [Traefik](https://docs.traefik.io/) middleware: -``` +### Traefik +Run `auth-server` as a [Traefik](https://docs.traefik.io/) ForwardAuth middleware: +```sh cd examples/traefik -docker-compose up -d +docker compose up -d +``` + +### Nginx +Run `auth-server` with [Nginx](https://nginx.org/) using the `auth_request` module: +```sh +cd examples/nginx +docker compose up -d +``` + +### Envoy +Run `auth-server` with [Envoy](https://www.envoyproxy.io/) using the `ext_authz` filter: +```sh +cd examples/envoy +docker compose up -d ``` ## License diff --git a/cmd/auth/main.go b/cmd/auth/main.go index 7177748..efe3f3f 100644 --- a/cmd/auth/main.go +++ b/cmd/auth/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "log/slog" "os" @@ -12,7 +13,7 @@ import ( ) const ( - version = "0.4.0" + version = "0.5.0" ) func run() int { @@ -50,21 +51,32 @@ func run() int { return server.Start() } - err := rootCmd.Execute() - if err != nil { + if err := rootCmd.Execute(); err != nil { return 1 } return 0 } +// readConfiguration reads the configuration file and returns the configuration. func readConfiguration(path string) (*config.Service, error) { + // read the configuration file data, err := os.ReadFile(path) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read configuration file %s: %w", path, err) } + + // unmarshal the configuration data config := config.NewServiceDefault() - err = yaml.Unmarshal(data, config) - return config, err + if err := yaml.Unmarshal(data, config); err != nil { + return nil, fmt.Errorf("failed to unmarshal configuration data: %w", err) + } + + // validate the configuration + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + return config, nil } func main() { diff --git a/config/service_config.yml b/config/service_config.yml index 6853daf..074ea0e 100644 --- a/config/service_config.yml +++ b/config/service_config.yml @@ -1,17 +1,29 @@ --- +# Supported signing methods: RS256, RS384, RS512 signing-method: RS256 -proxy: traefik + +# Supported proxy providers: direct, nginx, traefik, envoy, haproxy, kong +# Use 'direct' when accessing auth-server without a reverse proxy. +proxy: direct + +# Supported repository providers: local, aerospike, vault repository: local + +# HTTP server configuration http: - host: 0.0.0.0 - port: 8081 - rate: - tps: 1024 - size: 1024 - white-list: [] + host: 0.0.0.0 + port: 8081 + rate: + tps: 1024 + size: 1024 + white-list: [ ] + +# Secret configuration secret: - private-path: secrets/privkey.pem - public-path: secrets/cert.pem + private-path: secrets/privkey.pem + public-path: secrets/cert.pem + +# Logger configuration logger: - level: INFO - format: PLAIN + level: INFO + format: PLAIN diff --git a/examples/envoy/README.md b/examples/envoy/README.md new file mode 100644 index 0000000..5572978 --- /dev/null +++ b/examples/envoy/README.md @@ -0,0 +1,167 @@ +# Envoy Integration Example + +This example demonstrates how to integrate `auth-server` with [Envoy](https://www.envoyproxy.io/) using the [External Authorization](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) (`ext_authz`) filter. + +## Prerequisites + +1. Generate RSA keys (see `../../secrets/README.md`) +2. The example includes `service_config.yml` pre-configured with `proxy: envoy` +3. Ensure `../../config/local_repository_config.yml` exists + +## Architecture + +Envoy uses the `ext_authz` HTTP filter to make an external authorization call to the auth-server's `/auth` endpoint: +- If the auth server returns `200 OK`, the request is forwarded to the backend +- If the auth server returns `401 Unauthorized`, Envoy returns an error to the client +- Envoy sets `X-Envoy-*` and `X-Forwarded-*` headers that the auth-server parses + +## Usage + +1. Start services: +```sh +docker compose up -d +``` + +2. Get a token: +```sh +curl -u admin:1234 http://localhost:8082/token +``` + +3. Use token to access protected routes: +```sh +curl -H "Authorization: Bearer " http://localhost:8082/health +``` + +4. Access without token (will fail): +```sh +curl http://localhost:8082/health +``` + +5. Check Envoy admin interface: +```sh +curl http://localhost:9901/stats +``` + +## Configuration + +* Envoy proxy: http://localhost:8082 +* Envoy admin: http://localhost:9901 +* Auth server: http://localhost:8081 + +## Envoy Configuration Details + +The `envoy.yaml` file includes: + +- **External Authorization Filter** (`ext_authz`): Calls auth-server for all requests except bypassed routes +- **Token endpoint** (`/token`): Directly routes to auth-server, bypasses auth +- **Auth endpoint** (`/auth`): Directly routes to auth-server, bypasses auth +- **Health endpoints** (`/health`, `/ready`, `/version`): Bypass auth +- **Protected routes** (`/`): Require authentication via `ext_authz` filter +- **Header forwarding**: Uses Lua filter to set `X-Original-Method` and `X-Original-Uri` headers for auth-server + +## Customization + +### Protect Specific Routes +To protect only specific paths, modify the route configuration: +```yaml +routes: + # Public route - no auth + - match: + path: "/public" + route: + cluster: backend + # Protected route - requires auth + - match: + prefix: "/api" + route: + cluster: backend + # ext_authz filter will handle auth for this route +``` + +### Adjust Timeouts +Modify timeout values in the configuration: +```yaml +http_service: + server_uri: + timeout: 10s # Increase auth timeout +``` + +### Add More Headers +To forward additional headers to auth-server: +```yaml +authorization_request: + allowed_headers: + patterns: + - exact: authorization + - exact: x-custom-header + - prefix: x-forwarded- +``` + +## Troubleshooting + +### Check Envoy logs +```sh +docker logs envoy +``` + +### Check auth-server logs +```sh +docker logs auth-server +``` + +### Test auth endpoint directly +```sh +curl -H "Authorization: Bearer " http://localhost:8081/auth +``` + +### Check Envoy stats +```sh +curl http://localhost:9901/stats | grep ext_authz +``` + +### View Envoy configuration +```sh +curl http://localhost:9901/config_dump +``` + +### Verify Envoy configuration +```sh +docker exec envoy envoy --mode validate -c /etc/envoy/envoy.yaml +``` + +## Differences from Traefik/Nginx + +- **Configuration**: Envoy uses YAML configuration files vs Traefik's dynamic config or Nginx's nginx.conf +- **Filter-based**: Uses HTTP filters (`ext_authz`) vs Traefik's middleware or Nginx's `auth_request` +- **Admin interface**: Envoy provides a built-in admin interface on port 9901 +- **Header handling**: Envoy sets both `X-Forwarded-*` and `X-Envoy-*` headers +- **More granular control**: Can configure per-route auth policies + +## Advanced Configuration + +### Per-Route Auth Policies + +You can configure different auth policies for different routes: +```yaml +routes: + - match: + prefix: "/admin" + route: + cluster: backend + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + # Custom auth config for this route +``` + +### Response Headers + +To forward auth response headers to backend: +```yaml +allowed_upstream_headers: + patterns: + - exact: x-auth-user + - exact: x-auth-role +``` + +This allows the auth-server to set headers that Envoy will forward to the backend service. diff --git a/examples/envoy/docker-compose.yml b/examples/envoy/docker-compose.yml new file mode 100644 index 0000000..ff47d4a --- /dev/null +++ b/examples/envoy/docker-compose.yml @@ -0,0 +1,43 @@ +networks: + proxy: + +services: + envoy: + restart: always + image: envoyproxy/envoy:v1.30-latest + container_name: envoy + ports: + - "8082:8082" + - "9901:9901" # Admin interface + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml:ro + depends_on: + - auth-server + networks: + - proxy + command: /usr/local/bin/envoy -c /etc/envoy/envoy.yaml --service-cluster front-proxy + + auth-server: + restart: always + container_name: auth-server + image: auth-server:latest + build: + context: ../.. + dockerfile: Dockerfile + ports: + - "8081:8081" + volumes: + # Mount config files (use example-specific service config) + - ./service_config.yml:/app/service_config.yml:ro + - ../../config/local_repository_config.yml:/app/local_repository_config.yml:ro + - ../../secrets:/app/secrets:ro + environment: + - AUTH_SERVER_LOCAL_CONFIG_PATH=/app/local_repository_config.yml + networks: + - proxy + healthcheck: + test: [ "CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/examples/envoy/envoy.yaml b/examples/envoy/envoy.yaml new file mode 100644 index 0000000..b5752ae --- /dev/null +++ b/examples/envoy/envoy.yaml @@ -0,0 +1,97 @@ +admin: + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 9901 + +static_resources: + listeners: + - name: listener_0 + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 8082 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + access_log: + - name: envoy.access_loggers.stdout + typed_config: + "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog + http_filters: + # Lua filter to add custom headers with original request info + - name: envoy.filters.http.lua + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + default_source_code: + inline_string: | + function envoy_on_request(request_handle) + request_handle:headers():add("x-original-method", request_handle:headers():get(":method")) + request_handle:headers():add("x-original-uri", request_handle:headers():get(":path")) + end + # External authorization filter - calls auth-server + - name: envoy.filters.http.ext_authz + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + transport_api_version: V3 + failure_mode_allow: false + http_service: + server_uri: + uri: http://auth-server:8081 + cluster: auth_server + timeout: 5s + # IMPORTANT: path_prefix rewrites the request path to /auth + # Without this, Envoy sends the original path (e.g., /health) + path_prefix: "/auth" + authorization_request: + allowed_headers: + patterns: + - exact: authorization + - exact: x-original-method + - exact: x-original-uri + # Router filter + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: [ "*" ] + routes: + # Token endpoint - bypass auth + - match: + path: "/token" + route: + cluster: auth_server + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + # All other routes - require auth + - match: + prefix: "/" + route: + cluster: auth_server + timeout: 30s + + clusters: + - name: auth_server + connect_timeout: 5s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: auth_server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: auth-server + port_value: 8081 diff --git a/examples/envoy/service_config.yml b/examples/envoy/service_config.yml new file mode 100644 index 0000000..6754fba --- /dev/null +++ b/examples/envoy/service_config.yml @@ -0,0 +1,28 @@ +--- +# Envoy-specific service configuration +signing-method: RS256 + +# Envoy proxy provider +proxy: envoy + +# Repository provider +repository: local + +# HTTP server configuration +http: + host: 0.0.0.0 + port: 8081 + rate: + tps: 1024 + size: 1024 + white-list: [ ] + +# Secret configuration (paths are relative to /app in container) +secret: + private-path: secrets/privkey.pem + public-path: secrets/cert.pem + +# Logger configuration +logger: + level: DEBUG + format: PLAIN diff --git a/examples/nginx/README.md b/examples/nginx/README.md new file mode 100644 index 0000000..b36e183 --- /dev/null +++ b/examples/nginx/README.md @@ -0,0 +1,95 @@ +# Nginx Integration Example + +This example demonstrates how to integrate `auth-server` with [Nginx](https://nginx.org/) using the [auth_request](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module. + +## Prerequisites + +1. Generate RSA keys (see `../../secrets/README.md`) +2. The example includes `service_config.yml` pre-configured with `proxy: nginx` +3. Ensure `../../config/local_repository_config.yml` exists + +## Architecture + +Nginx uses the `auth_request` module to make a subrequest to the auth-server's `/auth` endpoint: +- If the auth server returns `200 OK`, the request is forwarded to the backend +- If the auth server returns `401 Unauthorized`, Nginx returns an error to the client +- Nginx sets `X-Forwarded-*` headers that the auth-server parses + +## Usage + +1. Start services: +```sh +docker compose up -d +``` + +2. Get a token: +```sh +curl -u admin:1234 http://localhost:8082/token +``` + +3. Use token to access protected routes: +```sh +curl -H "Authorization: Bearer " http://localhost:8082/health +``` + +4. Access without token (will fail): +```sh +curl http://localhost:8082/health +``` + +## Configuration + +* Nginx proxy: http://localhost:8082 +* Auth server: http://localhost:8081 +* Backend service: Internal (example nginx container) + +## Nginx Configuration Details + +The `nginx.conf` file includes: + +- **Token endpoint** (`/token`): Directly proxies to auth-server, no authentication required +- **Auth endpoint** (`/auth`): Used internally by `auth_request`, no authentication required +- **Protected routes** (`/`): Uses `auth_request` to validate tokens before proxying to backend +- **Headers**: Nginx sets `X-Forwarded-*` headers for the auth-server to parse + +## Customization + +### Protect Specific Path +```nginx +location /api { + auth_request /auth; + proxy_pass http://backend; + # ... other proxy settings +} +``` + +### Protect Multiple Paths +```nginx +location ~ ^/(api|admin|dashboard) { + auth_request /auth; + proxy_pass http://backend; + # ... other proxy settings +} +``` + +## Troubleshooting + +### Check Nginx logs +```sh +docker logs nginx +``` + +### Check auth-server logs +```sh +docker logs auth-server +``` + +### Test auth endpoint directly +```sh +curl -H "Authorization: Bearer " http://localhost:8081/auth +``` + +### Verify Nginx configuration +```sh +docker exec nginx nginx -t +``` diff --git a/examples/nginx/docker-compose.yml b/examples/nginx/docker-compose.yml new file mode 100644 index 0000000..b8c9595 --- /dev/null +++ b/examples/nginx/docker-compose.yml @@ -0,0 +1,41 @@ +networks: + proxy: + +services: + nginx: + restart: always + image: nginx:alpine + container_name: nginx + ports: + - "8082:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - auth-server + networks: + - proxy + + auth-server: + restart: always + container_name: auth-server + image: auth-server:latest + build: + context: ../.. + dockerfile: Dockerfile + ports: + - "8081:8081" + volumes: + # Mount config files (use example-specific service config) + - ./service_config.yml:/app/service_config.yml:ro + - ../../config/local_repository_config.yml:/app/local_repository_config.yml:ro + - ../../secrets:/app/secrets:ro + environment: + - AUTH_SERVER_LOCAL_CONFIG_PATH=/app/local_repository_config.yml + networks: + - proxy + healthcheck: + test: [ "CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/examples/nginx/nginx.conf b/examples/nginx/nginx.conf new file mode 100644 index 0000000..084c7c2 --- /dev/null +++ b/examples/nginx/nginx.conf @@ -0,0 +1,76 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Upstream for auth-server + upstream auth-server { + server auth-server:8081; + } + + server { + listen 80; + server_name _; + + # Token endpoint - no authentication required + location /token { + proxy_pass http://auth-server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Health check endpoint - protected (demonstrates auth) + location /health { + auth_request /_auth; + error_page 401 = @error401; + + proxy_pass http://auth-server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Internal auth subrequest location + location = /_auth { + internal; + proxy_pass http://auth-server/auth; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Method $request_method; + proxy_set_header X-Forwarded-Uri $request_uri; + proxy_set_header Authorization $http_authorization; + } + + # Error handler for 401 + location @error401 { + default_type application/json; + return 401 '{"error":"Unauthorized","message":"Authentication required"}'; + } + } +} diff --git a/examples/nginx/service_config.yml b/examples/nginx/service_config.yml new file mode 100644 index 0000000..9d6a636 --- /dev/null +++ b/examples/nginx/service_config.yml @@ -0,0 +1,28 @@ +--- +# Nginx-specific service configuration +signing-method: RS256 + +# Nginx proxy provider +proxy: nginx + +# Repository provider +repository: local + +# HTTP server configuration +http: + host: 0.0.0.0 + port: 8081 + rate: + tps: 1024 + size: 1024 + white-list: [ ] + +# Secret configuration (paths are relative to /app in container) +secret: + private-path: secrets/privkey.pem + public-path: secrets/cert.pem + +# Logger configuration +logger: + level: DEBUG + format: PLAIN diff --git a/examples/traefik/README.md b/examples/traefik/README.md new file mode 100644 index 0000000..eb3e659 --- /dev/null +++ b/examples/traefik/README.md @@ -0,0 +1,86 @@ +# Traefik Integration Example + +This example demonstrates how to integrate `auth-server` with [Traefik](https://traefik.io/) using the [ForwardAuth](https://doc.traefik.io/traefik/middlewares/http/forwardauth/) middleware. + +## Prerequisites + +1. Generate RSA keys (see `../../secrets/README.md`) +2. The example includes `service_config.yml` pre-configured with `proxy: traefik` +3. Ensure `../../config/local_repository_config.yml` exists + +## Architecture + +Traefik uses the ForwardAuth middleware to make a subrequest to the auth-server's `/auth` endpoint: +- If the auth server returns `200 OK`, the request is forwarded to the backend +- If the auth server returns `401 Unauthorized`, Traefik returns an error to the client +- Traefik sets `X-Forwarded-*` headers that the auth-server parses + +## Usage + +1. Start services: +```sh +docker compose up -d +``` + +2. Get a token: +```sh +curl -u admin:1234 http://localhost:8082/token +``` + +3. Use token to access protected routes: +```sh +curl -H "Authorization: Bearer " http://localhost:8082/health +``` + +4. Access without token (will fail): +```sh +curl http://localhost:8082/health +``` + +## Configuration + +* Traefik dashboard: http://localhost:8080 +* Auth server: http://localhost:8081 +* Public endpoint: http://localhost:8082 + +## Traefik Configuration Details + +The ForwardAuth middleware: +- Sends requests to `http://auth-server:8081/auth` +- Forwards `X-Forwarded-Method`, `X-Forwarded-Uri`, and `X-Forwarded-Prefix` headers +- Can optionally forward auth response headers to the backend + +## Customization + +### Protect Specific Routes +Add the `auth-server` middleware only to routes that need protection in your dynamic config. + +### Forward User Headers +To forward authenticated user info to the backend: +```yaml +forwardAuth: + address: "http://auth-server:8081/auth" + authResponseHeaders: + - "X-Auth-User" + - "X-Auth-Role" +``` + +## Troubleshooting + +### Check Traefik logs +```sh +docker logs traefik +``` + +### Check auth-server logs +```sh +docker logs auth-server +``` + +### Test auth endpoint directly +```sh +curl -H "Authorization: Bearer " http://localhost:8081/auth +``` + +### Access Traefik dashboard +Open http://localhost:8080 in your browser. diff --git a/examples/traefik/docker-compose.yml b/examples/traefik/docker-compose.yml index 5782835..a6e4a8c 100644 --- a/examples/traefik/docker-compose.yml +++ b/examples/traefik/docker-compose.yml @@ -1,35 +1,47 @@ -version: '3.4' - networks: proxy: services: reverse-proxy: restart: always - image: traefik:v2.11 + image: traefik:v3.0 container_name: traefik ports: - - 443:443 - - 8082:8082 + - "443:443" + - "8082:8082" + - "8080:8080" # Dashboard port volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./traefik.yml:/etc/traefik/traefik.yml - - ./dynamic-config.yml:/etc/traefik/dynamic-config.yml - labels: - - "traefik.http.routers.site.entryPoints=http,websecure" - - "traefik.enable=true" - - "traefik.port=8082" + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./traefik.yml:/etc/traefik/traefik.yml:ro + - ./dynamic-config.yml:/etc/traefik/dynamic-config.yml:ro networks: - proxy + labels: + # Remove incorrect labels - Traefik doesn't need labels on itself + - "traefik.enable=true" auth-server: restart: always container_name: auth-server - ports: - - 8081:8081 - image: auth-server + image: auth-server:latest build: + context: ../.. dockerfile: Dockerfile - context: ../../. + ports: + - "8081:8081" + volumes: + # Mount config files (use example-specific service config) + - ./service_config.yml:/app/service_config.yml:ro + - ../../config/local_repository_config.yml:/app/local_repository_config.yml:ro + # Mount secrets + - ../../secrets:/app/secrets:ro + environment: + - AUTH_SERVER_LOCAL_CONFIG_PATH=/app/local_repository_config.yml networks: - proxy + healthcheck: + test: [ "CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/examples/traefik/dynamic-config.yml b/examples/traefik/dynamic-config.yml index 6731ca7..9fa99c9 100644 --- a/examples/traefik/dynamic-config.yml +++ b/examples/traefik/dynamic-config.yml @@ -10,24 +10,37 @@ http: test-auth: forwardAuth: address: http://auth-server:8081/auth - authResponseHeaders: - - "X-Auth-User" - - "X-Secret" + # Remove authResponseHeaders - auth-server doesn't set these headers + # authResponseHeaders are only useful if your auth service sets them trustForwardHeader: true + # Optional: Add authRequestHeaders to forward specific headers + # authRequestHeaders: + # - "X-Forwarded-Method" + # - "X-Forwarded-Uri" routers: + # Router for token endpoint (no auth required) token-router: - rule: "Path(`/token`)" + rule: "PathPrefix(`/token`)" service: auth-server entrypoints: - http - priority: 2 + priority: 10 + # Router for auth endpoint (no auth required - it is the auth endpoint) auth-router: - rule: "HostRegexp(`{host:.*}`)" + rule: "PathPrefix(`/auth`)" + service: auth-server + entrypoints: + - http + priority: 10 + + # Protected route - forwards to auth-server health endpoint as demo + health-router: + rule: "PathPrefix(`/health`)" middlewares: - test-auth service: auth-server entrypoints: - http - priority: 1 + priority: 5 diff --git a/examples/traefik/service_config.yml b/examples/traefik/service_config.yml new file mode 100644 index 0000000..1ba8c27 --- /dev/null +++ b/examples/traefik/service_config.yml @@ -0,0 +1,28 @@ +--- +# Traefik-specific service configuration +signing-method: RS256 + +# Traefik proxy provider +proxy: traefik + +# Repository provider +repository: local + +# HTTP server configuration +http: + host: 0.0.0.0 + port: 8081 + rate: + tps: 1024 + size: 1024 + white-list: [ ] + +# Secret configuration (paths are relative to /app in container) +secret: + private-path: secrets/privkey.pem + public-path: secrets/cert.pem + +# Logger configuration +logger: + level: DEBUG + format: PLAIN diff --git a/examples/traefik/traefik.yml b/examples/traefik/traefik.yml index a82186d..bc13e5c 100644 --- a/examples/traefik/traefik.yml +++ b/examples/traefik/traefik.yml @@ -1,7 +1,7 @@ --- global: checkNewVersion: true - sendAnonymousUsage: true + sendAnonymousUsage: false # Consider privacy entryPoints: websecure: @@ -11,7 +11,12 @@ entryPoints: api: dashboard: true - insecure: true + insecure: true # Consider adding authentication in production + # Optional: Add authentication + # dashboard: true + # insecure: false + # users: + # admin: "$2y$10$..." # bcrypt hash providers: file: @@ -19,3 +24,13 @@ providers: watch: true docker: network: proxy + exposedByDefault: false # Only expose containers with traefik.enable=true + +# Optional: Add logging +# log: +# level: INFO +# format: json + +# Optional: Add metrics +# metrics: +# prometheus: {} diff --git a/go.mod b/go.mod index 892e8e7..f46c5fc 100644 --- a/go.mod +++ b/go.mod @@ -1,50 +1,42 @@ module github.com/reugn/auth-server -go 1.21 +go 1.24.0 require ( - github.com/aerospike/aerospike-client-go/v7 v7.1.0 - github.com/golang-jwt/jwt/v5 v5.2.0 - github.com/hashicorp/vault/api v1.11.0 - github.com/spf13/cobra v1.8.0 - golang.org/x/crypto v0.18.0 - golang.org/x/time v0.5.0 + github.com/aerospike/aerospike-client-go/v8 v8.5.1 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/hashicorp/vault/api v1.22.0 + github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.46.0 + golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/cenkalti/backoff/v3 v3.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fatih/color v1.14.1 // indirect - github.com/go-jose/go-jose/v3 v3.0.1 // indirect - github.com/go-test/deep v1.0.7 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/go-jose/go-jose/v4 v4.1.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-hclog v1.5.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-retryablehttp v0.6.7 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect - github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect - github.com/hashicorp/go-sockaddr v1.0.2 // indirect - github.com/hashicorp/hcl v1.0.1-vault-3 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad // indirect github.com/yuin/gopher-lua v1.1.1 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/text v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect - google.golang.org/grpc v1.59.0 // indirect - google.golang.org/protobuf v1.31.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index 4640057..e7936ba 100644 --- a/go.sum +++ b/go.sum @@ -1,156 +1,108 @@ -github.com/aerospike/aerospike-client-go/v7 v7.1.0 h1:yvCTKdbpqZxHvv7sWsFHV1j49jZcC8yXRooWsDFqKtA= -github.com/aerospike/aerospike-client-go/v7 v7.1.0/go.mod h1:AkHiKvCbqa1c16gCNGju3c5X/yzwLVvblNczqjxNwNk= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c= -github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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/aerospike/aerospike-client-go/v8 v8.5.1 h1:wItG15M1ZpWxU5JxJ+HYXYM9m29kwk8jkG5b/eLwDoI= +github.com/aerospike/aerospike-client-go/v8 v8.5.1/go.mod h1:F3qwGJUMWOtqZha7O2VglfIDatH3Rj8wYhmI7bkHOfU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= -github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= -github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= -github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= -github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= -github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= -github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M= -github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= -github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= -github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +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/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= -github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.6.7 h1:8/CAEZt/+F7kR7GevNHulKkUjLht3CPmn7egmhieNKo= -github.com/hashicorp/go-retryablehttp v0.6.7/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= -github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= -github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= -github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= -github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= -github.com/hashicorp/hcl v1.0.1-vault-3 h1:V95v5KSTu6DB5huDSKiq4uAfILEuNigK/+qPET6H/Mg= -github.com/hashicorp/hcl v1.0.1-vault-3/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= -github.com/hashicorp/vault/api v1.11.0 h1:AChWByeHf4/P9sX3Y1B7vFsQhZO2BgQiCMQ2SA1P1UY= -github.com/hashicorp/vault/api v1.11.0/go.mod h1:si+lJCYO7oGkIoNPAN8j3azBLTn9SjMGS+jFaHd1Cck= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -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.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= -github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= -github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= -github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= 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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad h1:W0LEBv82YCGEtcmPA3uNZBI33/qF//HAAs3MawDjRa0= +github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad/go.mod h1:Hy8o65+MXnS6EwGElrSRjUzQDLXreJlzYLlWiHtt8hM= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 h1:DC7wcm+i+P1rN3Ff07vL+OndGg5OhNddHyTA+ocPqYE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 07b92de..8c1cf18 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -2,6 +2,7 @@ package auth import ( "encoding/json" + "fmt" "log/slog" "github.com/golang-jwt/jwt/v5" @@ -46,7 +47,8 @@ func (t *AccessToken) Marshal() (string, error) { jsonByteArray, err := json.Marshal(t) if err != nil { slog.Debug("Failed to marshal token", "err", err) - return "", err + return "", fmt.Errorf("failed to marshal token: %w", err) } + return string(jsonByteArray), nil } diff --git a/internal/auth/jwt_generator.go b/internal/auth/jwt_generator.go index e958dc2..4ec24cd 100644 --- a/internal/auth/jwt_generator.go +++ b/internal/auth/jwt_generator.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "time" "github.com/golang-jwt/jwt/v5" @@ -9,6 +10,7 @@ import ( ) const ( + //nolint:gosec envTokenExpireAfterMillis = "AUTH_SERVER_ACCESS_TOKEN_EXPIRATION_MILLIS" ) @@ -49,7 +51,7 @@ func (gen *JWTGenerator) Generate(username string, role repository.UserRole) (*A token.Claims = &claims signed, err := token.SignedString(gen.keys.privateKey) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to sign token: %w", err) } // create an access token diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go index 70f4cca..3766aeb 100644 --- a/internal/auth/jwt_test.go +++ b/internal/auth/jwt_test.go @@ -74,7 +74,9 @@ func TestJWT_Authorize(t *testing.T) { false, }, } - for _, tt := range tests { + + for _, test := range tests { + tt := test t.Run(tt.name, func(t *testing.T) { userDetails := repo.AuthenticateBasic(tt.username, tt.password) if userDetails == nil { @@ -84,10 +86,12 @@ func TestJWT_Authorize(t *testing.T) { return } } + token, err := tokenGenerator.Generate(tt.username, userDetails.UserRole) if err != nil { t.Fatal(err) } + authorized := tokenValidator.Authorize(token.Token, &tt.request) if authorized != tt.authorized { t.Fatal("authorization result mismatch") diff --git a/internal/auth/jwt_validator.go b/internal/auth/jwt_validator.go index 7b0fc22..e18bb21 100644 --- a/internal/auth/jwt_validator.go +++ b/internal/auth/jwt_validator.go @@ -2,6 +2,7 @@ package auth import ( "encoding/json" + "fmt" "log/slog" "time" @@ -9,7 +10,8 @@ import ( "github.com/reugn/auth-server/internal/repository" ) -// JWTValidator validates and authorizes an AccessToken. +// JWTValidator validates and authorizes an AccessToken. It uses the keys to validate +// the token and the backend to authorize the request. type JWTValidator struct { keys *Keys backend repository.Repository @@ -23,18 +25,24 @@ func NewJWTValidator(keys *Keys, backend repository.Repository) *JWTValidator { } } -// validate validates the AccessToken. -func (v *JWTValidator) validate(jtwToken string) (*Claims, error) { - token, err := jwt.Parse(jtwToken, func(_ *jwt.Token) (interface{}, error) { +// validate validates the AccessToken. It returns the claims if the token is valid, +// an error otherwise. +func (v *JWTValidator) validate(jwtToken string) (*Claims, error) { + token, err := jwt.Parse(jwtToken, func(token *jwt.Token) (any, error) { + // validate signing method + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %s", token.Method.Alg()) + } return v.keys.publicKey, nil }) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse token: %w", err) } return v.validateClaims(token) } +// validateClaims validates the claims of the token. func (v *JWTValidator) validateClaims(token *jwt.Token) (*Claims, error) { claims, err := getClaims(token) if err != nil { @@ -42,7 +50,7 @@ func (v *JWTValidator) validateClaims(token *jwt.Token) (*Claims, error) { } // validate expiration - if claims.ExpiresAt.Before(time.Now()) { + if claims.ExpiresAt != nil && claims.ExpiresAt.Before(time.Now()) { slog.Debug("Token expired") return nil, jwt.ErrTokenExpired } @@ -50,23 +58,25 @@ func (v *JWTValidator) validateClaims(token *jwt.Token) (*Claims, error) { return claims, nil } +// getClaims gets the claims from the token. func getClaims(token *jwt.Token) (*Claims, error) { mapClaims := token.Claims.(jwt.MapClaims) jsonClaims, err := json.Marshal(mapClaims) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to marshal claims: %w", err) } claims := Claims{} err = json.Unmarshal(jsonClaims, &claims) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to unmarshal claims: %w", err) } return &claims, nil } -// Authorize validates the token and authorizes the actual request. +// Authorize validates the token and authorizes the actual request. It returns true +// if the request is authorized, false otherwise. func (v *JWTValidator) Authorize(token string, request *repository.RequestDetails) bool { claims, err := v.validate(token) if err != nil { diff --git a/internal/auth/keys.go b/internal/auth/keys.go index 0da5cbe..91aa2e6 100644 --- a/internal/auth/keys.go +++ b/internal/auth/keys.go @@ -3,6 +3,7 @@ package auth import ( "crypto/rsa" "errors" + "fmt" "os" "github.com/golang-jwt/jwt/v5" @@ -51,11 +52,12 @@ func NewKeysFromPem(privatePem []byte, publicPem []byte) (*Keys, error) { return &Keys{priv, pub}, nil } +// parsePrivateKey parses the private key from the file or the PEM bytes. func parsePrivateKey(privateKeyPath *string, pem []byte) (*rsa.PrivateKey, error) { if privateKeyPath != nil { pem, err := os.ReadFile(*privateKeyPath) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read private key file %s: %w", *privateKeyPath, err) } return jwt.ParseRSAPrivateKeyFromPEM(pem) } else if pem != nil { @@ -64,11 +66,12 @@ func parsePrivateKey(privateKeyPath *string, pem []byte) (*rsa.PrivateKey, error return nil, errors.New("parsePrivateKey nil parameters") } +// parsePublicKey parses the public key from the file or the PEM bytes. func parsePublicKey(publicKeyPath *string, pem []byte) (*rsa.PublicKey, error) { if publicKeyPath != nil { pem, err := os.ReadFile(*publicKeyPath) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read public key file %s: %w", *publicKeyPath, err) } return jwt.ParseRSAPublicKeyFromPEM(pem) } else if pem != nil { diff --git a/internal/config/http_server.go b/internal/config/http_server.go index 18445d5..7df4c08 100644 --- a/internal/config/http_server.go +++ b/internal/config/http_server.go @@ -25,6 +25,7 @@ type RateLimiter struct { WhiteList []string `yaml:"white-list,omitempty" json:"white-list,omitempty"` } +// validate validates the RateLimiter configuration. func (c *RateLimiter) validate() error { if c == nil { return errors.New("rate limiter config is nil") diff --git a/internal/config/logger.go b/internal/config/logger.go index 2b6cfc9..320cbfb 100644 --- a/internal/config/logger.go +++ b/internal/config/logger.go @@ -37,19 +37,34 @@ func NewLoggerDefault() *Logger { } var ( - validLoggerLevels = []string{logLevelDebug, logLevelInfo, logLevelWarn, - logLevelWarning, logLevelError} - supportedLoggerFormats = []string{logFormatPlain, logFormatJSON} + // validLoggerLevels are the valid log levels. + validLoggerLevels = []string{ + logLevelDebug, + logLevelInfo, + logLevelWarn, + logLevelWarning, + logLevelError, + } + + // supportedLoggerFormats are the supported log formats. + supportedLoggerFormats = []string{ + logFormatPlain, + logFormatJSON, + } ) +// SlogHandler returns a slog.Handler for the logger configuration. func (l *Logger) SlogHandler() (slog.Handler, error) { if err := l.validate(); err != nil { return nil, err } + + // get the log level logLevel, err := l.logLevel() if err != nil { return nil, err } + addSource := true writer := os.Stdout switch strings.ToUpper(l.Format) { @@ -82,7 +97,7 @@ func (l *Logger) validate() error { return nil } -// logLevel returns the log level. +// logLevel returns the slog.Level for the logger configuration. func (l *Logger) logLevel() (slog.Level, error) { switch strings.ToUpper(l.Level) { case logLevelDebug: diff --git a/internal/config/service.go b/internal/config/service.go index 6f76c4a..15fb830 100644 --- a/internal/config/service.go +++ b/internal/config/service.go @@ -17,8 +17,15 @@ const ( signingMethodRS256 = "RS256" signingMethodRS384 = "RS384" signingMethodRS512 = "RS512" + + proxyProviderDirect = "direct" + + repositoryProviderLocal = "local" + repositoryProviderAerospike = "aerospike" + repositoryProviderVault = "vault" ) +// validSigningMethods are the valid signing methods. var validSigningMethods = []string{signingMethodRS256, signingMethodRS384, signingMethodRS512} // Service contains the entire service configuration. @@ -35,14 +42,15 @@ type Service struct { func NewServiceDefault() *Service { return &Service{ SigningMethod: signingMethodRS256, - ProxyProvider: "simple", - RepositoryProvider: "local", + ProxyProvider: proxyProviderDirect, + RepositoryProvider: repositoryProviderLocal, HTTP: NewHTTPDefault(), Secret: NewSecretDefault(), Logger: NewLoggerDefault(), } } +// SigningMethodRSA returns the jwt.SigningMethodRSA for the signing method. func (c *Service) SigningMethodRSA() (*jwt.SigningMethodRSA, error) { var signingMethodRSA *jwt.SigningMethodRSA switch strings.ToUpper(c.SigningMethod) { @@ -58,33 +66,28 @@ func (c *Service) SigningMethodRSA() (*jwt.SigningMethodRSA, error) { return signingMethodRSA, nil } -func (c *Service) RequestParser() (proxy.RequestParser, error) { - var parser proxy.RequestParser - switch strings.ToLower(c.ProxyProvider) { - case "simple": - parser = proxy.NewSimpleParser() - case "traefik": - parser = proxy.NewTraefikParser() - default: - return nil, fmt.Errorf("unsupported proxy provider: %s", c.ProxyProvider) - } - return parser, nil +// RequestParser returns the proxy.RequestParser for the proxy provider. +// Supported providers: direct, nginx, traefik, envoy, haproxy, kong. +// Unknown providers fall back to direct mode with a warning. +func (c *Service) RequestParser() proxy.RequestParser { + return proxy.NewParser(c.ProxyProvider) } +// Repository returns the repository.Repository for the repository provider. func (c *Service) Repository() (repository.Repository, error) { switch strings.ToLower(c.RepositoryProvider) { - case "local": + case repositoryProviderLocal: return repository.NewLocal() - case "aerospike": + case repositoryProviderAerospike: return repository.NewAerospike() - case "vault": + case repositoryProviderVault: return repository.NewVault() default: return nil, fmt.Errorf("unsupported storage provider: %s", c.RepositoryProvider) } } -// Validate validates the service configuration. +// Validate validates the service configuration properties. func (c *Service) Validate() error { if c == nil { return errors.New("service config is nil") @@ -116,6 +119,7 @@ func (c *Service) String() string { if err != nil { return err.Error() } + return string(data) } @@ -125,5 +129,6 @@ func (c *Service) StringYaml() string { if err != nil { return err.Error() } + return string(data) } diff --git a/internal/http/rate_limiter.go b/internal/http/rate_limiter.go index f9af737..1b82e4c 100644 --- a/internal/http/rate_limiter.go +++ b/internal/http/rate_limiter.go @@ -1,6 +1,7 @@ package http import ( + "fmt" "log/slog" "net/netip" "strings" @@ -33,13 +34,15 @@ func NewIPWhiteList(ipList []string) (*IPWhiteList, error) { if err != nil { ipAddr, err := netip.ParseAddr(ip) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse IP address %s: %w", ip, err) } addresses[ip] = &ipAddr } else { networks = append(networks, &network) } } + + // return the IP white list return &IPWhiteList{ addresses: addresses, networks: networks, @@ -47,6 +50,7 @@ func NewIPWhiteList(ipList []string) (*IPWhiteList, error) { }, nil } +// isAllowed checks if the IP is in the white list. func (wl *IPWhiteList) isAllowed(ip string) bool { if wl.allowAny { return true diff --git a/internal/http/server.go b/internal/http/server.go index 4bfffcb..6cb55c9 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -10,6 +10,7 @@ import ( "github.com/reugn/auth-server/internal/config" "github.com/reugn/auth-server/internal/proxy" "github.com/reugn/auth-server/internal/repository" + "github.com/reugn/auth-server/internal/util/httputil" "golang.org/x/time/rate" ) @@ -26,47 +27,49 @@ type Server struct { } // NewServer returns a new instance of Server. +// It initializes the server with the given version, keys, and configuration. func NewServer(version string, keys *auth.Keys, config *config.Service) (*Server, error) { address := fmt.Sprintf("%s:%d", config.HTTP.Host, config.HTTP.Port) - repository, err := config.Repository() + repo, err := config.Repository() if err != nil { return nil, err } + + // get the signing method signingMethod, err := config.SigningMethodRSA() if err != nil { return nil, err } - generator := auth.NewJWTGenerator(keys, signingMethod) - validator := auth.NewJWTValidator(keys, repository) - requestParser, err := config.RequestParser() - if err != nil { - return nil, err - } + // get the IP white list ipWhiteList, err := NewIPWhiteList(config.HTTP.Rate.WhiteList) if err != nil { return nil, err } + return &Server{ address: address, version: version, - parser: requestParser, - repository: repository, + parser: config.RequestParser(), + repository: repo, rateLimiter: NewIPRateLimiter(rate.Limit(config.HTTP.Rate.Tps), config.HTTP.Rate.Size), ipWhiteList: ipWhiteList, - jwtGenerator: generator, - jwtValidator: validator, + jwtGenerator: auth.NewJWTGenerator(keys, signingMethod), + jwtValidator: auth.NewJWTValidator(keys, repo), }, nil } func (ws *Server) rateLimiterMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // get the IP from the remote address ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + + // check if the IP is in the white list if !ws.ipWhiteList.isAllowed(ip) { limiter := ws.rateLimiter.GetLimiter(ip) if !limiter.Allow() { @@ -75,6 +78,8 @@ func (ws *Server) rateLimiterMiddleware(next http.Handler) http.Handler { return } } + + // serve the request next.ServeHTTP(w, r) }) } @@ -100,7 +105,10 @@ func (ws *Server) Start() error { // authorization route, requires a JSON Web Token mux.HandleFunc("/auth", ws.authActionHandler) + // prefix match for Envoy ext_authz (handles /auth/health, /auth/ready, etc.) + mux.HandleFunc("/auth/", ws.authActionHandler) + //nolint:gosec return http.ListenAndServe(ws.address, ws.rateLimiterMiddleware(mux)) } @@ -125,27 +133,47 @@ func (ws *Server) versionActionHandler(w http.ResponseWriter, _ *http.Request) { func (ws *Server) tokenActionHandler(w http.ResponseWriter, r *http.Request) { slog.Debug("Token generation request") + // get the basic authentication credentials user, pass, ok := r.BasicAuth() if !ok { + slog.Info("Failed to get basic authentication credentials", httputil.LogAttrs(r)...) w.WriteHeader(http.StatusBadRequest) return } + + // authenticate the user userDetails := ws.repository.AuthenticateBasic(user, pass) if userDetails == nil { + slog.Info("Failed to authenticate user", httputil.LogAttrs(r, + slog.String("username", user))...) w.WriteHeader(http.StatusUnauthorized) return } + + slog.Debug("User authenticated", httputil.LogAttrs(r, + slog.String("username", user), slog.String("role", string(userDetails.UserRole)))...) + + // generate the access token accessToken, err := ws.jwtGenerator.Generate(userDetails.UserName, userDetails.UserRole) if err != nil { + slog.Error("Failed to generate access token", httputil.LogAttrs(r, + slog.String("username", user), slog.Any("error", err))...) w.WriteHeader(http.StatusInternalServerError) return } + + // marshal the access token marshalled, err := accessToken.Marshal() if err != nil { + slog.Error("Failed to marshal access token", httputil.LogAttrs(r, + slog.String("username", user), slog.Any("error", err))...) w.WriteHeader(http.StatusInternalServerError) return } - fmt.Fprintf(w, "%s", marshalled) + + // write the access token to the response + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, marshalled) } func (ws *Server) authActionHandler(w http.ResponseWriter, r *http.Request) { @@ -153,7 +181,12 @@ func (ws *Server) authActionHandler(w http.ResponseWriter, r *http.Request) { requestDetails := ws.parser.ParseRequestDetails(r) authToken := ws.parser.ParseAuthorizationToken(r) + // authorize the request using the JWT validator and write the response if !ws.jwtValidator.Authorize(authToken, requestDetails) { w.WriteHeader(http.StatusUnauthorized) + return } + + // success response + w.WriteHeader(http.StatusOK) } diff --git a/internal/proxy/parser.go b/internal/proxy/parser.go index ffdc2b0..87d62f5 100644 --- a/internal/proxy/parser.go +++ b/internal/proxy/parser.go @@ -1,17 +1,166 @@ package proxy import ( + "log/slog" "net/http" + "strings" "github.com/reugn/auth-server/internal/repository" ) -// RequestParser represents a request parser. -type RequestParser interface { +// bearerPrefix is the prefix for the Authorization header. +const bearerPrefix = "Bearer " +// RequestParser represents a request parser. It parses the authorization token and the request +// details from the original request. +type RequestParser interface { // ParseAuthorizationToken parses and returns an Authorization token from the original request. ParseAuthorizationToken(r *http.Request) string - // ParseRequestDetails parses and returns a RequestDetails from the original request. ParseRequestDetails(r *http.Request) *repository.RequestDetails } + +// HeaderConfig defines which headers to check for method and URI. +type HeaderConfig struct { + // MethodHeaders are headers to check for the original HTTP method (in priority order). + MethodHeaders []string + // URIHeaders are headers to check for the original URI (in priority order). + URIHeaders []string + // PrefixHeader is an optional header for path prefix reconstruction. + PrefixHeader string +} + +// DefaultHeaderConfigs provides pre-configured header settings for common proxies. +var DefaultHeaderConfigs = map[string]HeaderConfig{ + "direct": { + MethodHeaders: nil, + URIHeaders: nil, + PrefixHeader: "", + }, + "nginx": { + MethodHeaders: []string{"X-Forwarded-Method"}, + URIHeaders: []string{"X-Forwarded-Uri"}, + PrefixHeader: "", + }, + "traefik": { + MethodHeaders: []string{"X-Forwarded-Method"}, + URIHeaders: []string{"X-Forwarded-Uri"}, + PrefixHeader: "X-Forwarded-Prefix", + }, + "envoy": { + MethodHeaders: []string{"X-Original-Method", "X-Envoy-Original-Method", "X-Forwarded-Method"}, + URIHeaders: []string{"X-Original-Uri", "X-Envoy-Original-Path", "X-Forwarded-Uri"}, + PrefixHeader: "", + }, + "haproxy": { + MethodHeaders: []string{"X-Forwarded-Method"}, + URIHeaders: []string{"X-Forwarded-Uri", "X-Original-URI"}, + PrefixHeader: "", + }, + "kong": { + MethodHeaders: []string{"X-Forwarded-Method"}, + URIHeaders: []string{"X-Forwarded-Path", "X-Forwarded-Uri"}, + PrefixHeader: "X-Forwarded-Prefix", + }, +} + +// Parser is a configurable request parser that works with any proxy. +type Parser struct { + config HeaderConfig +} + +var _ RequestParser = (*Parser)(nil) + +// NewParser returns a Parser configured for a known proxy. +// Falls back to direct (no forwarded headers) if the proxy is unknown. +func NewParser(proxyName string) *Parser { + config, ok := DefaultHeaderConfigs[strings.ToLower(proxyName)] + if !ok { + slog.Warn("Unknown proxy provider, using direct mode", "proxy", proxyName) + config = DefaultHeaderConfigs["direct"] + } + return &Parser{config: config} +} + +// NewParserWithConfig returns a new Parser with a custom header configuration. +func NewParserWithConfig(config HeaderConfig) *Parser { + return &Parser{config: config} +} + +// ParseAuthorizationToken parses and returns an Authorization Bearer token from the request. +// It returns an empty string if the authorization header is not present or if the authorization +// header is not in the correct format (Bearer ). +func (p *Parser) ParseAuthorizationToken(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return "" + } + + if !strings.HasPrefix(authHeader, bearerPrefix) { + slog.Debug("Invalid Authorization header format") + return "" + } + + return strings.TrimSpace(authHeader[len(bearerPrefix):]) +} + +// ParseRequestDetails parses and returns RequestDetails from the request. +func (p *Parser) ParseRequestDetails(r *http.Request) *repository.RequestDetails { + return &repository.RequestDetails{ + Method: p.parseMethod(r), + URI: p.parseURI(r), + } +} + +// parseMethod extracts the HTTP method from forwarded headers or the request itself. +func (p *Parser) parseMethod(r *http.Request) string { + if len(p.config.MethodHeaders) > 0 { + if method := getFirstHeader(r, p.config.MethodHeaders...); method != "" { + return method + } + } + return r.Method +} + +// parseURI extracts the URI from forwarded headers or the request itself. +func (p *Parser) parseURI(r *http.Request) string { + // First, try configured URI headers + if len(p.config.URIHeaders) > 0 { + if uri := getFirstHeader(r, p.config.URIHeaders...); uri != "" { + return uri + } + } + + // Handle prefix reconstruction if configured + if p.config.PrefixHeader != "" { + if prefix := r.Header.Get(p.config.PrefixHeader); prefix != "" { + return reconstructURI(prefix, r.URL.RequestURI()) + } + } + + return r.URL.RequestURI() +} + +// getFirstHeader returns the first non-empty header value from the provided headers. +func getFirstHeader(r *http.Request, headers ...string) string { + for _, header := range headers { + if value := r.Header.Get(header); value != "" { + return value + } + } + return "" +} + +// reconstructURI combines a prefix with the current path. +func reconstructURI(prefix, currentPath string) string { + prefix = strings.TrimSuffix(prefix, "/") + currentPath = strings.TrimPrefix(currentPath, "/") + + if prefix == "" { + return "/" + currentPath + } + if currentPath == "" { + return prefix + } + return prefix + "/" + currentPath +} diff --git a/internal/proxy/simple_parser.go b/internal/proxy/simple_parser.go deleted file mode 100644 index ce4a70c..0000000 --- a/internal/proxy/simple_parser.go +++ /dev/null @@ -1,41 +0,0 @@ -package proxy - -import ( - "log/slog" - "net/http" - "strings" - - "github.com/reugn/auth-server/internal/repository" -) - -// SimpleParser implements the RequestParser interface. -type SimpleParser struct{} - -var _ RequestParser = (*SimpleParser)(nil) - -// NewSimpleParser returns a new SimpleParser. -func NewSimpleParser() *SimpleParser { - return &SimpleParser{} -} - -// ParseAuthorizationToken parses and returns an Authorization Bearer token from the original request. -func (sp *SimpleParser) ParseAuthorizationToken(r *http.Request) string { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - return authHeader - } - splitToken := strings.Split(authHeader, "Bearer") - if len(splitToken) == 2 { - return strings.TrimSpace(splitToken[1]) - } - slog.Debug("Invalid Authorization header", "header", authHeader) - return "" -} - -// ParseRequestDetails parses and returns a RequestDetails from the original request. -func (sp *SimpleParser) ParseRequestDetails(r *http.Request) *repository.RequestDetails { - return &repository.RequestDetails{ - Method: r.Method, - URI: r.URL.RequestURI(), - } -} diff --git a/internal/proxy/traefik_parser.go b/internal/proxy/traefik_parser.go deleted file mode 100644 index 945ed5f..0000000 --- a/internal/proxy/traefik_parser.go +++ /dev/null @@ -1,41 +0,0 @@ -package proxy - -import ( - "log/slog" - "net/http" - "strings" - - "github.com/reugn/auth-server/internal/repository" -) - -// TraefikParser implements the RequestParser interface. -type TraefikParser struct{} - -var _ RequestParser = (*TraefikParser)(nil) - -// NewTraefikParser returns a new TraefikParser. -func NewTraefikParser() *TraefikParser { - return &TraefikParser{} -} - -// ParseAuthorizationToken parses and returns an Authorization Bearer token from the original request. -func (tp *TraefikParser) ParseAuthorizationToken(r *http.Request) string { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - return authHeader - } - splitToken := strings.Split(authHeader, "Bearer") - if len(splitToken) == 2 { - return strings.TrimSpace(splitToken[1]) - } - slog.Debug("Invalid Authorization header", "header", authHeader) - return "" -} - -// ParseRequestDetails parses and returns a RequestDetails from the original request. -func (tp *TraefikParser) ParseRequestDetails(r *http.Request) *repository.RequestDetails { - return &repository.RequestDetails{ - Method: r.Header.Get("X-Forwarded-Method"), - URI: r.Header.Get("X-Forwarded-Uri"), - } -} diff --git a/internal/repository/aerospike.go b/internal/repository/aerospike.go index b3a69fd..2c89ab3 100644 --- a/internal/repository/aerospike.go +++ b/internal/repository/aerospike.go @@ -3,7 +3,7 @@ package repository import ( "log/slog" - as "github.com/aerospike/aerospike-client-go/v7" + as "github.com/aerospike/aerospike-client-go/v8" "github.com/reugn/auth-server/internal/util/env" ) @@ -38,6 +38,7 @@ type AerospikeRepository struct { var _ Repository = (*AerospikeRepository)(nil) +// getAerospikeConfig gets the Aerospike configuration from the environment variables. func getAerospikeConfig() aerospikeConfig { // set defaults config := aerospikeConfig{ @@ -67,10 +68,12 @@ func NewAerospike() (*AerospikeRepository, error) { if err != nil { return nil, err } + baseKey, err := as.NewKey(config.namespase, config.setName, config.basicAuthKey) if err != nil { return nil, err } + authKey, err := as.NewKey(config.namespase, config.setName, config.authorizationKey) if err != nil { return nil, err @@ -94,16 +97,22 @@ func (aero *AerospikeRepository) AuthenticateBasic(username string, password str } // Bin(user1: {username: user1, password: sha256, role: admin}) - userBin := record.Bins[username].(map[string]interface{}) + userBin := record.Bins[username].(map[string]any) hashed, ok := userBin["password"].(string) if !ok || !pwdMatch(hashed, password) { slog.Debug("Failed to authenticate", "user", username) return nil } + role, ok := userBin["role"].(UserRole) + if !ok { + slog.Error("Invalid role type", "role", userBin["role"]) + return nil + } + return &UserDetails{ UserName: username, - UserRole: userBin["role"].(UserRole), + UserRole: role, } } diff --git a/internal/repository/local.go b/internal/repository/local.go index f91b2bf..92a8ba4 100644 --- a/internal/repository/local.go +++ b/internal/repository/local.go @@ -1,8 +1,10 @@ package repository import ( + "fmt" "log/slog" "os" + "slices" "github.com/reugn/auth-server/internal/util/env" "gopkg.in/yaml.v3" @@ -36,12 +38,12 @@ func NewLocal() (*Local, error) { data, err := os.ReadFile(configPath) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read configuration file %s: %w", configPath, err) } localRepository := &Local{} if err = yaml.Unmarshal(data, localRepository); err != nil { - return nil, err + return nil, fmt.Errorf("failed to unmarshal configuration data: %w", err) } return localRepository, nil @@ -63,7 +65,7 @@ func (local *Local) AuthenticateBasic(username string, password string) *UserDet // AuthorizeRequest checks if the role has permissions to access the endpoint. func (local *Local) AuthorizeRequest(userRole UserRole, requestDetails RequestDetails) bool { if permissions, ok := local.Roles[userRole]; ok { - if containsRequestDetails(permissions, requestDetails) { + if slices.Contains(permissions, requestDetails) { slog.Debug("Request authorized", "request", requestDetails) return true } @@ -71,12 +73,3 @@ func (local *Local) AuthorizeRequest(userRole UserRole, requestDetails RequestDe slog.Debug("Authorization failed for the request", "request", requestDetails) return false } - -func containsRequestDetails(details []RequestDetails, requestDetails RequestDetails) bool { - for _, detail := range details { - if detail == requestDetails { - return true - } - } - return false -} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 8995cc4..ab77b60 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -23,7 +23,7 @@ type RequestDetails struct { URI string `yaml:"uri"` } -// String implements the fmt.Stringer interface. +// String returns a string representation of the RequestDetails. func (r RequestDetails) String() string { return fmt.Sprintf("%s %s", r.Method, r.URI) } @@ -39,6 +39,7 @@ type Repository interface { AuthorizeRequest(userRole UserRole, request RequestDetails) bool } +// isAuthorizedRequest checks if the role has permissions to access the endpoint. func isAuthorizedRequest(scopes []map[string]string, request RequestDetails) bool { for _, scope := range scopes { if (scope["method"] == "*" || scope["method"] == request.Method) && @@ -51,18 +52,20 @@ func isAuthorizedRequest(scopes []map[string]string, request RequestDetails) boo return false } +// HashAndSalt hashes and salts the password using the bcrypt algorithm. func HashAndSalt(pwd string) ([]byte, error) { bytePwd := []byte(pwd) // use bcrypt.GenerateFromPassword to hash and salt the password hash, err := bcrypt.GenerateFromPassword(bytePwd, bcrypt.MinCost) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to bcrypt hash password: %w", err) } return hash, nil } +// pwdMatch checks if the hashed password matches the plain password. func pwdMatch(hashed string, plain string) bool { hashedBytes := []byte(hashed) plainBytes := []byte(plain) diff --git a/internal/repository/vault.go b/internal/repository/vault.go index a403e00..2f12078 100644 --- a/internal/repository/vault.go +++ b/internal/repository/vault.go @@ -11,7 +11,7 @@ import ( // Environment variables to configure VaultRepository. const ( envVaultAddr = "AUTH_SERVER_VAULT_ADDR" - envVaultToken = "AUTH_SERVER_VAULT_TOKEN" + envVaultToken = "AUTH_SERVER_VAULT_TOKEN" //nolint:gosec envVaultBasicKey = "AUTH_SERVER_VAULT_BASIC_KEY" envVaultAuthKey = "AUTH_SERVER_VAULT_AUTHORIZATION_KEY" ) @@ -56,10 +56,12 @@ func NewVault() (*VaultRepository, error) { apiConfig := &api.Config{ Address: config.vaultAddr, } + client, err := api.NewClient(apiConfig) if err != nil { return nil, err } + client.SetToken(config.vaultToken) return &VaultRepository{ @@ -84,9 +86,15 @@ func (vr *VaultRepository) AuthenticateBasic(username string, password string) * return nil } + role, ok := secret.Data["role"].(UserRole) + if !ok { + slog.Error("Invalid role type", "role", secret.Data["role"]) + return nil + } + return &UserDetails{ UserName: username, - UserRole: secret.Data["role"].(UserRole), + UserRole: role, } } diff --git a/internal/util/hash/hash_test.go b/internal/util/hash/hash_test.go index 7ffee5d..e29e1e6 100644 --- a/internal/util/hash/hash_test.go +++ b/internal/util/hash/hash_test.go @@ -1,13 +1,52 @@ -package hash_test +package hash import ( "testing" - - "github.com/reugn/auth-server/internal/util/hash" ) func TestSha256(t *testing.T) { - if hash.Sha256("1234") != "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4" { - t.Fatal("Sha256") + tests := []struct { + name string + input string + expected string + }{ + { + name: "numeric string", + input: "1234", + expected: "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + }, + { + name: "empty string", + input: "", + expected: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + { + name: "hello world", + input: "hello world", + expected: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + }, + { + name: "unicode", + input: "日本語", + expected: "77710aedc74ecfa33685e33a6c7df5cc83004da1bdcef7fb280f5c2b2e97e0a5", + }, + { + name: "with spaces", + input: " spaces ", + expected: "3294fa4c529bfcd6d2b5683a79ea6ca944a92bc2dd8b2601cb2427a6abd0ba04", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Sha256(tt.input) + if result != tt.expected { + t.Errorf("Sha256(%q) = %q, want %q", tt.input, result, tt.expected) + } + // Verify output format: 64 lowercase hex characters + if len(result) != 64 { + t.Errorf("Sha256(%q) length = %d, want 64", tt.input, len(result)) + } + }) } } diff --git a/internal/util/httputil/http_request.go b/internal/util/httputil/http_request.go new file mode 100644 index 0000000..c0e47a9 --- /dev/null +++ b/internal/util/httputil/http_request.go @@ -0,0 +1,46 @@ +package httputil + +import ( + "log/slog" + "net" + "net/http" + "strings" +) + +// GetClientIP extracts the client IP address from the request. +func GetClientIP(r *http.Request) string { + // Check X-Forwarded-For header first (for proxy scenarios) + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + // X-Forwarded-For can contain multiple IPs, take the first one + if ip := strings.Split(forwarded, ",")[0]; ip != "" { + return strings.TrimSpace(ip) + } + } + + // Check X-Real-IP header (alternative proxy header) + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return realIP + } + + // Fall back to RemoteAddr + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} + +// LogAttrs returns log attributes for request logging. +// It includes IP address and user agent. Additional attributes can be provided. +func LogAttrs(r *http.Request, attrs ...slog.Attr) []any { + attrsList := make([]any, 0, len(attrs)+2) + attrsList = append(attrsList, + slog.String("ip", GetClientIP(r)), + slog.String("user_agent", r.UserAgent())) + + for _, attr := range attrs { + attrsList = append(attrsList, attr) + } + + return attrsList +} diff --git a/internal/util/httputil/http_request_test.go b/internal/util/httputil/http_request_test.go new file mode 100644 index 0000000..122df98 --- /dev/null +++ b/internal/util/httputil/http_request_test.go @@ -0,0 +1,174 @@ +package httputil + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "testing" +) + +const testRemoteAddr = "192.168.1.1:12345" + +func TestGetClientIP(t *testing.T) { + tests := []struct { + name string + headers map[string]string + remoteAddr string + expected string + }{ + { + name: "X-Forwarded-For single IP", + headers: map[string]string{"X-Forwarded-For": "192.168.1.1"}, + remoteAddr: "10.0.0.1:12345", + expected: "192.168.1.1", + }, + { + name: "X-Forwarded-For multiple IPs", + headers: map[string]string{"X-Forwarded-For": "192.168.1.1, 10.0.0.2, 172.16.0.1"}, + remoteAddr: "10.0.0.1:12345", + expected: "192.168.1.1", + }, + { + name: "X-Forwarded-For with spaces", + headers: map[string]string{"X-Forwarded-For": " 192.168.1.100 "}, + remoteAddr: "10.0.0.1:12345", + expected: "192.168.1.100", + }, + { + name: "X-Real-IP header", + headers: map[string]string{"X-Real-IP": "172.16.0.50"}, + remoteAddr: "10.0.0.1:12345", + expected: "172.16.0.50", + }, + { + name: "X-Forwarded-For takes precedence over X-Real-IP", + headers: map[string]string{"X-Forwarded-For": "192.168.1.1", "X-Real-IP": "172.16.0.50"}, + remoteAddr: "10.0.0.1:12345", + expected: "192.168.1.1", + }, + { + name: "RemoteAddr fallback with port", + headers: map[string]string{}, + remoteAddr: "203.0.113.50:54321", + expected: "203.0.113.50", + }, + { + name: "RemoteAddr fallback without port", + headers: map[string]string{}, + remoteAddr: "203.0.113.50", + expected: "203.0.113.50", + }, + { + name: "IPv6 address in X-Forwarded-For", + headers: map[string]string{"X-Forwarded-For": "2001:db8::1"}, + remoteAddr: "10.0.0.1:12345", + expected: "2001:db8::1", + }, + { + name: "Empty X-Forwarded-For falls back to X-Real-IP", + headers: map[string]string{"X-Forwarded-For": "", "X-Real-IP": "172.16.0.50"}, + remoteAddr: "10.0.0.1:12345", + expected: "172.16.0.50", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = tt.remoteAddr + for key, value := range tt.headers { + req.Header.Set(key, value) + } + + result := GetClientIP(req) + if result != tt.expected { + t.Errorf("GetClientIP() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestLogAttrs(t *testing.T) { + t.Run("basic attributes", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = testRemoteAddr + req.Header.Set("User-Agent", "TestAgent/1.0") + + attrs := LogAttrs(req) + + if len(attrs) != 2 { + t.Errorf("LogAttrs() returned %d attributes, want 2", len(attrs)) + } + + // Check IP attribute + ipAttr, ok := attrs[0].(slog.Attr) + if !ok { + t.Fatal("first attribute is not slog.Attr") + } + if ipAttr.Key != "ip" || ipAttr.Value.String() != "192.168.1.1" { + t.Errorf("IP attribute = %v, want ip=192.168.1.1", ipAttr) + } + + // Check user_agent attribute + uaAttr, ok := attrs[1].(slog.Attr) + if !ok { + t.Fatal("second attribute is not slog.Attr") + } + if uaAttr.Key != "user_agent" || uaAttr.Value.String() != "TestAgent/1.0" { + t.Errorf("user_agent attribute = %v, want user_agent=TestAgent/1.0", uaAttr) + } + }) + + t.Run("with additional attributes", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = testRemoteAddr + req.Header.Set("User-Agent", "TestAgent/1.0") + + extraAttrs := []slog.Attr{ + slog.String("username", "testuser"), + slog.Int("status", 200), + } + + attrs := LogAttrs(req, extraAttrs...) + + if len(attrs) != 4 { + t.Errorf("LogAttrs() returned %d attributes, want 4", len(attrs)) + } + + // Check additional attributes + usernameAttr, ok := attrs[2].(slog.Attr) + if !ok { + t.Fatal("third attribute is not slog.Attr") + } + if usernameAttr.Key != "username" || usernameAttr.Value.String() != "testuser" { + t.Errorf("username attribute = %v, want username=testuser", usernameAttr) + } + + statusAttr, ok := attrs[3].(slog.Attr) + if !ok { + t.Fatal("fourth attribute is not slog.Attr") + } + if statusAttr.Key != "status" || statusAttr.Value.Int64() != 200 { + t.Errorf("status attribute = %v, want status=200", statusAttr) + } + }) + + t.Run("empty user agent", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = testRemoteAddr + + attrs := LogAttrs(req) + + if len(attrs) != 2 { + t.Errorf("LogAttrs() returned %d attributes, want 2", len(attrs)) + } + + uaAttr, ok := attrs[1].(slog.Attr) + if !ok { + t.Fatal("second attribute is not slog.Attr") + } + if uaAttr.Key != "user_agent" || uaAttr.Value.String() != "" { + t.Errorf("user_agent attribute = %v, want user_agent=\"\"", uaAttr) + } + }) +}