mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Add cnp_ personal access tokens so the CLI, MCP server, and external agents authenticate without a browser session — the agent-first spine from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown once, only SHA-256 hash stored. - user_tokens table (goose migration + sqlc), name/prefix/scope/expiry, owner-scoped revoke, last_used_at stamping - middleware: cnp_ bearer resolution, read scope gates mutations with 403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin owners — a token never outranks its scope or its owner - GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types - Settings UI: create with scope/expiry pickers, one-time token display with copy, list with last-used/expiry, confirm-revoke - docs: OPERATIONS.md section, README feature line Verified end-to-end against live Postgres: create/list/revoke, read scope blocks PUT (403), write PAT on admin owner blocked from admin routes (403), admin scope works, revoked token returns 401. Authored By: TDvorak <[email protected]>
198 lines
5.7 KiB
Go
198 lines
5.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestSecurityHeaders(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(SecurityHeaders())
|
|
router.GET("/test", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("expected X-Content-Type-Options nosniff, got %q", got)
|
|
}
|
|
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
|
|
t.Fatalf("expected X-Frame-Options DENY, got %q", got)
|
|
}
|
|
if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" {
|
|
t.Fatalf("expected Referrer-Policy strict-origin-when-cross-origin, got %q", got)
|
|
}
|
|
if got := rec.Header().Get("X-XSS-Protection"); got != "1; mode=block" {
|
|
t.Fatalf("expected X-XSS-Protection header, got %q", got)
|
|
}
|
|
if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
|
|
t.Fatalf("expected no HSTS header for plain HTTP request, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSecurityHeadersAddsHSTSWhenForwardedProtoHTTPS(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(SecurityHeaders())
|
|
router.GET("/test", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if got := rec.Header().Get("Strict-Transport-Security"); got == "" {
|
|
t.Fatal("expected HSTS header for https-forwarded request")
|
|
}
|
|
}
|
|
|
|
func TestRequestBodyLimitRejectsLargeRequest(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(RequestBodyLimit(8))
|
|
router.POST("/test", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader("0123456789"))
|
|
req.ContentLength = 10
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthRejectsNonUUIDUserIDClaim(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(Auth("secret"))
|
|
router.GET("/test", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
token := issueJWT(t, "secret", jwt.MapClaims{
|
|
"user_id": "not-a-uuid",
|
|
"email": "[email protected]",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(Auth("secret"))
|
|
router.GET("/test", func(c *gin.Context) {
|
|
userID, _ := c.Get("user_id")
|
|
c.String(http.StatusOK, fmt.Sprint(userID))
|
|
})
|
|
|
|
expectedUserID := uuid.NewString()
|
|
token := issueJWT(t, "secret", jwt.MapClaims{
|
|
"user_id": expectedUserID,
|
|
"email": "[email protected]",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
|
}
|
|
if got := rec.Body.String(); got != expectedUserID {
|
|
t.Fatalf("expected user_id %q, got %q", expectedUserID, got)
|
|
}
|
|
}
|
|
|
|
func TestAuthRejectsPATWithoutDatabase(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(Auth("secret"))
|
|
router.GET("/test", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
// A cnp_-prefixed token takes the PAT path, not JWT parsing — without a
|
|
// db handle it must fail closed with 401.
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPATScopeAllows(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cases := []struct {
|
|
name string
|
|
scope string
|
|
method string
|
|
want bool
|
|
}{
|
|
{"read allows GET", "read", http.MethodGet, true},
|
|
{"read allows HEAD", "read", http.MethodHead, true},
|
|
{"read allows OPTIONS", "read", http.MethodOptions, true},
|
|
{"read blocks POST", "read", http.MethodPost, false},
|
|
{"read blocks DELETE", "read", http.MethodDelete, false},
|
|
{"write allows POST", "write", http.MethodPost, true},
|
|
{"write allows DELETE", "write", http.MethodDelete, true},
|
|
{"admin allows PATCH", "admin", http.MethodPatch, true},
|
|
{"no scope (session/JWT) unrestricted", "", http.MethodPost, true},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(tc.method, "/x", nil)
|
|
if tc.scope != "" {
|
|
c.Set("pat_scope", tc.scope)
|
|
}
|
|
if got := patScopeAllows(c); got != tc.want {
|
|
t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
|
|
t.Helper()
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
signed, err := token.SignedString([]byte(secret))
|
|
if err != nil {
|
|
t.Fatalf("failed to sign token: %v", err)
|
|
}
|
|
return signed
|
|
}
|