mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-29 00:01:22 +00:00
feat: personal access tokens with scoped bearer auth
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]>
This commit is contained in:
1 parent
f9ae5b962c
commit
e881747388
17 files changed
+1141
-30
No files matched your search
@@ -2,7 +2,10 @@ package middleware
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
sqlcdb "containr/internal/database/sqlcdb"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -75,6 +78,14 @@ func RequestID() gin.HandlerFunc {
|
||||
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
|
||||
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
||||
if tokenString != "" {
|
||||
// Personal access tokens (cnp_…) are not JWTs — they resolve
|
||||
// against the user_tokens table by sha256 hash.
|
||||
if strings.HasPrefix(tokenString, patPrefix) {
|
||||
if id, mail, verified := resolvePersonalAccessToken(c, tokenString); verified {
|
||||
return id, mail, "", hasToken, true
|
||||
}
|
||||
return "", "", "Invalid token", hasToken, false
|
||||
}
|
||||
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
|
||||
userIDClaim, exists := claims["user_id"]
|
||||
if exists {
|
||||
@@ -101,12 +112,67 @@ func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *bette
|
||||
return "", "", tokenErr, hasToken, false
|
||||
}
|
||||
|
||||
const patPrefix = "cnp_"
|
||||
|
||||
// resolvePersonalAccessToken authenticates a `cnp_` bearer token against the
|
||||
// user_tokens table (sha256 hash match, not revoked, not expired). On success
|
||||
// it stamps last_used_at, records the token scope on the context, and
|
||||
// returns the owner's id and email.
|
||||
func resolvePersonalAccessToken(c *gin.Context, token string) (string, string, bool) {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
return "", "", false
|
||||
}
|
||||
db, ok := dbValue.(*database.DB)
|
||||
if !ok || db == nil || db.DB == nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
queries := sqlcdb.New(db.DB)
|
||||
row, err := queries.GetUserTokenByHash(c.Request.Context(), hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var email string
|
||||
if err := db.QueryRowContext(c.Request.Context(),
|
||||
`SELECT email FROM users WHERE id = $1`, row.UserID).Scan(&email); err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
_ = queries.TouchUserToken(c.Request.Context(), row.ID)
|
||||
c.Set("pat_scope", row.Scope)
|
||||
return row.UserID.String(), email, true
|
||||
}
|
||||
|
||||
// patScopeAllows reports whether a PAT-authenticated request may proceed.
|
||||
// read scope permits only safe methods; write and admin are unrestricted at
|
||||
// this layer (admin routes still require the admin scope AND a platform
|
||||
// admin account — enforced via is_admin in setAuthenticatedContext).
|
||||
func patScopeAllows(c *gin.Context) bool {
|
||||
if c.GetString("pat_scope") != "read" {
|
||||
return true
|
||||
}
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setAuthenticatedContext stores the resolved identity plus the admin flag on
|
||||
// the gin context so handlers and RequireAdmin can rely on it.
|
||||
func setAuthenticatedContext(c *gin.Context, userID, email string) {
|
||||
c.Set("user_id", userID)
|
||||
c.Set("email", email)
|
||||
c.Set("is_admin", loadIsAdmin(c, userID))
|
||||
isAdmin := loadIsAdmin(c, userID)
|
||||
// A PAT can never outrank its scope: non-admin scopes cap the token at
|
||||
// non-admin even when the owning user is a platform admin.
|
||||
if scope := c.GetString("pat_scope"); scope != "" && scope != "admin" {
|
||||
isAdmin = false
|
||||
}
|
||||
c.Set("is_admin", isAdmin)
|
||||
}
|
||||
|
||||
func loadIsAdmin(c *gin.Context, userID string) bool {
|
||||
@@ -133,6 +199,14 @@ func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
|
||||
if ok {
|
||||
setAuthenticatedContext(c, userID, email)
|
||||
if !patScopeAllows(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Token scope is read-only",
|
||||
"code": "SCOPE_INSUFFICIENT",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user