Add AI service API token auth
This commit is contained in:
@@ -60,12 +60,17 @@ or compact `system` / `user` fields. The completed job result contains
|
|||||||
- `GET /healthz` returns process health.
|
- `GET /healthz` returns process health.
|
||||||
- `GET /readyz` checks PostgreSQL readiness.
|
- `GET /readyz` checks PostgreSQL readiness.
|
||||||
|
|
||||||
|
All `/api/v1/*` endpoints require `Authorization: Bearer <AI_SERVICE_TOKEN>`
|
||||||
|
when `AI_SERVICE_TOKEN` is configured. Health and readiness endpoints stay open
|
||||||
|
for Kubernetes probes.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
- `HTTP_HOST`, default `0.0.0.0`
|
- `HTTP_HOST`, default `0.0.0.0`
|
||||||
- `HTTP_PORT`, default `8080`
|
- `HTTP_PORT`, default `8080`
|
||||||
- `DATABASE_URL`, required
|
- `DATABASE_URL`, required
|
||||||
- `MIGRATE_ON_START`, default `true`
|
- `MIGRATE_ON_START`, default `true`
|
||||||
|
- `AI_SERVICE_TOKEN`, optional bearer token for service-to-service API calls
|
||||||
- `LLM_BASE_URL`, primary OpenAI-compatible LLM endpoint
|
- `LLM_BASE_URL`, primary OpenAI-compatible LLM endpoint
|
||||||
- `LLM_API_KEY`, primary LLM API key
|
- `LLM_API_KEY`, primary LLM API key
|
||||||
- `LLM_MODEL`, default `qwen2.5-14b`
|
- `LLM_MODEL`, default `qwen2.5-14b`
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Config struct {
|
|||||||
HTTPPort int
|
HTTPPort int
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
MigrateOnStart bool
|
MigrateOnStart bool
|
||||||
|
APIAuthToken string
|
||||||
|
|
||||||
LLMBaseURL string
|
LLMBaseURL string
|
||||||
LLMAPIKey string
|
LLMAPIKey string
|
||||||
@@ -30,6 +31,7 @@ func Load() Config {
|
|||||||
HTTPPort: envInt("HTTP_PORT", 8080),
|
HTTPPort: envInt("HTTP_PORT", 8080),
|
||||||
DatabaseURL: envString("DATABASE_URL", ""),
|
DatabaseURL: envString("DATABASE_URL", ""),
|
||||||
MigrateOnStart: envBool("MIGRATE_ON_START", true),
|
MigrateOnStart: envBool("MIGRATE_ON_START", true),
|
||||||
|
APIAuthToken: envString("AI_SERVICE_TOKEN", ""),
|
||||||
|
|
||||||
LLMBaseURL: envString("LLM_BASE_URL", ""),
|
LLMBaseURL: envString("LLM_BASE_URL", ""),
|
||||||
LLMAPIKey: envString("LLM_API_KEY", ""),
|
LLMAPIKey: envString("LLM_API_KEY", ""),
|
||||||
|
|||||||
30
internal/httpapi/auth.go
Normal file
30
internal/httpapi/auth.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) requireAPIToken(path string, r *http.Request) bool {
|
||||||
|
if !strings.HasPrefix(path, "/api/v1/") && path != "/api/v1" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
expected := strings.TrimSpace(s.cfg.APIAuthToken)
|
||||||
|
if expected == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
got := bearerToken(r.Header.Get("Authorization"))
|
||||||
|
if got == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerToken(header string) string {
|
||||||
|
header = strings.TrimSpace(header)
|
||||||
|
if len(header) < len("Bearer ") || !strings.EqualFold(header[:len("Bearer ")], "Bearer ") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(header[len("Bearer "):])
|
||||||
|
}
|
||||||
47
internal/httpapi/auth_test.go
Normal file
47
internal/httpapi/auth_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ai-service/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPITokenProtectsAPIRoutes(t *testing.T) {
|
||||||
|
srv := NewServer(nil, config.Config{APIAuthToken: "secret"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected unauthorized API request to be 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/not-found", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected wrong token to be 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/not-found", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer secret")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected authorized unknown route to be 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPITokenDoesNotProtectHealth(t *testing.T) {
|
||||||
|
srv := NewServer(nil, config.Config{APIAuthToken: "secret"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected healthz to stay open, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
path = "/"
|
path = "/"
|
||||||
}
|
}
|
||||||
|
if !s.requireAPIToken(path, r) {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case r.Method == http.MethodGet && path == "/healthz":
|
case r.Method == http.MethodGet && path == "/healthz":
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ type: Opaque
|
|||||||
stringData:
|
stringData:
|
||||||
DATABASE_URL: "postgres://ai_service:ai_service@postgres:5432/ai_service?sslmode=disable"
|
DATABASE_URL: "postgres://ai_service:ai_service@postgres:5432/ai_service?sslmode=disable"
|
||||||
LLM_API_KEY: "sk-111f838ccec43406e078cd9094b6797307cb895236179f32"
|
LLM_API_KEY: "sk-111f838ccec43406e078cd9094b6797307cb895236179f32"
|
||||||
|
AI_SERVICE_TOKEN: "d18bcacf9e02bae1806ee6b6eeda62b95be6a915c0a22936d9a700128b275442"
|
||||||
|
|||||||
Reference in New Issue
Block a user