Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Security](#security)
- [Basic Authentication](#basic-authentication)
- [OIDC](#oidc)
- [API Tokens](#api-tokens)
- [TLS Encryption](#tls-encryption)
- [Metrics](#metrics)
- [Custom Labels](#custom-labels)
Expand Down Expand Up @@ -2601,6 +2602,7 @@ endpoints:
| `security` | Security configuration | `{}` |
| `security.basic` | HTTP Basic configuration | `{}` |
| `security.oidc` | OpenID Connect configuration | `{}` |
| `security.api` | API token configuration | `{}` |


#### Basic Authentication
Expand Down Expand Up @@ -2651,6 +2653,53 @@ security:
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).


#### API Tokens
| Parameter | Description | Default |
|:-----------------------|:--------------------------------------------------------------------------|:------------------------|
| `security.api` | API token configuration | `{}` |
| `security.api.tokens` | List of valid API tokens for Bearer authentication. Supports environment variables. | `[]` |

API tokens provide a simple authentication method using Bearer tokens. Tokens can be plain text strings or environment variables.

The example below configures two API tokens:
```yaml
security:
api:
tokens:
- "my-secret-token-123"
- "${API_TOKEN_FROM_ENV}"
```

To authenticate, include the token in the `Authorization` header:
```bash
curl -H "Authorization: Bearer my-secret-token-123" https://status.example.com/api/v1/endpoints/statuses
```

**Key characteristics:**
- API tokens work as an **alternative** to Basic or OIDC authentication (any valid method succeeds)
- Tokens are stored only in the YAML configuration file (never persisted to database)
- Full support for environment variable substitution using `${VAR_NAME}` syntax
- Uses standard `Authorization: Bearer <token>` header (case-sensitive, "Bearer" prefix required)
- All tokens have full access (no per-token permissions)

**Combining authentication methods:**

You can configure API tokens alongside Basic or OIDC authentication. Requests will be authenticated if **any** valid method is provided:

```yaml
security:
api:
tokens:
- "my-api-token"
basic:
username: "admin"
password-bcrypt-base64: "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
```

With this configuration, users can authenticate using a valid API token:
`Authorization: Bearer my-api-token`


### TLS Encryption
Gatus supports basic encryption with TLS. To enable this, certificate files in PEM format have to be provided.

Expand Down
40 changes: 40 additions & 0 deletions security/api-tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package security

import "errors"

var (
ErrAPITokensEmpty = errors.New("security.api.tokens must not contain empty tokens")
)

// APIConfig is the configuration for API token authentication
type APIConfig struct {
// Tokens is a list of valid API tokens for authentication
Tokens []string `yaml:"tokens"`
}

// Validate validates the APIConfig
func (c *APIConfig) Validate() error {
if c == nil {
return nil
}
// Ensure no empty tokens are configured
for _, token := range c.Tokens {
if len(token) == 0 {
return ErrAPITokensEmpty
}
}
return nil
}

// IsValid checks if a given token is valid
func (c *APIConfig) IsValid(token string) bool {
if c == nil || len(c.Tokens) == 0 || len(token) == 0 {
return false
}
for _, validToken := range c.Tokens {
if validToken == token {
return true
}
}
return false
}
227 changes: 227 additions & 0 deletions security/api-tokens_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package security

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/gofiber/fiber/v2"
)

func TestAPIConfig_Validate(t *testing.T) {
tests := []struct {
name string
config *APIConfig
wantErr bool
}{
{
name: "nil config should be valid",
config: nil,
wantErr: false,
},
{
name: "valid tokens",
config: &APIConfig{
Tokens: []string{"token1", "token2"},
},
wantErr: false,
},
{
name: "empty token list should be valid",
config: &APIConfig{
Tokens: []string{},
},
wantErr: false,
},
{
name: "empty token string should be invalid",
config: &APIConfig{
Tokens: []string{"valid-token", ""},
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("APIConfig.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && err != ErrAPITokensEmpty {
t.Errorf("APIConfig.Validate() error = %v, expected %v", err, ErrAPITokensEmpty)
}
})
}
}

func TestAPIConfig_IsValid(t *testing.T) {
tests := []struct {
name string
config *APIConfig
token string
want bool
}{
{
name: "nil config",
config: nil,
token: "any-token",
want: false,
},
{
name: "empty token list",
config: &APIConfig{
Tokens: []string{},
},
token: "any-token",
want: false,
},
{
name: "empty token string",
config: &APIConfig{
Tokens: []string{"valid-token"},
},
token: "",
want: false,
},
{
name: "valid token matches",
config: &APIConfig{
Tokens: []string{"token1", "token2", "token3"},
},
token: "token2",
want: true,
},
{
name: "token does not match",
config: &APIConfig{
Tokens: []string{"token1", "token2"},
},
token: "invalid-token",
want: false,
},
{
name: "partial token match should not work",
config: &APIConfig{
Tokens: []string{"my-secret-token"},
},
token: "my-secret",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.config.IsValid(tt.token); got != tt.want {
t.Errorf("APIConfig.IsValid() = %v, want %v", got, tt.want)
}
})
}
}

func TestConfig_IsAuthenticated_WithAPITokens(t *testing.T) {
c := &Config{
API: &APIConfig{
Tokens: []string{"valid-token-123"},
},
}
app := fiber.New()

// Simulate /api/v1/config endpoint behavior
app.Get("/api/v1/config", func(ctx *fiber.Ctx) error {
isAuthenticated := c.IsAuthenticated(ctx)
response := map[string]interface{}{
"authenticated": isAuthenticated,
"oidc": false,
}
return ctx.JSON(response)
})

// Test with valid API token
t.Run("valid api token returns authenticated true", func(t *testing.T) {
request := httptest.NewRequest("GET", "/api/v1/config", http.NoBody)
request.Header.Set("Authorization", "Bearer valid-token-123")
response, err := app.Test(request)
if err != nil {
t.Fatal("expected no error, got", err)
}
if response.StatusCode != 200 {
t.Error("expected code to be 200, but was", response.StatusCode)
}

var result map[string]interface{}
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
t.Fatal("failed to decode response:", err)
}

if result["authenticated"] != true {
t.Error("expected authenticated to be true with valid token, but was", result["authenticated"])
}
})

// Test with invalid API token
t.Run("invalid api token returns authenticated false", func(t *testing.T) {
request := httptest.NewRequest("GET", "/api/v1/config", http.NoBody)
request.Header.Set("Authorization", "Bearer invalid-token")
response, err := app.Test(request)
if err != nil {
t.Fatal("expected no error, got", err)
}
if response.StatusCode != 200 {
t.Error("expected code to be 200, but was", response.StatusCode)
}

var result map[string]interface{}
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
t.Fatal("failed to decode response:", err)
}

if result["authenticated"] != false {
t.Error("expected authenticated to be false with invalid token, but was", result["authenticated"])
}
})

// Test without Authorization header
t.Run("no authorization header returns authenticated false", func(t *testing.T) {
request := httptest.NewRequest("GET", "/api/v1/config", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Fatal("expected no error, got", err)
}
if response.StatusCode != 200 {
t.Error("expected code to be 200, but was", response.StatusCode)
}

var result map[string]interface{}
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
t.Fatal("failed to decode response:", err)
}

if result["authenticated"] != false {
t.Error("expected authenticated to be false without auth header, but was", result["authenticated"])
}
})

// Test with malformed Bearer header
t.Run("malformed bearer header returns authenticated false", func(t *testing.T) {
request := httptest.NewRequest("GET", "/api/v1/config", http.NoBody)
request.Header.Set("Authorization", "bearer valid-token-123")
response, err := app.Test(request)
if err != nil {
t.Fatal("expected no error, got", err)
}
if response.StatusCode != 200 {
t.Error("expected code to be 200, but was", response.StatusCode)
}

var result map[string]interface{}
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
t.Fatal("failed to decode response:", err)
}

if result["authenticated"] != false {
t.Error("expected authenticated to be false with lowercase bearer, but was", result["authenticated"])
}
})
}
Loading