Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# debug
APP_DEBUG_ENABLED=false

# admin token for /server/* and /scheduled/* endpoints (optional)
# when set, requests to these endpoints require "Authorization: Bearer <token>"
# APP_ADMIN_TOKEN=your-secret-admin-token

# github app (required)
APP_GITHUB_APP_ID=123456
APP_GITHUB_APP_PRIVATE_KEY_PATH=./.local/private-key.pem
Expand Down
160 changes: 160 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,163 @@ func TestFakeDataTypes(t *testing.T) {
// ensure fake orphaned users report is compatible with notifier
var _ *okta.OrphanedUsersReport = fakeOrphanedUsersReport()
}

func TestCheckAdminAuth(t *testing.T) {
tests := []struct {
name string
adminToken string
authHeader string
expectError bool
}{
{
name: "no token configured, no header",
adminToken: "",
authHeader: "",
expectError: false,
},
{
name: "no token configured, with header",
adminToken: "",
authHeader: "Bearer some-token",
expectError: false,
},
{
name: "token configured, no header",
adminToken: "secret-token",
authHeader: "",
expectError: true,
},
{
name: "token configured, wrong token",
adminToken: "secret-token",
authHeader: "Bearer wrong-token",
expectError: true,
},
{
name: "token configured, correct token",
adminToken: "secret-token",
authHeader: "Bearer secret-token",
expectError: false,
},
{
name: "token configured, lowercase bearer",
adminToken: "secret-token",
authHeader: "bearer secret-token",
expectError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := &App{
Config: &config.Config{AdminToken: tt.adminToken},
Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
}

headers := map[string]string{}
if tt.authHeader != "" {
headers["authorization"] = tt.authHeader
}

req := Request{Headers: headers}
resp := app.checkAdminAuth(req)

if tt.expectError && resp == nil {
t.Error("expected error response, got nil")
}
if !tt.expectError && resp != nil {
t.Errorf("expected no error, got status %d", resp.StatusCode)
}
if tt.expectError && resp != nil && resp.StatusCode != 401 {
t.Errorf("expected status 401, got %d", resp.StatusCode)
}
})
}
}

func TestHandleRequest_AdminAuthOnProtectedEndpoints(t *testing.T) {
tests := []struct {
name string
path string
method string
adminToken string
authHeader string
expectedStatus int
}{
{
name: "status endpoint, no token configured",
path: "/server/status",
method: "GET",
adminToken: "",
authHeader: "",
expectedStatus: 200,
},
{
name: "status endpoint, token required, missing",
path: "/server/status",
method: "GET",
adminToken: "secret",
authHeader: "",
expectedStatus: 401,
},
{
name: "status endpoint, token required, correct",
path: "/server/status",
method: "GET",
adminToken: "secret",
authHeader: "Bearer secret",
expectedStatus: 200,
},
{
name: "config endpoint, token required, missing",
path: "/server/config",
method: "GET",
adminToken: "secret",
authHeader: "",
expectedStatus: 401,
},
{
name: "config endpoint, token required, correct",
path: "/server/config",
method: "GET",
adminToken: "secret",
authHeader: "Bearer secret",
expectedStatus: 200,
},
{
name: "scheduled endpoint, token required, missing",
path: "/scheduled/slack-test",
method: "POST",
adminToken: "secret",
authHeader: "",
expectedStatus: 401,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := &App{
Config: &config.Config{AdminToken: tt.adminToken},
Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
}

headers := map[string]string{}
if tt.authHeader != "" {
headers["authorization"] = tt.authHeader
}

req := Request{
Type: RequestTypeHTTP,
Method: tt.method,
Path: tt.path,
Headers: headers,
}

resp := app.HandleRequest(context.Background(), req)

if resp.StatusCode != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, resp.StatusCode)
}
})
}
}
36 changes: 36 additions & 0 deletions internal/app/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ func (a *App) handleStatusRequest(req Request) Response {
if req.Method != "GET" {
return errorResponse(405, "method not allowed")
}
if resp := a.checkAdminAuth(req); resp != nil {
return *resp
}
return jsonResponse(200, a.GetStatus())
}

Expand All @@ -120,6 +123,9 @@ func (a *App) handleConfigRequest(req Request) Response {
if req.Method != "GET" {
return errorResponse(405, "method not allowed")
}
if resp := a.checkAdminAuth(req); resp != nil {
return *resp
}
return jsonResponse(200, a.Config.Redacted())
}

Expand Down Expand Up @@ -162,6 +168,9 @@ func (a *App) handleScheduledHTTPRequest(ctx context.Context, req Request, path
if req.Method != "POST" {
return errorResponse(405, "method not allowed")
}
if resp := a.checkAdminAuth(req); resp != nil {
return *resp
}

// extract action from path (e.g., "/scheduled/okta-sync" -> "okta-sync")
action := strings.TrimPrefix(path, "/scheduled/")
Expand Down Expand Up @@ -199,3 +208,30 @@ func errorResponse(status int, message string) Response {
Body: []byte(message),
}
}

// checkAdminAuth validates the admin token from the request.
// returns nil if auth is disabled (no token configured) or if token is valid.
// returns an error response if token is required but missing or invalid.
func (a *App) checkAdminAuth(req Request) *Response {
if a.Config.AdminToken == "" {
return nil
}

authHeader := req.Headers["authorization"]
if authHeader == "" {
resp := errorResponse(401, "unauthorized")
return &resp
}

token := strings.TrimPrefix(authHeader, "Bearer ")
if token == authHeader {
token = strings.TrimPrefix(authHeader, "bearer ")
}

if token != a.Config.AdminToken {
resp := errorResponse(401, "unauthorized")
return &resp
}

return nil
}
9 changes: 9 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Config struct {
// General
DebugEnabled bool
BasePath string
AdminToken string

// GitHub App
GitHubOrg string
Expand Down Expand Up @@ -165,8 +166,14 @@ func NewConfigWithContext(ctx context.Context) (*Config, error) {
return nil, err
}

adminToken, err := getEnv(ctx, "APP_ADMIN_TOKEN")
if err != nil {
return nil, err
}

cfg := Config{
DebugEnabled: debugEnabled,
AdminToken: adminToken,
GitHubOrg: os.Getenv("APP_GITHUB_ORG"),
GitHubWebhookSecret: githubWebhookSecret,
GitHubBaseURL: os.Getenv("APP_GITHUB_BASE_URL"),
Expand Down Expand Up @@ -341,6 +348,7 @@ type RedactedConfig struct {
// General
DebugEnabled bool `json:"debug_enabled"`
BasePath string `json:"base_path"`
AdminToken string `json:"admin_token"`

// GitHub App
GitHubOrg string `json:"github_org"`
Expand Down Expand Up @@ -397,6 +405,7 @@ func (c *Config) Redacted() RedactedConfig {
// General
DebugEnabled: c.DebugEnabled,
BasePath: c.BasePath,
AdminToken: redact(c.AdminToken),

// GitHub App
GitHubOrg: c.GitHubOrg,
Expand Down