Skip to content

Commit 73c627d

Browse files
authored
Merge pull request #25 from cruxstack/dev
feat: add option admin token
2 parents 6dde43b + d9c73aa commit 73c627d

File tree

4 files changed

+209
-0
lines changed

4 files changed

+209
-0
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# debug
22
APP_DEBUG_ENABLED=false
33

4+
# admin token for /server/* and /scheduled/* endpoints (optional)
5+
# when set, requests to these endpoints require "Authorization: Bearer <token>"
6+
# APP_ADMIN_TOKEN=your-secret-admin-token
7+
48
# github app (required)
59
APP_GITHUB_APP_ID=123456
610
APP_GITHUB_APP_PRIVATE_KEY_PATH=./.local/private-key.pem

internal/app/app_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,163 @@ func TestFakeDataTypes(t *testing.T) {
169169
// ensure fake orphaned users report is compatible with notifier
170170
var _ *okta.OrphanedUsersReport = fakeOrphanedUsersReport()
171171
}
172+
173+
func TestCheckAdminAuth(t *testing.T) {
174+
tests := []struct {
175+
name string
176+
adminToken string
177+
authHeader string
178+
expectError bool
179+
}{
180+
{
181+
name: "no token configured, no header",
182+
adminToken: "",
183+
authHeader: "",
184+
expectError: false,
185+
},
186+
{
187+
name: "no token configured, with header",
188+
adminToken: "",
189+
authHeader: "Bearer some-token",
190+
expectError: false,
191+
},
192+
{
193+
name: "token configured, no header",
194+
adminToken: "secret-token",
195+
authHeader: "",
196+
expectError: true,
197+
},
198+
{
199+
name: "token configured, wrong token",
200+
adminToken: "secret-token",
201+
authHeader: "Bearer wrong-token",
202+
expectError: true,
203+
},
204+
{
205+
name: "token configured, correct token",
206+
adminToken: "secret-token",
207+
authHeader: "Bearer secret-token",
208+
expectError: false,
209+
},
210+
{
211+
name: "token configured, lowercase bearer",
212+
adminToken: "secret-token",
213+
authHeader: "bearer secret-token",
214+
expectError: false,
215+
},
216+
}
217+
218+
for _, tt := range tests {
219+
t.Run(tt.name, func(t *testing.T) {
220+
app := &App{
221+
Config: &config.Config{AdminToken: tt.adminToken},
222+
Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
223+
}
224+
225+
headers := map[string]string{}
226+
if tt.authHeader != "" {
227+
headers["authorization"] = tt.authHeader
228+
}
229+
230+
req := Request{Headers: headers}
231+
resp := app.checkAdminAuth(req)
232+
233+
if tt.expectError && resp == nil {
234+
t.Error("expected error response, got nil")
235+
}
236+
if !tt.expectError && resp != nil {
237+
t.Errorf("expected no error, got status %d", resp.StatusCode)
238+
}
239+
if tt.expectError && resp != nil && resp.StatusCode != 401 {
240+
t.Errorf("expected status 401, got %d", resp.StatusCode)
241+
}
242+
})
243+
}
244+
}
245+
246+
func TestHandleRequest_AdminAuthOnProtectedEndpoints(t *testing.T) {
247+
tests := []struct {
248+
name string
249+
path string
250+
method string
251+
adminToken string
252+
authHeader string
253+
expectedStatus int
254+
}{
255+
{
256+
name: "status endpoint, no token configured",
257+
path: "/server/status",
258+
method: "GET",
259+
adminToken: "",
260+
authHeader: "",
261+
expectedStatus: 200,
262+
},
263+
{
264+
name: "status endpoint, token required, missing",
265+
path: "/server/status",
266+
method: "GET",
267+
adminToken: "secret",
268+
authHeader: "",
269+
expectedStatus: 401,
270+
},
271+
{
272+
name: "status endpoint, token required, correct",
273+
path: "/server/status",
274+
method: "GET",
275+
adminToken: "secret",
276+
authHeader: "Bearer secret",
277+
expectedStatus: 200,
278+
},
279+
{
280+
name: "config endpoint, token required, missing",
281+
path: "/server/config",
282+
method: "GET",
283+
adminToken: "secret",
284+
authHeader: "",
285+
expectedStatus: 401,
286+
},
287+
{
288+
name: "config endpoint, token required, correct",
289+
path: "/server/config",
290+
method: "GET",
291+
adminToken: "secret",
292+
authHeader: "Bearer secret",
293+
expectedStatus: 200,
294+
},
295+
{
296+
name: "scheduled endpoint, token required, missing",
297+
path: "/scheduled/slack-test",
298+
method: "POST",
299+
adminToken: "secret",
300+
authHeader: "",
301+
expectedStatus: 401,
302+
},
303+
}
304+
305+
for _, tt := range tests {
306+
t.Run(tt.name, func(t *testing.T) {
307+
app := &App{
308+
Config: &config.Config{AdminToken: tt.adminToken},
309+
Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
310+
}
311+
312+
headers := map[string]string{}
313+
if tt.authHeader != "" {
314+
headers["authorization"] = tt.authHeader
315+
}
316+
317+
req := Request{
318+
Type: RequestTypeHTTP,
319+
Method: tt.method,
320+
Path: tt.path,
321+
Headers: headers,
322+
}
323+
324+
resp := app.HandleRequest(context.Background(), req)
325+
326+
if resp.StatusCode != tt.expectedStatus {
327+
t.Errorf("expected status %d, got %d", tt.expectedStatus, resp.StatusCode)
328+
}
329+
})
330+
}
331+
}

internal/app/request.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ func (a *App) handleStatusRequest(req Request) Response {
112112
if req.Method != "GET" {
113113
return errorResponse(405, "method not allowed")
114114
}
115+
if resp := a.checkAdminAuth(req); resp != nil {
116+
return *resp
117+
}
115118
return jsonResponse(200, a.GetStatus())
116119
}
117120

@@ -120,6 +123,9 @@ func (a *App) handleConfigRequest(req Request) Response {
120123
if req.Method != "GET" {
121124
return errorResponse(405, "method not allowed")
122125
}
126+
if resp := a.checkAdminAuth(req); resp != nil {
127+
return *resp
128+
}
123129
return jsonResponse(200, a.Config.Redacted())
124130
}
125131

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

166175
// extract action from path (e.g., "/scheduled/okta-sync" -> "okta-sync")
167176
action := strings.TrimPrefix(path, "/scheduled/")
@@ -199,3 +208,30 @@ func errorResponse(status int, message string) Response {
199208
Body: []byte(message),
200209
}
201210
}
211+
212+
// checkAdminAuth validates the admin token from the request.
213+
// returns nil if auth is disabled (no token configured) or if token is valid.
214+
// returns an error response if token is required but missing or invalid.
215+
func (a *App) checkAdminAuth(req Request) *Response {
216+
if a.Config.AdminToken == "" {
217+
return nil
218+
}
219+
220+
authHeader := req.Headers["authorization"]
221+
if authHeader == "" {
222+
resp := errorResponse(401, "unauthorized")
223+
return &resp
224+
}
225+
226+
token := strings.TrimPrefix(authHeader, "Bearer ")
227+
if token == authHeader {
228+
token = strings.TrimPrefix(authHeader, "bearer ")
229+
}
230+
231+
if token != a.Config.AdminToken {
232+
resp := errorResponse(401, "unauthorized")
233+
return &resp
234+
}
235+
236+
return nil
237+
}

internal/config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Config struct {
2424
// General
2525
DebugEnabled bool
2626
BasePath string
27+
AdminToken string
2728

2829
// GitHub App
2930
GitHubOrg string
@@ -165,8 +166,14 @@ func NewConfigWithContext(ctx context.Context) (*Config, error) {
165166
return nil, err
166167
}
167168

169+
adminToken, err := getEnv(ctx, "APP_ADMIN_TOKEN")
170+
if err != nil {
171+
return nil, err
172+
}
173+
168174
cfg := Config{
169175
DebugEnabled: debugEnabled,
176+
AdminToken: adminToken,
170177
GitHubOrg: os.Getenv("APP_GITHUB_ORG"),
171178
GitHubWebhookSecret: githubWebhookSecret,
172179
GitHubBaseURL: os.Getenv("APP_GITHUB_BASE_URL"),
@@ -341,6 +348,7 @@ type RedactedConfig struct {
341348
// General
342349
DebugEnabled bool `json:"debug_enabled"`
343350
BasePath string `json:"base_path"`
351+
AdminToken string `json:"admin_token"`
344352

345353
// GitHub App
346354
GitHubOrg string `json:"github_org"`
@@ -397,6 +405,7 @@ func (c *Config) Redacted() RedactedConfig {
397405
// General
398406
DebugEnabled: c.DebugEnabled,
399407
BasePath: c.BasePath,
408+
AdminToken: redact(c.AdminToken),
400409

401410
// GitHub App
402411
GitHubOrg: c.GitHubOrg,

0 commit comments

Comments
 (0)