├── .dockerignore ├── .gitignore ├── docs ├── images │ └── architecture_diagram_1.png └── repository_configuration.md ├── config ├── local_repository_config.yml └── service_config.yml ├── internal ├── util │ ├── hash │ │ ├── hash.go │ │ └── hash_test.go │ └── env │ │ └── env.go ├── proxy │ ├── parser.go │ ├── simple_parser.go │ └── traefik_parser.go ├── repository │ ├── vault_test.go │ ├── aerospike_test.go │ ├── repository.go │ ├── local.go │ ├── vault.go │ └── aerospike.go ├── config │ ├── secret.go │ ├── http_server.go │ ├── logger.go │ └── service.go ├── auth │ ├── jwt.go │ ├── jwt_generator.go │ ├── jwt_validator.go │ ├── keys.go │ └── jwt_test.go └── http │ ├── rate_limiter.go │ └── server.go ├── secrets └── README.md ├── examples └── traefik │ ├── traefik.yml │ ├── dynamic-config.yml │ └── docker-compose.yml ├── .github └── workflows │ ├── build.yml │ ├── golangci-lint.yml │ └── docker.yml ├── Dockerfile ├── .golangci.yml ├── cmd └── auth │ └── main.go ├── go.mod ├── README.md ├── LICENSE └── go.sum /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .cache 4 | 5 | examples/ 6 | docs/ 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | /vendor 4 | /secrets/cert.pem 5 | /secrets/privkey.pem 6 | /cmd/auth/auth 7 | -------------------------------------------------------------------------------- /docs/images/architecture_diagram_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reugn/auth-server/HEAD/docs/images/architecture_diagram_1.png -------------------------------------------------------------------------------- /config/local_repository_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | users: 3 | admin: 4 | password: 1234 5 | role: admin 6 | 7 | roles: 8 | admin: 9 | - method: "GET" 10 | uri: "/dashboard" 11 | - method: "POST" 12 | uri: "/auth" 13 | - method: "GET" 14 | uri: "/health" 15 | -------------------------------------------------------------------------------- /internal/util/hash/hash.go: -------------------------------------------------------------------------------- 1 | package hash 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | ) 7 | 8 | // Sha256 returns the Sha256 hexadecimal representation of the string. 9 | func Sha256(str string) string { 10 | sha256pwd := sha256.Sum256([]byte(str)) 11 | return fmt.Sprintf("%x", sha256pwd) 12 | } 13 | -------------------------------------------------------------------------------- /internal/util/hash/hash_test.go: -------------------------------------------------------------------------------- 1 | package hash_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/reugn/auth-server/internal/util/hash" 7 | ) 8 | 9 | func TestSha256(t *testing.T) { 10 | if hash.Sha256("1234") != "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4" { 11 | t.Fatal("Sha256") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /secrets/README.md: -------------------------------------------------------------------------------- 1 | # Secrets folder 2 | 3 | ## Gitignored contents: 4 | * privkey.pem 5 | * cert.pem 6 | 7 | ## Generate an RSA keypair with a 2048 bit private key 8 | `openssl genpkey -algorithm RSA -out privkey.pem -pkeyopt rsa_keygen_bits:2048` 9 | 10 | ## Extract the public key from an RSA keypair 11 | `openssl rsa -pubout -in privkey.pem -out cert.pem` 12 | -------------------------------------------------------------------------------- /config/service_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | signing-method: RS256 3 | proxy: traefik 4 | repository: local 5 | http: 6 | host: 0.0.0.0 7 | port: 8081 8 | rate: 9 | tps: 1024 10 | size: 1024 11 | white-list: [] 12 | secret: 13 | private-path: secrets/privkey.pem 14 | public-path: secrets/cert.pem 15 | logger: 16 | level: INFO 17 | format: PLAIN 18 | -------------------------------------------------------------------------------- /examples/traefik/traefik.yml: -------------------------------------------------------------------------------- 1 | --- 2 | global: 3 | checkNewVersion: true 4 | sendAnonymousUsage: true 5 | 6 | entryPoints: 7 | websecure: 8 | address: ":443" 9 | http: 10 | address: ":8082" 11 | 12 | api: 13 | dashboard: true 14 | insecure: true 15 | 16 | providers: 17 | file: 18 | filename: /etc/traefik/dynamic-config.yml 19 | watch: true 20 | docker: 21 | network: proxy 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | go-version: [1.21.x] 17 | steps: 18 | - name: Setup Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ matrix.go-version }} 22 | 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | 26 | - name: Test 27 | run: go test ./... 28 | -------------------------------------------------------------------------------- /internal/proxy/parser.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/reugn/auth-server/internal/repository" 7 | ) 8 | 9 | // RequestParser represents a request parser. 10 | type RequestParser interface { 11 | 12 | // ParseAuthorizationToken parses and returns an Authorization token from the original request. 13 | ParseAuthorizationToken(r *http.Request) string 14 | 15 | // ParseRequestDetails parses and returns a RequestDetails from the original request. 16 | ParseRequestDetails(r *http.Request) *repository.RequestDetails 17 | } 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.2 2 | FROM golang:alpine3.19 AS build 3 | RUN apk --no-cache add gcc g++ make git 4 | WORKDIR /go/src/app 5 | COPY . . 6 | RUN go get ./... 7 | WORKDIR /go/src/app/cmd/auth 8 | RUN GOOS=linux go build -ldflags="-s -w" -o ./bin/auth 9 | 10 | FROM alpine:3.19.1 11 | WORKDIR /app 12 | COPY --from=build /go/src/app/cmd/auth/bin /app 13 | COPY --from=build /go/src/app/config /app/ 14 | COPY ./secrets ./secrets 15 | ENV AUTH_SERVER_LOCAL_CONFIG_PATH=local_repository_config.yml 16 | 17 | EXPOSE 8081 18 | ENTRYPOINT ["/app/auth", "-c", "service_config.yml"] 19 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | golangci: 14 | name: lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.22' 23 | cache: false 24 | 25 | - name: golangci-lint 26 | uses: golangci/golangci-lint-action@v4 27 | with: 28 | version: v1.56 29 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | - dupl 5 | - errcheck 6 | - errorlint 7 | - exportloopref 8 | - funlen 9 | - gci 10 | - goconst 11 | - gocritic 12 | - gocyclo 13 | - gofmt 14 | - goimports 15 | - gosimple 16 | - govet 17 | - ineffassign 18 | - lll 19 | - misspell 20 | - prealloc 21 | - revive 22 | - staticcheck 23 | - stylecheck 24 | - typecheck 25 | - unconvert 26 | - unparam 27 | - unused 28 | 29 | issues: 30 | exclude-rules: 31 | - path: _test\.go 32 | linters: 33 | - unparam 34 | - funlen 35 | -------------------------------------------------------------------------------- /internal/repository/vault_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func Test_getVaultConfig(t *testing.T) { 9 | os.Setenv(envVaultAddr, "127.0.0.1:8200") 10 | os.Setenv(envVaultToken, "token1") 11 | os.Setenv(envVaultBasicKey, "secret/basic1") 12 | os.Setenv(envVaultAuthKey, "secret/authorization1") 13 | 14 | config := getVaultConfig() 15 | if config.vaultAddr != "127.0.0.1:8200" { 16 | t.Fail() 17 | } 18 | if config.vaultToken != "token1" { 19 | t.Fail() 20 | } 21 | if config.basicAuthKeyPrefix != "secret/basic1" { 22 | t.Fail() 23 | } 24 | if config.authorizationKeyPrefix != "secret/authorization1" { 25 | t.Fail() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/traefik/dynamic-config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http: 3 | services: 4 | auth-server: 5 | loadBalancer: 6 | servers: 7 | - url: http://auth-server:8081/ 8 | 9 | middlewares: 10 | test-auth: 11 | forwardAuth: 12 | address: http://auth-server:8081/auth 13 | authResponseHeaders: 14 | - "X-Auth-User" 15 | - "X-Secret" 16 | trustForwardHeader: true 17 | 18 | routers: 19 | token-router: 20 | rule: "Path(`/token`)" 21 | service: auth-server 22 | entrypoints: 23 | - http 24 | priority: 2 25 | 26 | auth-router: 27 | rule: "HostRegexp(`{host:.*}`)" 28 | middlewares: 29 | - test-auth 30 | service: auth-server 31 | entrypoints: 32 | - http 33 | priority: 1 34 | -------------------------------------------------------------------------------- /internal/repository/aerospike_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func Test_getAerospikeConfig(t *testing.T) { 9 | os.Setenv(envAerospikeHost, "127.0.0.1") 10 | os.Setenv(envAerospikePort, "3300") 11 | os.Setenv(envAerospikeNamespace, "test1") 12 | os.Setenv(envAerospikeSet, "set1") 13 | os.Setenv(envAerospikeBasicKey, "basic1") 14 | os.Setenv(envAerospikeAuthKey, "authorization1") 15 | 16 | config := getAerospikeConfig() 17 | if config.hostname != "127.0.0.1" { 18 | t.Fail() 19 | } 20 | if config.port != 3300 { 21 | t.Fail() 22 | } 23 | if config.namespase != "test1" { 24 | t.Fail() 25 | } 26 | if config.setName != "set1" { 27 | t.Fail() 28 | } 29 | if config.basicAuthKey != "basic1" { 30 | t.Fail() 31 | } 32 | if config.authorizationKey != "authorization1" { 33 | t.Fail() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/traefik/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | networks: 4 | proxy: 5 | 6 | services: 7 | reverse-proxy: 8 | restart: always 9 | image: traefik:v2.11 10 | container_name: traefik 11 | ports: 12 | - 443:443 13 | - 8082:8082 14 | volumes: 15 | - /var/run/docker.sock:/var/run/docker.sock 16 | - ./traefik.yml:/etc/traefik/traefik.yml 17 | - ./dynamic-config.yml:/etc/traefik/dynamic-config.yml 18 | labels: 19 | - "traefik.http.routers.site.entryPoints=http,websecure" 20 | - "traefik.enable=true" 21 | - "traefik.port=8082" 22 | networks: 23 | - proxy 24 | 25 | auth-server: 26 | restart: always 27 | container_name: auth-server 28 | ports: 29 | - 8081:8081 30 | image: auth-server 31 | build: 32 | dockerfile: Dockerfile 33 | context: ../../. 34 | networks: 35 | - proxy 36 | -------------------------------------------------------------------------------- /internal/util/env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | // ReadString retrieves the string value of the environment variable named 10 | // by the key. 11 | func ReadString(value *string, key string) { 12 | envValue, ok := os.LookupEnv(key) 13 | if ok { 14 | *value = envValue 15 | } 16 | } 17 | 18 | // ReadInt retrieves the integer value of the environment variable named 19 | // by the key. 20 | func ReadInt(value *int, key string) { 21 | envValue, ok := os.LookupEnv(key) 22 | if ok { 23 | intValue, err := strconv.Atoi(envValue) 24 | if err == nil { 25 | *value = intValue 26 | } 27 | } 28 | } 29 | 30 | // ReadTime retrieves the time value of the environment variable named 31 | // by the key. 32 | func ReadTime(value *time.Duration, key string, timeUnit time.Duration) { 33 | envValue, ok := os.LookupEnv(key) 34 | if ok { 35 | intValue, err := strconv.Atoi(envValue) 36 | if err == nil { 37 | *value = time.Duration(intValue) * timeUnit 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/config/secret.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | // Secret holds the configuration for secret keys. 8 | type Secret struct { 9 | // Private denotes the path to the private key. 10 | Private string `yaml:"private-path,omitempty" json:"private-path,omitempty"` 11 | // Public denotes the path to the public key. 12 | Public string `yaml:"public-path,omitempty" json:"public-path,omitempty"` 13 | } 14 | 15 | // NewSecretDefault returns a new Secret with default values. 16 | func NewSecretDefault() *Secret { 17 | return &Secret{ 18 | Private: "secrets/privkey.pem", 19 | Public: "secrets/cert.pem", 20 | } 21 | } 22 | 23 | // validate validates the Secret configuration properties. 24 | func (s *Secret) validate() error { 25 | if s == nil { 26 | return errors.New("secret config is nil") 27 | } 28 | if s.Private == "" { 29 | return errors.New("private key path is not specified") 30 | } 31 | if s.Public == "" { 32 | return errors.New("public key path is not specified") 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Log in to the Container registry 24 | uses: docker/login-action@v2 25 | with: 26 | registry: ${{ env.REGISTRY }} 27 | username: ${{ github.actor }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@v4 33 | with: 34 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@v4 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /internal/proxy/simple_parser.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/reugn/auth-server/internal/repository" 9 | ) 10 | 11 | // SimpleParser implements the RequestParser interface. 12 | type SimpleParser struct{} 13 | 14 | var _ RequestParser = (*SimpleParser)(nil) 15 | 16 | // NewSimpleParser returns a new SimpleParser. 17 | func NewSimpleParser() *SimpleParser { 18 | return &SimpleParser{} 19 | } 20 | 21 | // ParseAuthorizationToken parses and returns an Authorization Bearer token from the original request. 22 | func (sp *SimpleParser) ParseAuthorizationToken(r *http.Request) string { 23 | authHeader := r.Header.Get("Authorization") 24 | if authHeader == "" { 25 | return authHeader 26 | } 27 | splitToken := strings.Split(authHeader, "Bearer") 28 | if len(splitToken) == 2 { 29 | return strings.TrimSpace(splitToken[1]) 30 | } 31 | slog.Debug("Invalid Authorization header", "header", authHeader) 32 | return "" 33 | } 34 | 35 | // ParseRequestDetails parses and returns a RequestDetails from the original request. 36 | func (sp *SimpleParser) ParseRequestDetails(r *http.Request) *repository.RequestDetails { 37 | return &repository.RequestDetails{ 38 | Method: r.Method, 39 | URI: r.URL.RequestURI(), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/proxy/traefik_parser.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/reugn/auth-server/internal/repository" 9 | ) 10 | 11 | // TraefikParser implements the RequestParser interface. 12 | type TraefikParser struct{} 13 | 14 | var _ RequestParser = (*TraefikParser)(nil) 15 | 16 | // NewTraefikParser returns a new TraefikParser. 17 | func NewTraefikParser() *TraefikParser { 18 | return &TraefikParser{} 19 | } 20 | 21 | // ParseAuthorizationToken parses and returns an Authorization Bearer token from the original request. 22 | func (tp *TraefikParser) ParseAuthorizationToken(r *http.Request) string { 23 | authHeader := r.Header.Get("Authorization") 24 | if authHeader == "" { 25 | return authHeader 26 | } 27 | splitToken := strings.Split(authHeader, "Bearer") 28 | if len(splitToken) == 2 { 29 | return strings.TrimSpace(splitToken[1]) 30 | } 31 | slog.Debug("Invalid Authorization header", "header", authHeader) 32 | return "" 33 | } 34 | 35 | // ParseRequestDetails parses and returns a RequestDetails from the original request. 36 | func (tp *TraefikParser) ParseRequestDetails(r *http.Request) *repository.RequestDetails { 37 | return &repository.RequestDetails{ 38 | Method: r.Header.Get("X-Forwarded-Method"), 39 | URI: r.Header.Get("X-Forwarded-Uri"), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/auth/jwt.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | 7 | "github.com/golang-jwt/jwt/v5" 8 | "github.com/reugn/auth-server/internal/repository" 9 | ) 10 | 11 | // TokenType represents a token type. 12 | type TokenType int 13 | 14 | const ( 15 | // BearerToken is an opaque string, not intended to have any meaning to clients using it. 16 | // Some servers will issue tokens that are a short `string` of hexadecimal characters, 17 | // while others may use structured tokens such as JSON Web Tokens. 18 | BearerToken TokenType = iota 19 | 20 | // BasicToken is a string where credentials is the base64 encoding of id and 21 | // password joined by a single colon : 22 | BasicToken 23 | ) 24 | 25 | // String returns the string representation of the TokenType. 26 | func (t TokenType) String() string { 27 | return [...]string{"Bearer", "Basic"}[t] 28 | } 29 | 30 | // Claims is the custom JWT claims container. 31 | type Claims struct { 32 | jwt.RegisteredClaims 33 | Username string `json:"user"` 34 | Role repository.UserRole `json:"role"` 35 | } 36 | 37 | // AccessToken represents an access token. 38 | type AccessToken struct { 39 | Token string `json:"access_token"` 40 | Type string `json:"token_type"` 41 | Expires int64 `json:"expires_in"` 42 | } 43 | 44 | // Marshal marshals the AccessToken to a JSON string. 45 | func (t *AccessToken) Marshal() (string, error) { 46 | jsonByteArray, err := json.Marshal(t) 47 | if err != nil { 48 | slog.Debug("Failed to marshal token", "err", err) 49 | return "", err 50 | } 51 | return string(jsonByteArray), nil 52 | } 53 | -------------------------------------------------------------------------------- /cmd/auth/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | 7 | "github.com/reugn/auth-server/internal/auth" 8 | "github.com/reugn/auth-server/internal/config" 9 | "github.com/reugn/auth-server/internal/http" 10 | "github.com/spf13/cobra" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | const ( 15 | version = "0.4.0" 16 | ) 17 | 18 | func run() int { 19 | rootCmd := &cobra.Command{ 20 | Short: "Authentication and authorization service", 21 | Version: version, 22 | } 23 | 24 | var configFilePath string 25 | rootCmd.Flags().StringVarP(&configFilePath, "config", "c", "config.yaml", "configuration file path") 26 | 27 | rootCmd.RunE = func(_ *cobra.Command, _ []string) error { 28 | // read configuration file 29 | config, err := readConfiguration(configFilePath) 30 | if err != nil { 31 | return err 32 | } 33 | // load ssl keys 34 | keys, err := auth.NewKeys(config.Secret) 35 | if err != nil { 36 | return err 37 | } 38 | // set default logger 39 | slogHandler, err := config.Logger.SlogHandler() 40 | if err != nil { 41 | return err 42 | } 43 | slog.SetDefault(slog.New(slogHandler)) 44 | // start http server 45 | server, err := http.NewServer(version, keys, config) 46 | if err != nil { 47 | return err 48 | } 49 | slog.Info("Starting service", "config", config) 50 | return server.Start() 51 | } 52 | 53 | err := rootCmd.Execute() 54 | if err != nil { 55 | return 1 56 | } 57 | return 0 58 | } 59 | 60 | func readConfiguration(path string) (*config.Service, error) { 61 | data, err := os.ReadFile(path) 62 | if err != nil { 63 | return nil, err 64 | } 65 | config := config.NewServiceDefault() 66 | err = yaml.Unmarshal(data, config) 67 | return config, err 68 | } 69 | 70 | func main() { 71 | // start the service 72 | os.Exit(run()) 73 | } 74 | -------------------------------------------------------------------------------- /docs/repository_configuration.md: -------------------------------------------------------------------------------- 1 | ## Repository configuration 2 | Repositories can be configured using environment variables. 3 | Find below the lists of available configuration properties per provider. 4 | 5 | ### Vault 6 | | Environment variable | Default value | Description 7 | | --- | --- | --- 8 | | AUTH_SERVER_VAULT_ADDR | localhost:8200 | The address of the Vault server 9 | | AUTH_SERVER_VAULT_TOKEN | | Vault token 10 | | AUTH_SERVER_VAULT_BASIC_KEY | secret/basic | Basic authentication secret key prefix 11 | | AUTH_SERVER_VAULT_AUTHORIZATION_KEY | secret/authorization | Authorization secret key prefix 12 | 13 | ### Aerospike 14 | | Environment variable | Default value | Description 15 | | --- | --- | --- 16 | | AUTH_SERVER_AEROSPIKE_HOST | localhost | The Aerospike cluster seed host 17 | | AUTH_SERVER_AEROSPIKE_PORT | 3000 | The Aerospike cluster seed port 18 | | AUTH_SERVER_AEROSPIKE_NAMESPACE | test | The name of the namespace containing auth details 19 | | AUTH_SERVER_AEROSPIKE_SETNAME | auth | The name of the set containing auth details 20 | | AUTH_SERVER_AEROSPIKE_BASIC_KEY | basic | The key of the record containing the basic authentication details 21 | | AUTH_SERVER_AEROSPIKE_AUTHORIZATION_KEY | authorization | The key of the record containing the authorization details 22 | 23 | ### Local 24 | | Environment variable | Default value | Description 25 | | --- | --- | --- 26 | | AUTH_SERVER_LOCAL_CONFIG_PATH | config/local_repository_config.yml | The path to the file with the local repository configuration 27 | -------------------------------------------------------------------------------- /internal/auth/jwt_generator.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/golang-jwt/jwt/v5" 7 | "github.com/reugn/auth-server/internal/repository" 8 | "github.com/reugn/auth-server/internal/util/env" 9 | ) 10 | 11 | const ( 12 | envTokenExpireAfterMillis = "AUTH_SERVER_ACCESS_TOKEN_EXPIRATION_MILLIS" 13 | ) 14 | 15 | // JWTGenerator generates an AccessToken. 16 | type JWTGenerator struct { 17 | keys *Keys 18 | signingMethod jwt.SigningMethod 19 | tokenExpireAfter time.Duration 20 | } 21 | 22 | // NewJWTGenerator returns a new instance of JWTGenerator. 23 | func NewJWTGenerator(keys *Keys, signingMethod jwt.SigningMethod) *JWTGenerator { 24 | tokenExpireAfter := time.Hour // default 1 hour 25 | env.ReadTime(&tokenExpireAfter, envTokenExpireAfterMillis, time.Millisecond) 26 | return &JWTGenerator{ 27 | keys: keys, 28 | signingMethod: signingMethod, 29 | tokenExpireAfter: tokenExpireAfter, 30 | } 31 | } 32 | 33 | // Generate generates an AccessToken using the username and role claims. 34 | func (gen *JWTGenerator) Generate(username string, role repository.UserRole) (*AccessToken, error) { 35 | token := jwt.New(gen.signingMethod) 36 | claims := Claims{} 37 | 38 | // set custom claims 39 | claims.Username = username 40 | claims.Role = role 41 | 42 | // set standard claims 43 | now := time.Now() 44 | claims.IssuedAt = jwt.NewNumericDate(now) 45 | if gen.tokenExpireAfter > 0 { 46 | claims.ExpiresAt = jwt.NewNumericDate(now.Add(gen.tokenExpireAfter)) 47 | } 48 | 49 | token.Claims = &claims 50 | signed, err := token.SignedString(gen.keys.privateKey) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | // create an access token 56 | accessToken := &AccessToken{ 57 | Token: signed, 58 | Type: BearerToken.String(), 59 | Expires: gen.tokenExpireAfter.Milliseconds(), 60 | } 61 | 62 | return accessToken, nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/config/http_server.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // HTTP contains HTTP server configuration properties. 9 | type HTTP struct { 10 | // The address to listen on. 11 | Host string `yaml:"host,omitempty" json:"host,omitempty"` 12 | // The port to listen on. 13 | Port int `yaml:"port,omitempty" json:"port,omitempty"` 14 | // Rate limiter configuration. 15 | Rate RateLimiter `yaml:"rate,omitempty" json:"rate,omitempty"` 16 | } 17 | 18 | // RateLimiter contains rate limiter configuration properties. 19 | type RateLimiter struct { 20 | // Rate limiter tokens per second threshold. 21 | Tps int `yaml:"tps,omitempty" json:"tps,omitempty"` 22 | // Rate limiter token bucket size (bursts threshold). 23 | Size int `yaml:"size,omitempty" json:"size,omitempty"` 24 | // A list of IP addresses to exclude from rate limiting. 25 | WhiteList []string `yaml:"white-list,omitempty" json:"white-list,omitempty"` 26 | } 27 | 28 | func (c *RateLimiter) validate() error { 29 | if c == nil { 30 | return errors.New("rate limiter config is nil") 31 | } 32 | if c.Tps < 1 { 33 | return fmt.Errorf("invalid rate tps: %d", c.Tps) 34 | } 35 | if c.Size < 1 { 36 | return fmt.Errorf("invalid rate size: %d", c.Size) 37 | } 38 | return nil 39 | } 40 | 41 | // NewHTTPDefault returns a new HTTP config with default values. 42 | func NewHTTPDefault() *HTTP { 43 | return &HTTP{ 44 | Host: "0.0.0.0", 45 | Port: 8080, 46 | Rate: RateLimiter{ 47 | Tps: 1024, 48 | Size: 1024, 49 | WhiteList: []string{}, 50 | }, 51 | } 52 | } 53 | 54 | // validate validates the HTTP configuration. 55 | func (c *HTTP) validate() error { 56 | if c == nil { 57 | return errors.New("http config is nil") 58 | } 59 | if c.Host == "" { 60 | return errors.New("host is not specified") 61 | } 62 | if c.Port < 1 { 63 | return fmt.Errorf("invalid port: %d", c.Port) 64 | } 65 | if err := c.Rate.validate(); err != nil { 66 | return err 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/auth/jwt_validator.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "time" 7 | 8 | "github.com/golang-jwt/jwt/v5" 9 | "github.com/reugn/auth-server/internal/repository" 10 | ) 11 | 12 | // JWTValidator validates and authorizes an AccessToken. 13 | type JWTValidator struct { 14 | keys *Keys 15 | backend repository.Repository 16 | } 17 | 18 | // NewJWTValidator returns a new JWTValidator. 19 | func NewJWTValidator(keys *Keys, backend repository.Repository) *JWTValidator { 20 | return &JWTValidator{ 21 | keys: keys, 22 | backend: backend, 23 | } 24 | } 25 | 26 | // validate validates the AccessToken. 27 | func (v *JWTValidator) validate(jtwToken string) (*Claims, error) { 28 | token, err := jwt.Parse(jtwToken, func(_ *jwt.Token) (interface{}, error) { 29 | return v.keys.publicKey, nil 30 | }) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return v.validateClaims(token) 36 | } 37 | 38 | func (v *JWTValidator) validateClaims(token *jwt.Token) (*Claims, error) { 39 | claims, err := getClaims(token) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | // validate expiration 45 | if claims.ExpiresAt.Before(time.Now()) { 46 | slog.Debug("Token expired") 47 | return nil, jwt.ErrTokenExpired 48 | } 49 | 50 | return claims, nil 51 | } 52 | 53 | func getClaims(token *jwt.Token) (*Claims, error) { 54 | mapClaims := token.Claims.(jwt.MapClaims) 55 | jsonClaims, err := json.Marshal(mapClaims) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | claims := Claims{} 61 | err = json.Unmarshal(jsonClaims, &claims) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | return &claims, nil 67 | } 68 | 69 | // Authorize validates the token and authorizes the actual request. 70 | func (v *JWTValidator) Authorize(token string, request *repository.RequestDetails) bool { 71 | claims, err := v.validate(token) 72 | if err != nil { 73 | slog.Debug("Failed to authorize token", "err", err) 74 | return false 75 | } 76 | 77 | return v.backend.AuthorizeRequest(claims.Role, *request) 78 | } 79 | -------------------------------------------------------------------------------- /internal/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strings" 7 | 8 | "golang.org/x/crypto/bcrypt" 9 | ) 10 | 11 | // UserRole represents a user role. 12 | type UserRole string 13 | 14 | // UserDetails represents user details. 15 | type UserDetails struct { 16 | UserName string 17 | UserRole UserRole 18 | } 19 | 20 | // RequestDetails represents request details. 21 | type RequestDetails struct { 22 | Method string `yaml:"method"` 23 | URI string `yaml:"uri"` 24 | } 25 | 26 | // String implements the fmt.Stringer interface. 27 | func (r RequestDetails) String() string { 28 | return fmt.Sprintf("%s %s", r.Method, r.URI) 29 | } 30 | 31 | // A Repository acts as a gateway to the authentication and authorization 32 | // operations, facilitating secure access to resources. 33 | type Repository interface { 34 | 35 | // AuthenticateBasic validates the basic username and password before issuing a JWT. 36 | AuthenticateBasic(username string, password string) *UserDetails 37 | 38 | // AuthorizeRequest checks if the role has permissions to access the endpoint. 39 | AuthorizeRequest(userRole UserRole, request RequestDetails) bool 40 | } 41 | 42 | func isAuthorizedRequest(scopes []map[string]string, request RequestDetails) bool { 43 | for _, scope := range scopes { 44 | if (scope["method"] == "*" || scope["method"] == request.Method) && 45 | (scope["uri"] == "*" || strings.HasPrefix(request.URI, scope["uri"])) { 46 | slog.Debug("Request authorized", "request", request) 47 | return true 48 | } 49 | } 50 | slog.Debug("Authorization failed for the request", "request", request) 51 | return false 52 | } 53 | 54 | func HashAndSalt(pwd string) ([]byte, error) { 55 | bytePwd := []byte(pwd) 56 | 57 | // use bcrypt.GenerateFromPassword to hash and salt the password 58 | hash, err := bcrypt.GenerateFromPassword(bytePwd, bcrypt.MinCost) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | return hash, nil 64 | } 65 | 66 | func pwdMatch(hashed string, plain string) bool { 67 | hashedBytes := []byte(hashed) 68 | plainBytes := []byte(plain) 69 | 70 | err := bcrypt.CompareHashAndPassword(hashedBytes, plainBytes) 71 | return err == nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/auth/keys.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/rsa" 5 | "errors" 6 | "os" 7 | 8 | "github.com/golang-jwt/jwt/v5" 9 | "github.com/reugn/auth-server/internal/config" 10 | ) 11 | 12 | // Keys represents a container for the private and public keys. 13 | type Keys struct { 14 | privateKey *rsa.PrivateKey 15 | publicKey *rsa.PublicKey 16 | } 17 | 18 | // NewKeys returns a new instance of Keys. 19 | func NewKeys(config *config.Secret) (*Keys, error) { 20 | return NewKeysFromFile(config.Private, config.Public) 21 | } 22 | 23 | // NewKeysFromFile creates and returns a new instance of Keys from the files 24 | // containing the secrets information. 25 | func NewKeysFromFile(privateKeyPath string, publicKeyPath string) (*Keys, error) { 26 | priv, err := parsePrivateKey(&privateKeyPath, nil) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | pub, err := parsePublicKey(&publicKeyPath, nil) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return &Keys{priv, pub}, nil 37 | } 38 | 39 | // NewKeysFromPem creates and returns a new instance of Keys from the pem byte arrays. 40 | func NewKeysFromPem(privatePem []byte, publicPem []byte) (*Keys, error) { 41 | priv, err := parsePrivateKey(nil, privatePem) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | pub, err := parsePublicKey(nil, publicPem) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return &Keys{priv, pub}, nil 52 | } 53 | 54 | func parsePrivateKey(privateKeyPath *string, pem []byte) (*rsa.PrivateKey, error) { 55 | if privateKeyPath != nil { 56 | pem, err := os.ReadFile(*privateKeyPath) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return jwt.ParseRSAPrivateKeyFromPEM(pem) 61 | } else if pem != nil { 62 | return jwt.ParseRSAPrivateKeyFromPEM(pem) 63 | } 64 | return nil, errors.New("parsePrivateKey nil parameters") 65 | } 66 | 67 | func parsePublicKey(publicKeyPath *string, pem []byte) (*rsa.PublicKey, error) { 68 | if publicKeyPath != nil { 69 | pem, err := os.ReadFile(*publicKeyPath) 70 | if err != nil { 71 | return nil, err 72 | } 73 | return jwt.ParseRSAPublicKeyFromPEM(pem) 74 | } else if pem != nil { 75 | return jwt.ParseRSAPublicKeyFromPEM(pem) 76 | } 77 | return nil, errors.New("parsePublicKey nil parameters") 78 | } 79 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/reugn/auth-server 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/aerospike/aerospike-client-go/v7 v7.1.0 7 | github.com/golang-jwt/jwt/v5 v5.2.0 8 | github.com/hashicorp/vault/api v1.11.0 9 | github.com/spf13/cobra v1.8.0 10 | golang.org/x/crypto v0.18.0 11 | golang.org/x/time v0.5.0 12 | gopkg.in/yaml.v3 v3.0.1 13 | ) 14 | 15 | require ( 16 | github.com/cenkalti/backoff/v3 v3.0.0 // indirect 17 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 18 | github.com/fatih/color v1.14.1 // indirect 19 | github.com/go-jose/go-jose/v3 v3.0.1 // indirect 20 | github.com/go-test/deep v1.0.7 // indirect 21 | github.com/golang/protobuf v1.5.3 // indirect 22 | github.com/hashicorp/errwrap v1.1.0 // indirect 23 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 24 | github.com/hashicorp/go-hclog v1.5.0 // indirect 25 | github.com/hashicorp/go-multierror v1.1.1 // indirect 26 | github.com/hashicorp/go-retryablehttp v0.6.7 // indirect 27 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 28 | github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect 29 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 30 | github.com/hashicorp/go-sockaddr v1.0.2 // indirect 31 | github.com/hashicorp/hcl v1.0.1-vault-3 // indirect 32 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 33 | github.com/kr/pretty v0.3.1 // indirect 34 | github.com/mattn/go-colorable v0.1.13 // indirect 35 | github.com/mattn/go-isatty v0.0.17 // indirect 36 | github.com/mitchellh/go-homedir v1.1.0 // indirect 37 | github.com/mitchellh/mapstructure v1.5.0 // indirect 38 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 39 | github.com/ryanuber/go-glob v1.0.0 // indirect 40 | github.com/spf13/pflag v1.0.5 // indirect 41 | github.com/yuin/gopher-lua v1.1.1 // indirect 42 | golang.org/x/net v0.19.0 // indirect 43 | golang.org/x/sync v0.5.0 // indirect 44 | golang.org/x/sys v0.16.0 // indirect 45 | golang.org/x/text v0.14.0 // indirect 46 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect 47 | google.golang.org/grpc v1.59.0 // indirect 48 | google.golang.org/protobuf v1.31.0 // indirect 49 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /internal/auth/jwt_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/golang-jwt/jwt/v5" 8 | "github.com/reugn/auth-server/internal/config" 9 | "github.com/reugn/auth-server/internal/repository" 10 | ) 11 | 12 | func TestJWT_Authorize(t *testing.T) { 13 | os.Setenv(repository.EnvLocalConfigPath, repository.DefaultLocalConfigPath) 14 | repo, err := repository.NewLocal() 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | secretConfig := &config.Secret{ 19 | Private: "../../secrets/privkey.pem", 20 | Public: "../../secrets/cert.pem", 21 | } 22 | keys, err := NewKeys(secretConfig) 23 | if err != nil { 24 | t.Skip("keys are not available") 25 | } 26 | tokenGenerator := NewJWTGenerator(keys, jwt.SigningMethodRS256) 27 | tokenValidator := NewJWTValidator(keys, repo) 28 | 29 | tests := []struct { 30 | name string 31 | username string 32 | password string 33 | request repository.RequestDetails 34 | authorized bool 35 | }{ 36 | { 37 | "configured-uri", 38 | "admin", 39 | "1234", 40 | repository.RequestDetails{ 41 | Method: "GET", 42 | URI: "/health", 43 | }, 44 | true, 45 | }, 46 | { 47 | "unknown-uri", 48 | "admin", 49 | "1234", 50 | repository.RequestDetails{ 51 | Method: "GET", 52 | URI: "/health2", 53 | }, 54 | false, 55 | }, 56 | { 57 | "invalid-user", 58 | "admin2", 59 | "1234", 60 | repository.RequestDetails{ 61 | Method: "GET", 62 | URI: "/health", 63 | }, 64 | false, 65 | }, 66 | { 67 | "invalid-password", 68 | "admin", 69 | "1111", 70 | repository.RequestDetails{ 71 | Method: "GET", 72 | URI: "/health", 73 | }, 74 | false, 75 | }, 76 | } 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | userDetails := repo.AuthenticateBasic(tt.username, tt.password) 80 | if userDetails == nil { 81 | if tt.authorized { 82 | t.Fatal("authentication failed") 83 | } else { 84 | return 85 | } 86 | } 87 | token, err := tokenGenerator.Generate(tt.username, userDetails.UserRole) 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | authorized := tokenValidator.Authorize(token.Token, &tt.request) 92 | if authorized != tt.authorized { 93 | t.Fatal("authorization result mismatch") 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/repository/local.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | 7 | "github.com/reugn/auth-server/internal/util/env" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | const ( 12 | EnvLocalConfigPath = "AUTH_SERVER_LOCAL_CONFIG_PATH" 13 | DefaultLocalConfigPath = "../../config/local_repository_config.yml" 14 | ) 15 | 16 | // AuthDetails contains authentication details for the user. 17 | type AuthDetails struct { 18 | Password string `yaml:"password"` 19 | Role UserRole `yaml:"role"` 20 | } 21 | 22 | // Local implements the Repository interface by loading authentication details from 23 | // a local configuration file. 24 | type Local struct { 25 | Users map[string]AuthDetails `yaml:"users"` 26 | Roles map[UserRole][]RequestDetails `yaml:"roles"` 27 | } 28 | 29 | var _ Repository = (*Local)(nil) 30 | 31 | // NewLocal returns a new Local repository using an environment variable to 32 | // read a custom path to the configuration file. 33 | func NewLocal() (*Local, error) { 34 | configPath := DefaultLocalConfigPath 35 | env.ReadString(&configPath, EnvLocalConfigPath) 36 | 37 | data, err := os.ReadFile(configPath) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | localRepository := &Local{} 43 | if err = yaml.Unmarshal(data, localRepository); err != nil { 44 | return nil, err 45 | } 46 | 47 | return localRepository, nil 48 | } 49 | 50 | // AuthenticateBasic validates the basic username and password before issuing a JWT. 51 | func (local *Local) AuthenticateBasic(username string, password string) *UserDetails { 52 | if authDetails, ok := local.Users[username]; ok { 53 | if authDetails.Password == password { 54 | return &UserDetails{ 55 | UserName: username, 56 | UserRole: authDetails.Role, 57 | } 58 | } 59 | } 60 | return nil 61 | } 62 | 63 | // AuthorizeRequest checks if the role has permissions to access the endpoint. 64 | func (local *Local) AuthorizeRequest(userRole UserRole, requestDetails RequestDetails) bool { 65 | if permissions, ok := local.Roles[userRole]; ok { 66 | if containsRequestDetails(permissions, requestDetails) { 67 | slog.Debug("Request authorized", "request", requestDetails) 68 | return true 69 | } 70 | } 71 | slog.Debug("Authorization failed for the request", "request", requestDetails) 72 | return false 73 | } 74 | 75 | func containsRequestDetails(details []RequestDetails, requestDetails RequestDetails) bool { 76 | for _, detail := range details { 77 | if detail == requestDetails { 78 | return true 79 | } 80 | } 81 | return false 82 | } 83 | -------------------------------------------------------------------------------- /internal/config/logger.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "slices" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | logLevelDebug = "DEBUG" 14 | logLevelInfo = "INFO" 15 | logLevelWarn = "WARN" 16 | logLevelWarning = "WARNING" 17 | logLevelError = "ERROR" 18 | 19 | logFormatPlain = "PLAIN" 20 | logFormatJSON = "JSON" 21 | ) 22 | 23 | // Logger contains the service logger configuration properties. 24 | type Logger struct { 25 | // Level is the log level (DEBUG, INFO, WARN, WARNING, ERROR). 26 | Level string `yaml:"level,omitempty" json:"level,omitempty"` 27 | // Format is the log format (PLAIN, JSON). 28 | Format string `yaml:"format,omitempty" json:"format,omitempty"` 29 | } 30 | 31 | // NewLoggerDefault returns a new Logger with default values. 32 | func NewLoggerDefault() *Logger { 33 | return &Logger{ 34 | Level: logLevelInfo, 35 | Format: logFormatPlain, 36 | } 37 | } 38 | 39 | var ( 40 | validLoggerLevels = []string{logLevelDebug, logLevelInfo, logLevelWarn, 41 | logLevelWarning, logLevelError} 42 | supportedLoggerFormats = []string{logFormatPlain, logFormatJSON} 43 | ) 44 | 45 | func (l *Logger) SlogHandler() (slog.Handler, error) { 46 | if err := l.validate(); err != nil { 47 | return nil, err 48 | } 49 | logLevel, err := l.logLevel() 50 | if err != nil { 51 | return nil, err 52 | } 53 | addSource := true 54 | writer := os.Stdout 55 | switch strings.ToUpper(l.Format) { 56 | case logFormatPlain: 57 | return slog.NewTextHandler(writer, &slog.HandlerOptions{ 58 | Level: logLevel, 59 | AddSource: addSource, 60 | }), nil 61 | case logFormatJSON: 62 | return slog.NewJSONHandler(writer, &slog.HandlerOptions{ 63 | Level: logLevel, 64 | AddSource: addSource, 65 | }), nil 66 | default: 67 | return nil, fmt.Errorf("unsupported log format: %s", l.Format) 68 | } 69 | } 70 | 71 | // validate validates the logger configuration properties. 72 | func (l *Logger) validate() error { 73 | if l == nil { 74 | return errors.New("logger config is nil") 75 | } 76 | if !slices.Contains(validLoggerLevels, strings.ToUpper(l.Level)) { 77 | return fmt.Errorf("unsupported log level: %s", l.Level) 78 | } 79 | if !slices.Contains(supportedLoggerFormats, strings.ToUpper(l.Format)) { 80 | return fmt.Errorf("unsupported log format: %s", l.Format) 81 | } 82 | return nil 83 | } 84 | 85 | // logLevel returns the log level. 86 | func (l *Logger) logLevel() (slog.Level, error) { 87 | switch strings.ToUpper(l.Level) { 88 | case logLevelDebug: 89 | return slog.LevelDebug, nil 90 | case logLevelInfo: 91 | return slog.LevelInfo, nil 92 | case logLevelWarn, logLevelWarning: 93 | return slog.LevelWarn, nil 94 | case logLevelError: 95 | return slog.LevelError, nil 96 | default: 97 | return 0, fmt.Errorf("invalid log level: %s", l.Level) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /internal/http/rate_limiter.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "log/slog" 5 | "net/netip" 6 | "strings" 7 | "sync" 8 | 9 | "golang.org/x/time/rate" 10 | ) 11 | 12 | // IPWhiteList contains white list information for rate limiting. 13 | type IPWhiteList struct { 14 | addresses map[string]*netip.Addr 15 | networks []*netip.Prefix 16 | allowAny bool 17 | } 18 | 19 | // NewIPWhiteList builds a new IPWhiteList from the list of IPs. 20 | func NewIPWhiteList(ipList []string) (*IPWhiteList, error) { 21 | addresses := make(map[string]*netip.Addr) 22 | networks := make([]*netip.Prefix, 0) 23 | var allowAny bool 24 | for _, ip := range ipList { 25 | ip := strings.TrimSpace(ip) 26 | if ip == "" { 27 | continue 28 | } 29 | if strings.HasPrefix(ip, "0.0.0.0") { 30 | allowAny = true 31 | } 32 | network, err := netip.ParsePrefix(ip) 33 | if err != nil { 34 | ipAddr, err := netip.ParseAddr(ip) 35 | if err != nil { 36 | return nil, err 37 | } 38 | addresses[ip] = &ipAddr 39 | } else { 40 | networks = append(networks, &network) 41 | } 42 | } 43 | return &IPWhiteList{ 44 | addresses: addresses, 45 | networks: networks, 46 | allowAny: allowAny, 47 | }, nil 48 | } 49 | 50 | func (wl *IPWhiteList) isAllowed(ip string) bool { 51 | if wl.allowAny { 52 | return true 53 | } 54 | ipAddr, err := netip.ParseAddr(ip) 55 | if err != nil { 56 | slog.Warn("Invalid client ip", "ip", ip, "err", err) 57 | return false 58 | } 59 | _, ok := wl.addresses[ip] 60 | if ok { 61 | return true 62 | } 63 | 64 | for _, network := range wl.networks { 65 | if network.Contains(ipAddr) { 66 | return true 67 | } 68 | } 69 | 70 | return false 71 | } 72 | 73 | // IPAddress represents an IP address string. 74 | type IPAddress string 75 | 76 | // IPRateLimiter represents a rate limiter based on an IP address. 77 | type IPRateLimiter struct { 78 | sync.Mutex 79 | limiters map[IPAddress]*rate.Limiter 80 | tokensPerSecond rate.Limit 81 | tokenBucketSize int 82 | } 83 | 84 | // NewIPRateLimiter returns a new IPRateLimiter. 85 | func NewIPRateLimiter(tps rate.Limit, size int) *IPRateLimiter { 86 | ipLimiter := &IPRateLimiter{ 87 | limiters: make(map[IPAddress]*rate.Limiter), 88 | tokensPerSecond: tps, 89 | tokenBucketSize: size, 90 | } 91 | 92 | return ipLimiter 93 | } 94 | 95 | // AddLimiter creates a new rate limiter and adds it to the limiters map, 96 | // using the IP address as the key. 97 | func (ipLimiter *IPRateLimiter) AddLimiter(ipAddr string) *rate.Limiter { 98 | ipLimiter.Lock() 99 | defer ipLimiter.Unlock() 100 | 101 | limiter := rate.NewLimiter(ipLimiter.tokensPerSecond, ipLimiter.tokenBucketSize) 102 | ipLimiter.limiters[IPAddress(ipAddr)] = limiter 103 | 104 | return limiter 105 | } 106 | 107 | // GetLimiter returns the rate limiter for the provided IP address if it exists. 108 | // Otherwise calls AddLimiter to add a new limiter to the map. 109 | func (ipLimiter *IPRateLimiter) GetLimiter(ipAddr string) *rate.Limiter { 110 | ipLimiter.Lock() 111 | limiter, exists := ipLimiter.limiters[IPAddress(ipAddr)] 112 | 113 | if !exists { 114 | ipLimiter.Unlock() 115 | return ipLimiter.AddLimiter(ipAddr) 116 | } 117 | 118 | ipLimiter.Unlock() 119 | 120 | return limiter 121 | } 122 | -------------------------------------------------------------------------------- /internal/repository/vault.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/hashicorp/vault/api" 8 | "github.com/reugn/auth-server/internal/util/env" 9 | ) 10 | 11 | // Environment variables to configure VaultRepository. 12 | const ( 13 | envVaultAddr = "AUTH_SERVER_VAULT_ADDR" 14 | envVaultToken = "AUTH_SERVER_VAULT_TOKEN" 15 | envVaultBasicKey = "AUTH_SERVER_VAULT_BASIC_KEY" 16 | envVaultAuthKey = "AUTH_SERVER_VAULT_AUTHORIZATION_KEY" 17 | ) 18 | 19 | // vaultConfig contains VaultRepository configuration properties. 20 | type vaultConfig struct { 21 | vaultAddr string 22 | vaultToken string 23 | basicAuthKeyPrefix string 24 | authorizationKeyPrefix string 25 | } 26 | 27 | // VaultRepository implements the Repository interface using HashiCorp Vault 28 | // as the storage backend. 29 | type VaultRepository struct { 30 | client *api.Client 31 | config vaultConfig 32 | } 33 | 34 | var _ Repository = (*VaultRepository)(nil) 35 | 36 | func getVaultConfig() vaultConfig { 37 | // set defaults 38 | config := vaultConfig{ 39 | vaultAddr: "localhost:8200", 40 | basicAuthKeyPrefix: "secret/basic", 41 | authorizationKeyPrefix: "secret/authorization", 42 | } 43 | 44 | // read configuration from environment variables 45 | env.ReadString(&config.vaultAddr, envVaultAddr) 46 | env.ReadString(&config.vaultToken, envVaultToken) 47 | env.ReadString(&config.basicAuthKeyPrefix, envVaultBasicKey) 48 | env.ReadString(&config.authorizationKeyPrefix, envVaultAuthKey) 49 | 50 | return config 51 | } 52 | 53 | // NewVault returns a new VaultRepository using environment variables for configuration. 54 | func NewVault() (*VaultRepository, error) { 55 | config := getVaultConfig() // read configuration 56 | apiConfig := &api.Config{ 57 | Address: config.vaultAddr, 58 | } 59 | client, err := api.NewClient(apiConfig) 60 | if err != nil { 61 | return nil, err 62 | } 63 | client.SetToken(config.vaultToken) 64 | 65 | return &VaultRepository{ 66 | client: client, 67 | config: config, 68 | }, nil 69 | } 70 | 71 | // AuthenticateBasic validates the basic username and password before issuing a JWT. 72 | // It uses the bcrypt password-hashing function to validate the password. 73 | func (vr *VaultRepository) AuthenticateBasic(username string, password string) *UserDetails { 74 | path := fmt.Sprintf("%s/%s", vr.config.basicAuthKeyPrefix, username) 75 | secret, err := vr.client.Logical().Read(path) 76 | if err != nil { 77 | slog.Error("Failed to read path", "path", path, "err", err) 78 | return nil 79 | } 80 | 81 | hashed, ok := secret.Data["password"].(string) 82 | if !ok || !pwdMatch(hashed, password) { 83 | slog.Debug("Failed to authenticate", "user", username) 84 | return nil 85 | } 86 | 87 | return &UserDetails{ 88 | UserName: username, 89 | UserRole: secret.Data["role"].(UserRole), 90 | } 91 | } 92 | 93 | // AuthorizeRequest checks if the role has permissions to access the endpoint. 94 | func (vr *VaultRepository) AuthorizeRequest(userRole UserRole, request RequestDetails) bool { 95 | path := fmt.Sprintf("%s/%s", vr.config.authorizationKeyPrefix, userRole) 96 | secret, err := vr.client.Logical().Read(path) 97 | if err != nil { 98 | slog.Error("Failed to read path", "path", path, "err", err) 99 | return false 100 | } 101 | 102 | scopes, ok := secret.Data["scopes"].([]map[string]string) 103 | if !ok { 104 | slog.Error("Error reading scopes", "role", userRole) 105 | return false 106 | } 107 | 108 | return isAuthorizedRequest(scopes, request) 109 | } 110 | -------------------------------------------------------------------------------- /internal/config/service.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/golang-jwt/jwt/v5" 11 | "github.com/reugn/auth-server/internal/proxy" 12 | "github.com/reugn/auth-server/internal/repository" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | const ( 17 | signingMethodRS256 = "RS256" 18 | signingMethodRS384 = "RS384" 19 | signingMethodRS512 = "RS512" 20 | ) 21 | 22 | var validSigningMethods = []string{signingMethodRS256, signingMethodRS384, signingMethodRS512} 23 | 24 | // Service contains the entire service configuration. 25 | type Service struct { 26 | SigningMethod string `yaml:"signing-method,omitempty" json:"signing-method,omitempty"` 27 | ProxyProvider string `yaml:"proxy,omitempty" json:"proxy,omitempty"` 28 | RepositoryProvider string `yaml:"repository,omitempty" json:"repository,omitempty"` 29 | HTTP *HTTP `yaml:"http,omitempty" json:"http,omitempty"` 30 | Secret *Secret `yaml:"secret,omitempty" json:"secret,omitempty"` 31 | Logger *Logger `yaml:"logger,omitempty" json:"logger,omitempty"` 32 | } 33 | 34 | // NewServiceDefault returns a new Service config with default values. 35 | func NewServiceDefault() *Service { 36 | return &Service{ 37 | SigningMethod: signingMethodRS256, 38 | ProxyProvider: "simple", 39 | RepositoryProvider: "local", 40 | HTTP: NewHTTPDefault(), 41 | Secret: NewSecretDefault(), 42 | Logger: NewLoggerDefault(), 43 | } 44 | } 45 | 46 | func (c *Service) SigningMethodRSA() (*jwt.SigningMethodRSA, error) { 47 | var signingMethodRSA *jwt.SigningMethodRSA 48 | switch strings.ToUpper(c.SigningMethod) { 49 | case signingMethodRS256: 50 | signingMethodRSA = jwt.SigningMethodRS256 51 | case signingMethodRS384: 52 | signingMethodRSA = jwt.SigningMethodRS384 53 | case signingMethodRS512: 54 | signingMethodRSA = jwt.SigningMethodRS512 55 | default: 56 | return nil, fmt.Errorf("unsupported signing method: %s", c.SigningMethod) 57 | } 58 | return signingMethodRSA, nil 59 | } 60 | 61 | func (c *Service) RequestParser() (proxy.RequestParser, error) { 62 | var parser proxy.RequestParser 63 | switch strings.ToLower(c.ProxyProvider) { 64 | case "simple": 65 | parser = proxy.NewSimpleParser() 66 | case "traefik": 67 | parser = proxy.NewTraefikParser() 68 | default: 69 | return nil, fmt.Errorf("unsupported proxy provider: %s", c.ProxyProvider) 70 | } 71 | return parser, nil 72 | } 73 | 74 | func (c *Service) Repository() (repository.Repository, error) { 75 | switch strings.ToLower(c.RepositoryProvider) { 76 | case "local": 77 | return repository.NewLocal() 78 | case "aerospike": 79 | return repository.NewAerospike() 80 | case "vault": 81 | return repository.NewVault() 82 | default: 83 | return nil, fmt.Errorf("unsupported storage provider: %s", c.RepositoryProvider) 84 | } 85 | } 86 | 87 | // Validate validates the service configuration. 88 | func (c *Service) Validate() error { 89 | if c == nil { 90 | return errors.New("service config is nil") 91 | } 92 | if !slices.Contains(validSigningMethods, strings.ToUpper(c.SigningMethod)) { 93 | return fmt.Errorf("invalid signing method: %s", c.SigningMethod) 94 | } 95 | if c.ProxyProvider == "" { 96 | return errors.New("proxy provider is not specified") 97 | } 98 | if c.RepositoryProvider == "" { 99 | return errors.New("repository provider is not specified") 100 | } 101 | if err := c.HTTP.validate(); err != nil { 102 | return err 103 | } 104 | if err := c.Secret.validate(); err != nil { 105 | return err 106 | } 107 | if err := c.Logger.validate(); err != nil { 108 | return err 109 | } 110 | return nil 111 | } 112 | 113 | // String returns a string representation of the service configuration in JSON format. 114 | func (c *Service) String() string { 115 | data, err := json.Marshal(c) 116 | if err != nil { 117 | return err.Error() 118 | } 119 | return string(data) 120 | } 121 | 122 | // StringYaml returns a string representation of the service configuration in YAML format. 123 | func (c *Service) StringYaml() string { 124 | data, err := yaml.Marshal(c) 125 | if err != nil { 126 | return err.Error() 127 | } 128 | return string(data) 129 | } 130 | -------------------------------------------------------------------------------- /internal/repository/aerospike.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "log/slog" 5 | 6 | as "github.com/aerospike/aerospike-client-go/v7" 7 | "github.com/reugn/auth-server/internal/util/env" 8 | ) 9 | 10 | // Environment variables to configure AerospikeRepository. 11 | const ( 12 | envAerospikeHost = "AUTH_SERVER_AEROSPIKE_HOST" 13 | envAerospikePort = "AUTH_SERVER_AEROSPIKE_PORT" 14 | envAerospikeNamespace = "AUTH_SERVER_AEROSPIKE_NAMESPACE" 15 | envAerospikeSet = "AUTH_SERVER_AEROSPIKE_SETNAME" 16 | envAerospikeBasicKey = "AUTH_SERVER_AEROSPIKE_BASIC_KEY" 17 | envAerospikeAuthKey = "AUTH_SERVER_AEROSPIKE_AUTHORIZATION_KEY" 18 | ) 19 | 20 | // aerospikeConfig contains AerospikeRepository configuration properties. 21 | type aerospikeConfig struct { 22 | hostname string 23 | port int 24 | namespase string 25 | setName string 26 | basicAuthKey string 27 | authorizationKey string 28 | } 29 | 30 | // AerospikeRepository implements the Repository interface using Aerospike Database 31 | // as the storage backend. 32 | type AerospikeRepository struct { 33 | client *as.Client 34 | config aerospikeConfig 35 | baseKey *as.Key 36 | authKey *as.Key 37 | } 38 | 39 | var _ Repository = (*AerospikeRepository)(nil) 40 | 41 | func getAerospikeConfig() aerospikeConfig { 42 | // set defaults 43 | config := aerospikeConfig{ 44 | hostname: "localhost", 45 | port: 3000, 46 | namespase: "test", 47 | setName: "auth", 48 | basicAuthKey: "basic", 49 | authorizationKey: "authorization", 50 | } 51 | 52 | // read configuration from environment variables 53 | env.ReadString(&config.hostname, envAerospikeHost) 54 | env.ReadInt(&config.port, envAerospikePort) 55 | env.ReadString(&config.namespase, envAerospikeNamespace) 56 | env.ReadString(&config.setName, envAerospikeSet) 57 | env.ReadString(&config.basicAuthKey, envAerospikeBasicKey) 58 | env.ReadString(&config.authorizationKey, envAerospikeAuthKey) 59 | 60 | return config 61 | } 62 | 63 | // NewAerospike returns a new AerospikeRepository using environment variables for configuration. 64 | func NewAerospike() (*AerospikeRepository, error) { 65 | config := getAerospikeConfig() // read configuration 66 | client, err := as.NewClient(config.hostname, config.port) 67 | if err != nil { 68 | return nil, err 69 | } 70 | baseKey, err := as.NewKey(config.namespase, config.setName, config.basicAuthKey) 71 | if err != nil { 72 | return nil, err 73 | } 74 | authKey, err := as.NewKey(config.namespase, config.setName, config.authorizationKey) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | return &AerospikeRepository{ 80 | client: client, 81 | config: config, 82 | baseKey: baseKey, 83 | authKey: authKey, 84 | }, nil 85 | } 86 | 87 | // AuthenticateBasic validates the basic username and password before issuing a JWT. 88 | // It uses the bcrypt password-hashing function to validate the password. 89 | func (aero *AerospikeRepository) AuthenticateBasic(username string, password string) *UserDetails { 90 | record, err := aero.client.Get(nil, aero.baseKey, username) 91 | if err != nil { 92 | slog.Error("Failed to fetch record", "key", aero.baseKey, "err", err) 93 | return nil 94 | } 95 | 96 | // Bin(user1: {username: user1, password: sha256, role: admin}) 97 | userBin := record.Bins[username].(map[string]interface{}) 98 | hashed, ok := userBin["password"].(string) 99 | if !ok || !pwdMatch(hashed, password) { 100 | slog.Debug("Failed to authenticate", "user", username) 101 | return nil 102 | } 103 | 104 | return &UserDetails{ 105 | UserName: username, 106 | UserRole: userBin["role"].(UserRole), 107 | } 108 | } 109 | 110 | // AuthorizeRequest checks if the role has permissions to access the endpoint. 111 | func (aero *AerospikeRepository) AuthorizeRequest(userRole UserRole, request RequestDetails) bool { 112 | record, err := aero.client.Get(nil, aero.authKey, string(userRole)) 113 | if err != nil { 114 | slog.Error("Failed to fetch record", "key", aero.authKey, "err", err) 115 | return false 116 | } 117 | // Bin(admin: [{method: GET, uri: /health}]) 118 | scopes := record.Bins[string(userRole)].([]map[string]string) 119 | 120 | return isAuthorizedRequest(scopes, request) 121 | } 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # auth-server 2 | [![Build](https://github.com/reugn/auth-server/actions/workflows/build.yml/badge.svg)](https://github.com/reugn/auth-server/actions/workflows/build.yml) 3 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/reugn/auth-server)](https://pkg.go.dev/github.com/reugn/auth-server) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/reugn/auth-server)](https://goreportcard.com/report/github.com/reugn/auth-server) 5 | 6 | This project offers a toolkit for building and configuring a tailored authentication and authorization service. 7 | 8 | `auth-server` can act as a proxy middleware or be configured in a stand-alone mode. It doesn't require any third-party software integration. 9 | Leverage existing backend [storage repositories](internal/repository) for storing security policies or develop a custom one to suit your specific requirements. 10 | For information on how to configure repositories using environment variables, refer to the [repository configuration](docs/repository_configuration.md) page. 11 | 12 | > [!NOTE] 13 | > This project's security has not been thoroughly evaluated. Proceed with caution when setting up your own auth provider. 14 | 15 | ## Introduction 16 | * **Authentication** is used by a server when the server needs to know exactly who is accessing their information or site. 17 | * **Authorization** is a process by which a server determines if the client has permission to use a resource or access a file. 18 | 19 | The inherent complexity of crafting an authentication and authorization strategy raises a barrage of immediate questions: 20 | 21 | * Would it be beneficial to utilize separate services for authentication and authorization purposes? 22 | * What is the process for creating access tokens, and who is tasked with this responsibility? 23 | * Is it necessary to adapt our REST service to support an authorization flow? 24 | 25 | The `auth-server` project aims to address these concerns by serving as a transparent authentication and authorization proxy middleware. 26 | 27 | ## Architecture 28 | ![architecture_diagram](docs/images/architecture_diagram_1.png) 29 | 30 | 1. The user requests an access token (JWT), using a basic authentication header: 31 | ``` 32 | GET /token HTTP/1.1 33 | Host: localhost:8081 34 | Authorization: Basic YWRtaW46MTIzNA== 35 | ``` 36 | 37 | 2. The proxy server routes this request to `auth-server` to issue a token. 38 | Response body: 39 | `{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODg5MzMyNTIsImlhdCI6MTU4ODkyOTY1MiwidXNlciI6ImFkbWluIiwicm9sZSI6MX0.LUx9EYsfBZGwbEsofBTT_5Lo3Y_3lk7T8pWLv3bw-XKVOqb_GhaRkVE90QR_sI-bWTkYCFIG9cPYmMXzmPLyjbofgsqTOzH6OaXi3IqxwZRtRGFtuqMoqXkakX5n38mvI3XkIOwFkNosHrpMtIq-HdqB3tfiDJc3YMsYfPbqyRBnBYJu2K51NslGQSiqKSnS_4KeLeaqqdpC7Zdb9Fo-r7EMn3FFuyPEab1iBsrcUYG3qnsKkvDhaq_jEGHflao7dEPEWaiGvJywXWaKR6XyyGtVx0H-OPfgvh1vUCLUUci2K3xE-IxjfRrHx3dSzdqFgJq_n4bVXpO9iNVYOZLccQ","token_type":"Bearer","expires_in":3600000}` 40 | 41 | 3. The user sends an authenticated request to the proxy server: 42 | ``` 43 | GET /foo HTTP/1.1 44 | Host: localhost:8081 45 | Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODg5MzMyNTIsImlhdCI6MTU4ODkyOTY1MiwidXNlciI6ImFkbWluIiwicm9sZSI6MX0.LUx9EYsfBZGwbEsofBTT_5Lo3Y_3lk7T8pWLv3bw-XKVOqb_GhaRkVE90QR_sI-bWTkYCFIG9cPYmMXzmPLyjbofgsqTOzH6OaXi3IqxwZRtRGFtuqMoqXkakX5n38mvI3XkIOwFkNosHrpMtIq-HdqB3tfiDJc3YMsYfPbqyRBnBYJu2K51NslGQSiqKSnS_4KeLeaqqdpC7Zdb9Fo-r7EMn3FFuyPEab1iBsrcUYG3qnsKkvDhaq_jEGHflao7dEPEWaiGvJywXWaKR6XyyGtVx0H-OPfgvh1vUCLUUci2K3xE-IxjfRrHx3dSzdqFgJq_n4bVXpO9iNVYOZLccQ 46 | ``` 47 | 48 | 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. 49 | 50 | ## Installation and Prerequisites 51 | * `auth-server` is written in Golang. 52 | To install the latest stable version of Go, visit the [releases page](https://golang.org/dl/). 53 | 54 | * 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). 55 | 56 | * The following example shows how to run the service using a configuration file: 57 | ``` 58 | ./auth -c service_config.yml 59 | ``` 60 | 61 | * 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). 62 | 63 | * Install `docker-compose` to get started with the examples. 64 | 65 | ## Examples 66 | Examples are available under the [examples](examples) folder. 67 | 68 | To run `auth-server` as a [Traefik](https://docs.traefik.io/) middleware: 69 | ``` 70 | cd examples/traefik 71 | docker-compose up -d 72 | ``` 73 | 74 | ## License 75 | Licensed under the Apache 2.0 License. 76 | -------------------------------------------------------------------------------- /internal/http/server.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net" 7 | "net/http" 8 | 9 | "github.com/reugn/auth-server/internal/auth" 10 | "github.com/reugn/auth-server/internal/config" 11 | "github.com/reugn/auth-server/internal/proxy" 12 | "github.com/reugn/auth-server/internal/repository" 13 | "golang.org/x/time/rate" 14 | ) 15 | 16 | // Server represents the entry point to interact with the service via HTTP requests. 17 | type Server struct { 18 | address string 19 | version string 20 | parser proxy.RequestParser 21 | repository repository.Repository 22 | rateLimiter *IPRateLimiter 23 | ipWhiteList *IPWhiteList 24 | jwtGenerator *auth.JWTGenerator 25 | jwtValidator *auth.JWTValidator 26 | } 27 | 28 | // NewServer returns a new instance of Server. 29 | func NewServer(version string, keys *auth.Keys, config *config.Service) (*Server, error) { 30 | address := fmt.Sprintf("%s:%d", config.HTTP.Host, config.HTTP.Port) 31 | repository, err := config.Repository() 32 | if err != nil { 33 | return nil, err 34 | } 35 | signingMethod, err := config.SigningMethodRSA() 36 | if err != nil { 37 | return nil, err 38 | } 39 | generator := auth.NewJWTGenerator(keys, signingMethod) 40 | validator := auth.NewJWTValidator(keys, repository) 41 | 42 | requestParser, err := config.RequestParser() 43 | if err != nil { 44 | return nil, err 45 | } 46 | ipWhiteList, err := NewIPWhiteList(config.HTTP.Rate.WhiteList) 47 | if err != nil { 48 | return nil, err 49 | } 50 | return &Server{ 51 | address: address, 52 | version: version, 53 | parser: requestParser, 54 | repository: repository, 55 | rateLimiter: NewIPRateLimiter(rate.Limit(config.HTTP.Rate.Tps), config.HTTP.Rate.Size), 56 | ipWhiteList: ipWhiteList, 57 | jwtGenerator: generator, 58 | jwtValidator: validator, 59 | }, nil 60 | } 61 | 62 | func (ws *Server) rateLimiterMiddleware(next http.Handler) http.Handler { 63 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 64 | ip, _, err := net.SplitHostPort(r.RemoteAddr) 65 | if err != nil { 66 | http.Error(w, http.StatusText(http.StatusInternalServerError), 67 | http.StatusInternalServerError) 68 | return 69 | } 70 | if !ws.ipWhiteList.isAllowed(ip) { 71 | limiter := ws.rateLimiter.GetLimiter(ip) 72 | if !limiter.Allow() { 73 | http.Error(w, http.StatusText(http.StatusTooManyRequests), 74 | http.StatusTooManyRequests) 75 | return 76 | } 77 | } 78 | next.ServeHTTP(w, r) 79 | }) 80 | } 81 | 82 | // Start initiates the HTTP server. 83 | func (ws *Server) Start() error { 84 | mux := http.NewServeMux() 85 | 86 | // root route 87 | mux.HandleFunc("/", rootActionHandler) 88 | 89 | // health route 90 | mux.HandleFunc("/health", healthActionHandler) 91 | 92 | // readiness route 93 | mux.HandleFunc("/ready", readyActionHandler) 94 | 95 | // version route 96 | mux.HandleFunc("/version", ws.versionActionHandler) 97 | 98 | // token issuing route, requires basic authentication 99 | mux.HandleFunc("/token", ws.tokenActionHandler) 100 | 101 | // authorization route, requires a JSON Web Token 102 | mux.HandleFunc("/auth", ws.authActionHandler) 103 | 104 | return http.ListenAndServe(ws.address, ws.rateLimiterMiddleware(mux)) 105 | } 106 | 107 | func rootActionHandler(w http.ResponseWriter, r *http.Request) { 108 | if r.URL.Path != "/" { 109 | w.WriteHeader(http.StatusNotFound) 110 | } 111 | fmt.Fprintf(w, "") 112 | } 113 | 114 | func healthActionHandler(w http.ResponseWriter, _ *http.Request) { 115 | fmt.Fprintf(w, "Ok") 116 | } 117 | 118 | func readyActionHandler(w http.ResponseWriter, _ *http.Request) { 119 | fmt.Fprintf(w, "Ok") 120 | } 121 | 122 | func (ws *Server) versionActionHandler(w http.ResponseWriter, _ *http.Request) { 123 | fmt.Fprint(w, ws.version) 124 | } 125 | 126 | func (ws *Server) tokenActionHandler(w http.ResponseWriter, r *http.Request) { 127 | slog.Debug("Token generation request") 128 | user, pass, ok := r.BasicAuth() 129 | if !ok { 130 | w.WriteHeader(http.StatusBadRequest) 131 | return 132 | } 133 | userDetails := ws.repository.AuthenticateBasic(user, pass) 134 | if userDetails == nil { 135 | w.WriteHeader(http.StatusUnauthorized) 136 | return 137 | } 138 | accessToken, err := ws.jwtGenerator.Generate(userDetails.UserName, userDetails.UserRole) 139 | if err != nil { 140 | w.WriteHeader(http.StatusInternalServerError) 141 | return 142 | } 143 | marshalled, err := accessToken.Marshal() 144 | if err != nil { 145 | w.WriteHeader(http.StatusInternalServerError) 146 | return 147 | } 148 | fmt.Fprintf(w, "%s", marshalled) 149 | } 150 | 151 | func (ws *Server) authActionHandler(w http.ResponseWriter, r *http.Request) { 152 | slog.Debug("Token authorization request") 153 | requestDetails := ws.parser.ParseRequestDetails(r) 154 | authToken := ws.parser.ParseAuthorizationToken(r) 155 | 156 | if !ws.jwtValidator.Authorize(authToken, requestDetails) { 157 | w.WriteHeader(http.StatusUnauthorized) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aerospike/aerospike-client-go/v7 v7.1.0 h1:yvCTKdbpqZxHvv7sWsFHV1j49jZcC8yXRooWsDFqKtA= 2 | github.com/aerospike/aerospike-client-go/v7 v7.1.0/go.mod h1:AkHiKvCbqa1c16gCNGju3c5X/yzwLVvblNczqjxNwNk= 3 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 4 | github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 5 | github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c= 6 | github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 8 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 12 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 14 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 15 | github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= 16 | github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= 17 | github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= 18 | github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= 19 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 20 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 21 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 22 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 23 | github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M= 24 | github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= 25 | github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= 26 | github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 27 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 28 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 29 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 30 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 31 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 32 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 33 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 34 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= 35 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 36 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 37 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 38 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 39 | github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 40 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 41 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 42 | github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 43 | github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= 44 | github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 45 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 46 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 47 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 48 | github.com/hashicorp/go-retryablehttp v0.6.7 h1:8/CAEZt/+F7kR7GevNHulKkUjLht3CPmn7egmhieNKo= 49 | github.com/hashicorp/go-retryablehttp v0.6.7/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= 50 | github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= 51 | github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= 52 | github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= 53 | github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= 54 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= 55 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= 56 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 57 | github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= 58 | github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= 59 | github.com/hashicorp/hcl v1.0.1-vault-3 h1:V95v5KSTu6DB5huDSKiq4uAfILEuNigK/+qPET6H/Mg= 60 | github.com/hashicorp/hcl v1.0.1-vault-3/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= 61 | github.com/hashicorp/vault/api v1.11.0 h1:AChWByeHf4/P9sX3Y1B7vFsQhZO2BgQiCMQ2SA1P1UY= 62 | github.com/hashicorp/vault/api v1.11.0/go.mod h1:si+lJCYO7oGkIoNPAN8j3azBLTn9SjMGS+jFaHd1Cck= 63 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 64 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 65 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 66 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 67 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 68 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 69 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 70 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 71 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 72 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 73 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 74 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 75 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 76 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 77 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 78 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 79 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 80 | github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= 81 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 82 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 83 | github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 84 | github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 85 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 86 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 87 | github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= 88 | github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= 89 | github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= 90 | github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= 91 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 92 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 93 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 94 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 95 | github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= 96 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 97 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 98 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 99 | github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= 100 | github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 101 | github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 102 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 103 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 104 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 105 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 106 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 107 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 108 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 109 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 110 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 111 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 112 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 113 | github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= 114 | github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= 115 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 116 | golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 117 | golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= 118 | golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 119 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 120 | golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= 121 | golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= 122 | golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= 123 | golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 124 | golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 125 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 126 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 127 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 128 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 129 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 130 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 131 | golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 132 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 133 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 134 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 135 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 136 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 137 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 138 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 139 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 140 | golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= 141 | golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= 142 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 143 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 h1:DC7wcm+i+P1rN3Ff07vL+OndGg5OhNddHyTA+ocPqYE= 144 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM= 145 | google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= 146 | google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= 147 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 148 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 149 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 150 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 151 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 152 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 153 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 154 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 155 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 156 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 157 | --------------------------------------------------------------------------------