From d9c73aaf5ed720cce2234ed4eae4a2ea6256acb1 Mon Sep 17 00:00:00 2001 From: Brian Ojeda <9335829+sgtoj@users.noreply.github.com> Date: Tue, 9 Dec 2025 11:42:16 -0500 Subject: [PATCH] feat: add option admin token --- .env.example | 4 + internal/app/app_test.go | 160 ++++++++++++++++++++++++++++++++++++++ internal/app/request.go | 36 +++++++++ internal/config/config.go | 9 +++ 4 files changed, 209 insertions(+) diff --git a/.env.example b/.env.example index d2a7821..1e1c1b0 100644 --- a/.env.example +++ b/.env.example @@ -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 " +# 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 diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 66e31e9..ee8bf0c 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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) + } + }) + } +} diff --git a/internal/app/request.go b/internal/app/request.go index c7a1a37..d3a17d7 100644 --- a/internal/app/request.go +++ b/internal/app/request.go @@ -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()) } @@ -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()) } @@ -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/") @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index 5249c7d..f43dde0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,7 @@ type Config struct { // General DebugEnabled bool BasePath string + AdminToken string // GitHub App GitHubOrg string @@ -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"), @@ -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"` @@ -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,