feat: sync lijiaoqiao implementation and staging validation artifacts

This commit is contained in:
Your Name
2026-03-31 13:40:00 +08:00
parent 0e5ecd930e
commit e9338dec28
686 changed files with 29213 additions and 168 deletions

View File

@@ -0,0 +1,51 @@
package middleware
import (
"net/http"
"strings"
"time"
"lijiaoqiao/platform-token-runtime/internal/auth/service"
)
var disallowedQueryKeys = []string{"key", "api_key", "token"}
func QueryKeyRejectMiddleware(next http.Handler, auditor service.AuditEmitter, now func() time.Time) http.Handler {
if next == nil {
next = http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
}
if now == nil {
now = defaultNowFunc
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, exists := externalQueryKey(r)
if !exists {
next.ServeHTTP(w, r)
return
}
requestID := ensureRequestID(r, now)
emitAuditEvent(r.Context(), auditor, service.AuditEvent{
EventName: service.EventTokenQueryKeyRejected,
RequestID: requestID,
Route: r.URL.Path,
ResultCode: service.CodeQueryKeyNotAllowed,
ClientIP: extractClientIP(r),
CreatedAt: now(),
})
writeError(w, http.StatusUnauthorized, requestID, service.CodeQueryKeyNotAllowed, "query key ingress is not allowed")
})
}
func externalQueryKey(r *http.Request) (string, bool) {
values := r.URL.Query()
for key := range values {
lowered := strings.ToLower(key)
for _, disallowed := range disallowedQueryKeys {
if lowered == disallowed {
return key, true
}
}
}
return "", false
}

View File

@@ -0,0 +1,270 @@
package middleware
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"time"
"lijiaoqiao/platform-token-runtime/internal/auth/model"
"lijiaoqiao/platform-token-runtime/internal/auth/service"
)
const requestIDHeader = "X-Request-Id"
var defaultNowFunc = time.Now
type contextKey string
const (
requestIDKey contextKey = "request_id"
principalKey contextKey = "principal"
)
type AuthMiddlewareConfig struct {
Verifier service.TokenVerifier
StatusResolver service.TokenStatusResolver
Authorizer service.RouteAuthorizer
Auditor service.AuditEmitter
ProtectedPrefixes []string
ExcludedPrefixes []string
Now func() time.Time
}
func BuildTokenAuthChain(cfg AuthMiddlewareConfig, next http.Handler) http.Handler {
handler := TokenAuthMiddleware(cfg)(next)
handler = QueryKeyRejectMiddleware(handler, cfg.Auditor, cfg.Now)
handler = RequestIDMiddleware(handler, cfg.Now)
return handler
}
func RequestIDMiddleware(next http.Handler, now func() time.Time) http.Handler {
if next == nil {
return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
}
if now == nil {
now = defaultNowFunc
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := ensureRequestID(r, now)
w.Header().Set(requestIDHeader, requestID)
next.ServeHTTP(w, r)
})
}
func TokenAuthMiddleware(cfg AuthMiddlewareConfig) func(http.Handler) http.Handler {
cfg = cfg.withDefaults()
return func(next http.Handler) http.Handler {
if next == nil {
next = http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !cfg.shouldProtect(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
requestID := ensureRequestID(r, cfg.Now)
if cfg.Verifier == nil || cfg.StatusResolver == nil || cfg.Authorizer == nil {
writeError(w, http.StatusServiceUnavailable, requestID, service.CodeAuthNotReady, "auth middleware dependencies are not ready")
return
}
rawToken, ok := extractBearerToken(r.Header.Get("Authorization"))
if !ok {
emitAuditEvent(r.Context(), cfg.Auditor, service.AuditEvent{
EventName: service.EventTokenAuthnFail,
RequestID: requestID,
Route: r.URL.Path,
ResultCode: service.CodeAuthMissingBearer,
ClientIP: extractClientIP(r),
CreatedAt: cfg.Now(),
})
writeError(w, http.StatusUnauthorized, requestID, service.CodeAuthMissingBearer, "missing bearer token")
return
}
claims, err := cfg.Verifier.Verify(r.Context(), rawToken)
if err != nil {
emitAuditEvent(r.Context(), cfg.Auditor, service.AuditEvent{
EventName: service.EventTokenAuthnFail,
RequestID: requestID,
Route: r.URL.Path,
ResultCode: service.CodeAuthInvalidToken,
ClientIP: extractClientIP(r),
CreatedAt: cfg.Now(),
})
writeError(w, http.StatusUnauthorized, requestID, service.CodeAuthInvalidToken, "invalid bearer token")
return
}
tokenStatus, err := cfg.StatusResolver.Resolve(r.Context(), claims.TokenID)
if err != nil || tokenStatus != service.TokenStatusActive {
emitAuditEvent(r.Context(), cfg.Auditor, service.AuditEvent{
EventName: service.EventTokenAuthnFail,
RequestID: requestID,
TokenID: claims.TokenID,
SubjectID: claims.SubjectID,
Route: r.URL.Path,
ResultCode: service.CodeAuthTokenInactive,
ClientIP: extractClientIP(r),
CreatedAt: cfg.Now(),
})
writeError(w, http.StatusUnauthorized, requestID, service.CodeAuthTokenInactive, "token is inactive")
return
}
if !cfg.Authorizer.Authorize(r.URL.Path, r.Method, claims.Scope, claims.Role) {
emitAuditEvent(r.Context(), cfg.Auditor, service.AuditEvent{
EventName: service.EventTokenAuthzDenied,
RequestID: requestID,
TokenID: claims.TokenID,
SubjectID: claims.SubjectID,
Route: r.URL.Path,
ResultCode: service.CodeAuthScopeDenied,
ClientIP: extractClientIP(r),
CreatedAt: cfg.Now(),
})
writeError(w, http.StatusForbidden, requestID, service.CodeAuthScopeDenied, "scope denied")
return
}
principal := model.Principal{
RequestID: requestID,
TokenID: claims.TokenID,
SubjectID: claims.SubjectID,
Role: claims.Role,
Scope: append([]string(nil), claims.Scope...),
}
ctx := context.WithValue(r.Context(), principalKey, principal)
ctx = context.WithValue(ctx, requestIDKey, requestID)
emitAuditEvent(ctx, cfg.Auditor, service.AuditEvent{
EventName: service.EventTokenAuthnSuccess,
RequestID: requestID,
TokenID: claims.TokenID,
SubjectID: claims.SubjectID,
Route: r.URL.Path,
ResultCode: "OK",
ClientIP: extractClientIP(r),
CreatedAt: cfg.Now(),
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
value, ok := ctx.Value(requestIDKey).(string)
return value, ok
}
func PrincipalFromContext(ctx context.Context) (model.Principal, bool) {
if ctx == nil {
return model.Principal{}, false
}
value, ok := ctx.Value(principalKey).(model.Principal)
return value, ok
}
func (cfg AuthMiddlewareConfig) withDefaults() AuthMiddlewareConfig {
if cfg.Now == nil {
cfg.Now = defaultNowFunc
}
if len(cfg.ProtectedPrefixes) == 0 {
cfg.ProtectedPrefixes = []string{"/api/v1/supply", "/api/v1/platform"}
}
if len(cfg.ExcludedPrefixes) == 0 {
cfg.ExcludedPrefixes = []string{"/healthz", "/metrics", "/readyz"}
}
return cfg
}
func (cfg AuthMiddlewareConfig) shouldProtect(path string) bool {
for _, prefix := range cfg.ExcludedPrefixes {
if strings.HasPrefix(path, prefix) {
return false
}
}
for _, prefix := range cfg.ProtectedPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
func ensureRequestID(r *http.Request, now func() time.Time) string {
if now == nil {
now = defaultNowFunc
}
if requestID, ok := RequestIDFromContext(r.Context()); ok && requestID != "" {
return requestID
}
requestID := strings.TrimSpace(r.Header.Get(requestIDHeader))
if requestID == "" {
requestID = fmt.Sprintf("req-%d", now().UnixNano())
}
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
*r = *r.WithContext(ctx)
return requestID
}
func extractBearerToken(authHeader string) (string, bool) {
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
return "", false
}
token := strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix))
return token, token != ""
}
func emitAuditEvent(ctx context.Context, auditor service.AuditEmitter, event service.AuditEvent) {
if auditor == nil {
return
}
_ = auditor.Emit(ctx, event)
}
type errorResponse struct {
RequestID string `json:"request_id"`
Error errorPayload `json:"error"`
}
type errorPayload struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
}
func writeError(w http.ResponseWriter, status int, requestID, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
payload := errorResponse{
RequestID: requestID,
Error: errorPayload{
Code: code,
Message: message,
},
}
_ = json.NewEncoder(w).Encode(payload)
}
func extractClientIP(r *http.Request) string {
xForwardedFor := strings.TrimSpace(r.Header.Get("X-Forwarded-For"))
if xForwardedFor != "" {
parts := strings.Split(xForwardedFor, ",")
return strings.TrimSpace(parts[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}

View File

@@ -0,0 +1,244 @@
package middleware
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"lijiaoqiao/platform-token-runtime/internal/auth/model"
"lijiaoqiao/platform-token-runtime/internal/auth/service"
)
var fixedNow = func() time.Time {
return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
}
type fakeVerifier struct {
token service.VerifiedToken
err error
}
func (f *fakeVerifier) Verify(context.Context, string) (service.VerifiedToken, error) {
return f.token, f.err
}
type fakeStatusResolver struct {
status service.TokenStatus
err error
}
func (f *fakeStatusResolver) Resolve(context.Context, string) (service.TokenStatus, error) {
return f.status, f.err
}
type fakeAuthorizer struct {
allowed bool
}
func (f *fakeAuthorizer) Authorize(string, string, []string, string) bool {
return f.allowed
}
type fakeAuditor struct {
events []service.AuditEvent
}
func (f *fakeAuditor) Emit(_ context.Context, event service.AuditEvent) error {
f.events = append(f.events, event)
return nil
}
func TestQueryKeyRejectMiddleware(t *testing.T) {
auditor := &fakeAuditor{}
nextCalled := false
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
nextCalled = true
})
handler := QueryKeyRejectMiddleware(next, auditor, fixedNow)
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts?api_key=secret", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if nextCalled {
t.Fatalf("next handler should not be called when query key exists")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusUnauthorized)
}
if got := decodeErrorCode(t, rec); got != service.CodeQueryKeyNotAllowed {
t.Fatalf("unexpected error code: got=%s want=%s", got, service.CodeQueryKeyNotAllowed)
}
if len(auditor.events) != 1 {
t.Fatalf("unexpected audit event count: got=%d want=1", len(auditor.events))
}
if auditor.events[0].EventName != service.EventTokenQueryKeyRejected {
t.Fatalf("unexpected event name: got=%s want=%s", auditor.events[0].EventName, service.EventTokenQueryKeyRejected)
}
}
func TestTokenAuthMiddleware(t *testing.T) {
baseToken := service.VerifiedToken{
TokenID: "tok-001",
SubjectID: "subject-001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
IssuedAt: fixedNow(),
ExpiresAt: fixedNow().Add(time.Hour),
}
cases := []struct {
name string
path string
authHeader string
verifierErr error
status service.TokenStatus
statusErr error
allowed bool
wantStatus int
wantErrorCode string
wantEvent string
wantNext bool
}{
{
name: "missing bearer",
path: "/api/v1/supply/packages",
wantStatus: http.StatusUnauthorized,
wantErrorCode: service.CodeAuthMissingBearer,
wantEvent: service.EventTokenAuthnFail,
},
{
name: "invalid token",
path: "/api/v1/supply/packages",
authHeader: "Bearer invalid-token",
verifierErr: errors.New("invalid signature"),
wantStatus: http.StatusUnauthorized,
wantErrorCode: service.CodeAuthInvalidToken,
wantEvent: service.EventTokenAuthnFail,
},
{
name: "inactive token",
path: "/api/v1/supply/packages",
authHeader: "Bearer active-token",
status: service.TokenStatusRevoked,
wantStatus: http.StatusUnauthorized,
wantErrorCode: service.CodeAuthTokenInactive,
wantEvent: service.EventTokenAuthnFail,
},
{
name: "scope denied",
path: "/api/v1/supply/packages",
authHeader: "Bearer active-token",
status: service.TokenStatusActive,
allowed: false,
wantStatus: http.StatusForbidden,
wantErrorCode: service.CodeAuthScopeDenied,
wantEvent: service.EventTokenAuthzDenied,
},
{
name: "authn success",
path: "/api/v1/supply/packages",
authHeader: "Bearer active-token",
status: service.TokenStatusActive,
allowed: true,
wantStatus: http.StatusNoContent,
wantEvent: service.EventTokenAuthnSuccess,
wantNext: true,
},
{
name: "excluded path bypasses auth",
path: "/healthz",
wantStatus: http.StatusNoContent,
wantNext: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
auditor := &fakeAuditor{}
verifier := &fakeVerifier{
token: baseToken,
err: tc.verifierErr,
}
resolver := &fakeStatusResolver{
status: tc.status,
err: tc.statusErr,
}
authorizer := &fakeAuthorizer{allowed: tc.allowed}
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
if tc.wantNext && strings.HasPrefix(tc.path, "/api/v1/") {
principal, ok := PrincipalFromContext(r.Context())
if !ok {
t.Fatalf("principal should be attached when auth succeeded")
}
if principal.TokenID != baseToken.TokenID {
t.Fatalf("unexpected principal token id: got=%s want=%s", principal.TokenID, baseToken.TokenID)
}
}
w.WriteHeader(http.StatusNoContent)
})
handler := TokenAuthMiddleware(AuthMiddlewareConfig{
Verifier: verifier,
StatusResolver: resolver,
Authorizer: authorizer,
Auditor: auditor,
ProtectedPrefixes: []string{"/api/v1/supply/", "/api/v1/platform/"},
ExcludedPrefixes: []string{"/healthz"},
Now: fixedNow,
})(next)
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
if tc.authHeader != "" {
req.Header.Set("Authorization", tc.authHeader)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, tc.wantStatus)
}
if tc.wantErrorCode != "" {
if got := decodeErrorCode(t, rec); got != tc.wantErrorCode {
t.Fatalf("unexpected error code: got=%s want=%s", got, tc.wantErrorCode)
}
}
if nextCalled != tc.wantNext {
t.Fatalf("unexpected next call state: got=%v want=%v", nextCalled, tc.wantNext)
}
if tc.wantEvent == "" {
return
}
if len(auditor.events) == 0 {
t.Fatalf("audit event should be emitted")
}
lastEvent := auditor.events[len(auditor.events)-1]
if lastEvent.EventName != tc.wantEvent {
t.Fatalf("unexpected event name: got=%s want=%s", lastEvent.EventName, tc.wantEvent)
}
})
}
}
type errorEnvelope struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
func decodeErrorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var envelope errorEnvelope
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
return envelope.Error.Code
}

View File

@@ -0,0 +1,35 @@
package model
import "strings"
const (
RoleOwner = "owner"
RoleViewer = "viewer"
RoleAdmin = "admin"
)
type Principal struct {
RequestID string
TokenID string
SubjectID string
Role string
Scope []string
}
func (p Principal) HasScope(required string) bool {
if required == "" {
return true
}
for _, scope := range p.Scope {
if scope == required {
return true
}
if strings.HasSuffix(scope, ":*") {
prefix := strings.TrimSuffix(scope, "*")
if strings.HasPrefix(required, prefix) {
return true
}
}
}
return false
}

View File

@@ -0,0 +1,491 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"sort"
"strings"
"sync"
"time"
"lijiaoqiao/platform-token-runtime/internal/auth/model"
)
type TokenRecord struct {
TokenID string
AccessToken string
SubjectID string
Role string
Scope []string
IssuedAt time.Time
ExpiresAt time.Time
Status TokenStatus
RequestID string
RevokedReason string
}
type IssueTokenInput struct {
SubjectID string
Role string
Scope []string
TTL time.Duration
RequestID string
IdempotencyKey string
}
type InMemoryTokenRuntime struct {
mu sync.RWMutex
now func() time.Time
records map[string]*TokenRecord
tokenToID map[string]string
idempotencyByKey map[string]idempotencyEntry
}
type idempotencyEntry struct {
RequestHash string
TokenID string
}
func NewInMemoryTokenRuntime(now func() time.Time) *InMemoryTokenRuntime {
if now == nil {
now = time.Now
}
return &InMemoryTokenRuntime{
now: now,
records: make(map[string]*TokenRecord),
tokenToID: make(map[string]string),
idempotencyByKey: make(map[string]idempotencyEntry),
}
}
func (r *InMemoryTokenRuntime) Issue(_ context.Context, input IssueTokenInput) (TokenRecord, error) {
if strings.TrimSpace(input.SubjectID) == "" {
return TokenRecord{}, errors.New("subject_id is required")
}
if strings.TrimSpace(input.Role) == "" {
return TokenRecord{}, errors.New("role is required")
}
if input.TTL <= 0 {
return TokenRecord{}, errors.New("ttl must be positive")
}
if len(input.Scope) == 0 {
return TokenRecord{}, errors.New("scope must not be empty")
}
idempotencyKey := strings.TrimSpace(input.IdempotencyKey)
requestHash := hashIssueInput(input)
issuedAt := r.now()
tokenID, err := generateTokenID()
if err != nil {
return TokenRecord{}, err
}
accessToken, err := generateAccessToken()
if err != nil {
return TokenRecord{}, err
}
record := TokenRecord{
TokenID: tokenID,
AccessToken: accessToken,
SubjectID: input.SubjectID,
Role: input.Role,
Scope: append([]string(nil), input.Scope...),
IssuedAt: issuedAt,
ExpiresAt: issuedAt.Add(input.TTL),
Status: TokenStatusActive,
RequestID: input.RequestID,
RevokedReason: "",
}
r.mu.Lock()
if idempotencyKey != "" {
entry, ok := r.idempotencyByKey[idempotencyKey]
if ok {
if entry.RequestHash != requestHash {
r.mu.Unlock()
return TokenRecord{}, errors.New("idempotency key payload mismatch")
}
existing, exists := r.records[entry.TokenID]
if exists {
r.mu.Unlock()
return cloneRecord(*existing), nil
}
}
}
r.records[tokenID] = &record
r.tokenToID[accessToken] = tokenID
if idempotencyKey != "" {
r.idempotencyByKey[idempotencyKey] = idempotencyEntry{
RequestHash: requestHash,
TokenID: tokenID,
}
}
r.mu.Unlock()
return record, nil
}
func (r *InMemoryTokenRuntime) Refresh(_ context.Context, tokenID string, ttl time.Duration) (TokenRecord, error) {
if ttl <= 0 {
return TokenRecord{}, errors.New("ttl must be positive")
}
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.records[tokenID]
if !ok {
return TokenRecord{}, errors.New("token not found")
}
r.applyExpiry(record)
if record.Status != TokenStatusActive {
return TokenRecord{}, errors.New("token is not active")
}
record.ExpiresAt = r.now().Add(ttl)
return cloneRecord(*record), nil
}
func (r *InMemoryTokenRuntime) Revoke(_ context.Context, tokenID, reason string) (TokenRecord, error) {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.records[tokenID]
if !ok {
return TokenRecord{}, errors.New("token not found")
}
r.applyExpiry(record)
record.Status = TokenStatusRevoked
record.RevokedReason = strings.TrimSpace(reason)
return cloneRecord(*record), nil
}
func (r *InMemoryTokenRuntime) Introspect(_ context.Context, accessToken string) (TokenRecord, error) {
r.mu.Lock()
defer r.mu.Unlock()
tokenID, ok := r.tokenToID[accessToken]
if !ok {
return TokenRecord{}, errors.New("token not found")
}
record := r.records[tokenID]
r.applyExpiry(record)
return cloneRecord(*record), nil
}
func (r *InMemoryTokenRuntime) Lookup(_ context.Context, tokenID string) (TokenRecord, error) {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.records[tokenID]
if !ok {
return TokenRecord{}, errors.New("token not found")
}
r.applyExpiry(record)
return cloneRecord(*record), nil
}
func (r *InMemoryTokenRuntime) Verify(_ context.Context, rawToken string) (VerifiedToken, error) {
r.mu.RLock()
tokenID, ok := r.tokenToID[rawToken]
if !ok {
r.mu.RUnlock()
return VerifiedToken{}, NewAuthError(CodeAuthInvalidToken, errors.New("token not found"))
}
record, ok := r.records[tokenID]
if !ok {
r.mu.RUnlock()
return VerifiedToken{}, NewAuthError(CodeAuthInvalidToken, errors.New("token record not found"))
}
claims := VerifiedToken{
TokenID: record.TokenID,
SubjectID: record.SubjectID,
Role: record.Role,
Scope: append([]string(nil), record.Scope...),
IssuedAt: record.IssuedAt,
ExpiresAt: record.ExpiresAt,
}
r.mu.RUnlock()
return claims, nil
}
func (r *InMemoryTokenRuntime) Resolve(_ context.Context, tokenID string) (TokenStatus, error) {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.records[tokenID]
if !ok {
return "", NewAuthError(CodeAuthInvalidToken, errors.New("token not found"))
}
r.applyExpiry(record)
return record.Status, nil
}
func (r *InMemoryTokenRuntime) TokenCount() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.records)
}
func (r *InMemoryTokenRuntime) IssueAndAudit(ctx context.Context, input IssueTokenInput, auditor AuditEmitter) (TokenRecord, error) {
record, err := r.Issue(ctx, input)
if err != nil {
emitAudit(auditor, AuditEvent{
EventName: EventTokenIssueFail,
RequestID: input.RequestID,
SubjectID: input.SubjectID,
Route: "/api/v1/platform/tokens/issue",
ResultCode: "ISSUE_FAILED",
}, r.now)
return TokenRecord{}, err
}
emitAudit(auditor, AuditEvent{
EventName: EventTokenIssueSuccess,
RequestID: input.RequestID,
TokenID: record.TokenID,
SubjectID: record.SubjectID,
Route: "/api/v1/platform/tokens/issue",
ResultCode: "OK",
}, r.now)
return record, nil
}
func (r *InMemoryTokenRuntime) RevokeAndAudit(ctx context.Context, tokenID, reason, requestID, subjectID string, auditor AuditEmitter) (TokenRecord, error) {
record, err := r.Revoke(ctx, tokenID, reason)
if err != nil {
emitAudit(auditor, AuditEvent{
EventName: EventTokenRevokeFail,
RequestID: requestID,
TokenID: tokenID,
SubjectID: subjectID,
Route: "/api/v1/platform/tokens/revoke",
ResultCode: "REVOKE_FAILED",
}, r.now)
return TokenRecord{}, err
}
emitAudit(auditor, AuditEvent{
EventName: EventTokenRevokeSuccess,
RequestID: requestID,
TokenID: record.TokenID,
SubjectID: record.SubjectID,
Route: "/api/v1/platform/tokens/revoke",
ResultCode: "OK",
}, r.now)
return record, nil
}
func (r *InMemoryTokenRuntime) applyExpiry(record *TokenRecord) {
if record == nil {
return
}
if record.Status == TokenStatusActive && !record.ExpiresAt.IsZero() && !r.now().Before(record.ExpiresAt) {
record.Status = TokenStatusExpired
}
}
func cloneRecord(record TokenRecord) TokenRecord {
record.Scope = append([]string(nil), record.Scope...)
return record
}
func hashIssueInput(input IssueTokenInput) string {
scope := append([]string(nil), input.Scope...)
sort.Strings(scope)
joined := strings.Join(scope, ",")
data := strings.TrimSpace(input.SubjectID) + "|" +
strings.TrimSpace(input.Role) + "|" +
joined + "|" +
input.TTL.String()
sum := sha256.Sum256([]byte(data))
return hex.EncodeToString(sum[:])
}
func generateAccessToken() (string, error) {
var entropy [16]byte
if _, err := rand.Read(entropy[:]); err != nil {
return "", err
}
return "ptk_" + hex.EncodeToString(entropy[:]), nil
}
func generateTokenID() (string, error) {
var entropy [8]byte
if _, err := rand.Read(entropy[:]); err != nil {
return "", err
}
return "tok_" + hex.EncodeToString(entropy[:]), nil
}
type ScopeRoleAuthorizer struct{}
func NewScopeRoleAuthorizer() *ScopeRoleAuthorizer {
return &ScopeRoleAuthorizer{}
}
func (a *ScopeRoleAuthorizer) Authorize(path, method string, scopes []string, role string) bool {
if role == model.RoleAdmin {
return true
}
requiredScope := requiredScopeForRoute(path, method)
if requiredScope == "" {
return true
}
return hasScope(scopes, requiredScope)
}
func requiredScopeForRoute(path, method string) string {
if path == "/api/v1/supply" || strings.HasPrefix(path, "/api/v1/supply/") {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return "supply:read"
default:
return "supply:write"
}
}
if path == "/api/v1/platform" || strings.HasPrefix(path, "/api/v1/platform/") {
return "platform:admin"
}
return ""
}
func hasScope(scopes []string, required string) bool {
for _, scope := range scopes {
if scope == required {
return true
}
if strings.HasSuffix(scope, ":*") {
prefix := strings.TrimSuffix(scope, "*")
if strings.HasPrefix(required, prefix) {
return true
}
}
}
return false
}
type MemoryAuditEmitter struct {
mu sync.RWMutex
events []AuditEvent
now func() time.Time
}
func NewMemoryAuditEmitter() *MemoryAuditEmitter {
return &MemoryAuditEmitter{now: time.Now}
}
func (e *MemoryAuditEmitter) Emit(_ context.Context, event AuditEvent) error {
if event.EventID == "" {
eventID, err := generateEventID()
if err != nil {
return err
}
event.EventID = eventID
}
if event.CreatedAt.IsZero() {
event.CreatedAt = e.now()
}
e.mu.Lock()
e.events = append(e.events, event)
e.mu.Unlock()
return nil
}
func (e *MemoryAuditEmitter) Events() []AuditEvent {
e.mu.RLock()
defer e.mu.RUnlock()
copied := make([]AuditEvent, len(e.events))
copy(copied, e.events)
return copied
}
func (e *MemoryAuditEmitter) QueryEvents(_ context.Context, filter AuditEventFilter) ([]AuditEvent, error) {
e.mu.RLock()
defer e.mu.RUnlock()
limit := filter.Limit
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
result := make([]AuditEvent, 0, minInt(limit, len(e.events)))
for idx := len(e.events) - 1; idx >= 0; idx-- {
ev := e.events[idx]
if !matchAuditFilter(ev, filter) {
continue
}
result = append(result, ev)
if len(result) >= limit {
break
}
}
// 按时间正序返回,便于前端/审计系统展示时间线。
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return result, nil
}
func (e *MemoryAuditEmitter) LastEvent() (AuditEvent, bool) {
e.mu.RLock()
defer e.mu.RUnlock()
if len(e.events) == 0 {
return AuditEvent{}, false
}
return e.events[len(e.events)-1], true
}
func emitAudit(emitter AuditEmitter, event AuditEvent, now func() time.Time) {
if emitter == nil {
return
}
if now == nil {
now = time.Now
}
if event.CreatedAt.IsZero() {
event.CreatedAt = now()
}
_ = emitter.Emit(context.Background(), event)
}
func matchAuditFilter(ev AuditEvent, filter AuditEventFilter) bool {
if filter.RequestID != "" && ev.RequestID != filter.RequestID {
return false
}
if filter.TokenID != "" && ev.TokenID != filter.TokenID {
return false
}
if filter.SubjectID != "" && ev.SubjectID != filter.SubjectID {
return false
}
if filter.EventName != "" && ev.EventName != filter.EventName {
return false
}
if filter.ResultCode != "" && ev.ResultCode != filter.ResultCode {
return false
}
return true
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func generateEventID() (string, error) {
var entropy [8]byte
if _, err := rand.Read(entropy[:]); err != nil {
return "", err
}
return "evt_" + hex.EncodeToString(entropy[:]), nil
}

View File

@@ -0,0 +1,127 @@
package service
import (
"context"
"errors"
"fmt"
"time"
)
const (
CodeAuthMissingBearer = "AUTH_MISSING_BEARER"
CodeQueryKeyNotAllowed = "QUERY_KEY_NOT_ALLOWED"
CodeAuthInvalidToken = "AUTH_INVALID_TOKEN"
CodeAuthTokenInactive = "AUTH_TOKEN_INACTIVE"
CodeAuthScopeDenied = "AUTH_SCOPE_DENIED"
CodeAuthNotReady = "AUTH_NOT_READY"
)
const (
EventTokenAuthnSuccess = "token.authn.success"
EventTokenAuthnFail = "token.authn.fail"
EventTokenAuthzDenied = "token.authz.denied"
EventTokenQueryKeyRejected = "token.query_key.rejected"
EventTokenIssueSuccess = "token.issue.success"
EventTokenIssueFail = "token.issue.fail"
EventTokenIntrospectSuccess = "token.introspect.success"
EventTokenIntrospectFail = "token.introspect.fail"
EventTokenRefreshSuccess = "token.refresh.success"
EventTokenRefreshFail = "token.refresh.fail"
EventTokenRevokeSuccess = "token.revoke.success"
EventTokenRevokeFail = "token.revoke.fail"
)
type TokenStatus string
const (
TokenStatusActive TokenStatus = "active"
TokenStatusRevoked TokenStatus = "revoked"
TokenStatusExpired TokenStatus = "expired"
)
type VerifiedToken struct {
TokenID string
SubjectID string
Role string
Scope []string
IssuedAt time.Time
ExpiresAt time.Time
NotBefore time.Time
Issuer string
Audience string
}
type TokenVerifier interface {
Verify(ctx context.Context, rawToken string) (VerifiedToken, error)
}
type TokenStatusResolver interface {
Resolve(ctx context.Context, tokenID string) (TokenStatus, error)
}
type RouteAuthorizer interface {
Authorize(path, method string, scopes []string, role string) bool
}
type AuditEvent struct {
EventID string
EventName string
RequestID string
TokenID string
SubjectID string
Route string
ResultCode string
ClientIP string
CreatedAt time.Time
}
type AuditEmitter interface {
Emit(ctx context.Context, event AuditEvent) error
}
type AuditEventFilter struct {
RequestID string
TokenID string
SubjectID string
EventName string
ResultCode string
Limit int
}
type AuditEventQuerier interface {
QueryEvents(ctx context.Context, filter AuditEventFilter) ([]AuditEvent, error)
}
type AuthError struct {
Code string
Cause error
}
func (e *AuthError) Error() string {
if e == nil {
return ""
}
if e.Cause == nil {
return e.Code
}
return fmt.Sprintf("%s: %v", e.Code, e.Cause)
}
func (e *AuthError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
func NewAuthError(code string, cause error) *AuthError {
return &AuthError{Code: code, Cause: cause}
}
func IsAuthCode(err error, code string) bool {
var authErr *AuthError
if !errors.As(err, &authErr) {
return false
}
return authErr.Code == code
}

View File

@@ -0,0 +1,437 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"lijiaoqiao/platform-token-runtime/internal/auth/model"
"lijiaoqiao/platform-token-runtime/internal/auth/service"
)
const (
tokenBasePath = "/api/v1/platform/tokens"
)
type Runtime interface {
IssueAndAudit(ctx context.Context, input service.IssueTokenInput, auditor service.AuditEmitter) (service.TokenRecord, error)
Refresh(ctx context.Context, tokenID string, ttl time.Duration) (service.TokenRecord, error)
RevokeAndAudit(ctx context.Context, tokenID, reason, requestID, subjectID string, auditor service.AuditEmitter) (service.TokenRecord, error)
Introspect(ctx context.Context, accessToken string) (service.TokenRecord, error)
Lookup(ctx context.Context, tokenID string) (service.TokenRecord, error)
}
type TokenAPI struct {
runtime Runtime
auditor service.AuditEmitter
now func() time.Time
}
func NewTokenAPI(runtime Runtime, auditor service.AuditEmitter, now func() time.Time) *TokenAPI {
if now == nil {
now = time.Now
}
return &TokenAPI{runtime: runtime, auditor: auditor, now: now}
}
func (a *TokenAPI) Register(mux *http.ServeMux) {
mux.HandleFunc(tokenBasePath+"/issue", a.handleIssue)
mux.HandleFunc(tokenBasePath+"/introspect", a.handleIntrospect)
mux.HandleFunc(tokenBasePath+"/audit-events", a.handleAuditEvents)
mux.HandleFunc(tokenBasePath+"/", a.handleTokenAction)
}
func (a *TokenAPI) handleTokenAction(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, tokenBasePath+"/") {
writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
return
}
tail := strings.TrimPrefix(r.URL.Path, tokenBasePath+"/")
parts := strings.Split(tail, "/")
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" {
writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
return
}
tokenID := strings.TrimSpace(parts[0])
action := strings.TrimSpace(parts[1])
switch action {
case "refresh":
a.handleRefresh(w, r, tokenID)
case "revoke":
a.handleRevoke(w, r, tokenID)
default:
writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
}
}
type issueRequest struct {
SubjectID string `json:"subject_id"`
Role string `json:"role"`
TTLSeconds int64 `json:"ttl_seconds"`
Scope []string `json:"scope"`
}
type refreshRequest struct {
TTLSeconds int64 `json:"ttl_seconds"`
}
type revokeRequest struct {
Reason string `json:"reason"`
}
type introspectRequest struct {
Token string `json:"token"`
}
type errorEnvelope struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func (a *TokenAPI) handleIssue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if requestID == "" || idempotencyKey == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id or Idempotency-Key")
return
}
var req issueRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
if err := validateIssueRequest(req); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
record, err := a.runtime.IssueAndAudit(r.Context(), service.IssueTokenInput{
SubjectID: req.SubjectID,
Role: req.Role,
Scope: req.Scope,
TTL: time.Duration(req.TTLSeconds) * time.Second,
RequestID: requestID,
IdempotencyKey: idempotencyKey,
}, a.auditor)
if err != nil {
if strings.Contains(err.Error(), "idempotency key payload mismatch") {
writeError(w, http.StatusConflict, "IDEMPOTENCY_CONFLICT", "idempotency key payload mismatch")
return
}
writeError(w, http.StatusUnprocessableEntity, "ISSUE_FAILED", err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"request_id": requestID,
"data": map[string]any{
"token_id": record.TokenID,
"access_token": record.AccessToken,
"issued_at": record.IssuedAt,
"expires_at": record.ExpiresAt,
"status": record.Status,
},
})
}
func (a *TokenAPI) handleRefresh(w http.ResponseWriter, r *http.Request, tokenID string) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if requestID == "" || idempotencyKey == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id or Idempotency-Key")
return
}
var req refreshRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
if req.TTLSeconds < 60 {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "ttl_seconds must be >= 60")
return
}
before, err := a.runtime.Lookup(r.Context(), tokenID)
if err != nil {
before = service.TokenRecord{}
}
record, err := a.runtime.Refresh(r.Context(), tokenID, time.Duration(req.TTLSeconds)*time.Second)
if err != nil {
status, code := mapRuntimeError(err)
writeError(w, status, code, err.Error())
return
}
if a.auditor != nil {
_ = a.auditor.Emit(r.Context(), service.AuditEvent{
EventName: service.EventTokenRefreshSuccess,
RequestID: requestID,
TokenID: record.TokenID,
SubjectID: record.SubjectID,
Route: tokenBasePath + "/" + tokenID + "/refresh",
ResultCode: "OK",
CreatedAt: a.now(),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"request_id": requestID,
"data": map[string]any{
"token_id": record.TokenID,
"previous_expires_at": before.ExpiresAt,
"expires_at": record.ExpiresAt,
"status": record.Status,
},
})
}
func (a *TokenAPI) handleRevoke(w http.ResponseWriter, r *http.Request, tokenID string) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if requestID == "" || idempotencyKey == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id or Idempotency-Key")
return
}
var req revokeRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
if strings.TrimSpace(req.Reason) == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "reason is required")
return
}
introspected, err := a.runtime.Lookup(r.Context(), tokenID)
subjectID := ""
if err == nil {
subjectID = introspected.SubjectID
}
record, err := a.runtime.RevokeAndAudit(r.Context(), tokenID, req.Reason, requestID, subjectID, a.auditor)
if err != nil {
status, code := mapRuntimeError(err)
writeError(w, status, code, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"request_id": requestID,
"data": map[string]any{
"token_id": record.TokenID,
"status": record.Status,
"revoked_at": a.now(),
},
})
}
func (a *TokenAPI) handleIntrospect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
if requestID == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id")
return
}
var req introspectRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
if strings.TrimSpace(req.Token) == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "token is required")
return
}
record, err := a.runtime.Introspect(r.Context(), req.Token)
if err != nil {
if a.auditor != nil {
_ = a.auditor.Emit(r.Context(), service.AuditEvent{
EventName: service.EventTokenIntrospectFail,
RequestID: requestID,
Route: tokenBasePath + "/introspect",
ResultCode: "INVALID_TOKEN",
CreatedAt: a.now(),
})
}
writeError(w, http.StatusUnprocessableEntity, "TOKEN_INVALID", err.Error())
return
}
if a.auditor != nil {
_ = a.auditor.Emit(r.Context(), service.AuditEvent{
EventName: service.EventTokenIntrospectSuccess,
RequestID: requestID,
TokenID: record.TokenID,
SubjectID: record.SubjectID,
Route: tokenBasePath + "/introspect",
ResultCode: "OK",
CreatedAt: a.now(),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"request_id": requestID,
"data": map[string]any{
"token_id": record.TokenID,
"subject_id": record.SubjectID,
"role": record.Role,
"status": record.Status,
"scope": record.Scope,
"issued_at": record.IssuedAt,
"expires_at": record.ExpiresAt,
},
})
}
func (a *TokenAPI) handleAuditEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
if requestID == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id")
return
}
querier, ok := a.auditor.(service.AuditEventQuerier)
if !ok {
writeError(w, http.StatusNotImplemented, "AUDIT_QUERY_NOT_READY", "audit query capability is not available")
return
}
limit := parseLimit(r.URL.Query().Get("limit"))
filter := service.AuditEventFilter{
RequestID: strings.TrimSpace(r.URL.Query().Get("request_id")),
TokenID: strings.TrimSpace(r.URL.Query().Get("token_id")),
SubjectID: strings.TrimSpace(r.URL.Query().Get("subject_id")),
EventName: strings.TrimSpace(r.URL.Query().Get("event_name")),
ResultCode: strings.TrimSpace(r.URL.Query().Get("result_code")),
Limit: limit,
}
events, err := querier.QueryEvents(r.Context(), filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "AUDIT_QUERY_FAILED", err.Error())
return
}
items := make([]map[string]any, 0, len(events))
for _, ev := range events {
items = append(items, map[string]any{
"event_id": ev.EventID,
"event_name": ev.EventName,
"request_id": ev.RequestID,
"token_id": ev.TokenID,
"subject_id": ev.SubjectID,
"route": ev.Route,
"result_code": ev.ResultCode,
"client_ip": ev.ClientIP,
"created_at": ev.CreatedAt,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"request_id": requestID,
"data": map[string]any{
"total": len(items),
"items": items,
},
})
}
func validateIssueRequest(req issueRequest) error {
if strings.TrimSpace(req.SubjectID) == "" {
return errors.New("subject_id is required")
}
if req.TTLSeconds < 60 {
return errors.New("ttl_seconds must be >= 60")
}
if len(req.Scope) == 0 {
return errors.New("scope is required")
}
switch req.Role {
case model.RoleOwner, model.RoleViewer, model.RoleAdmin:
return nil
default:
return fmt.Errorf("unsupported role: %s", req.Role)
}
}
func mapRuntimeError(err error) (int, string) {
msg := err.Error()
switch {
case strings.Contains(msg, "not found"):
return http.StatusNotFound, "TOKEN_NOT_FOUND"
case strings.Contains(msg, "not active"):
return http.StatusConflict, "TOKEN_NOT_ACTIVE"
case strings.Contains(msg, "idempotency key payload mismatch"):
return http.StatusConflict, "IDEMPOTENCY_CONFLICT"
default:
return http.StatusUnprocessableEntity, "BUSINESS_ERROR"
}
}
func decodeJSON(r *http.Request, out any) error {
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(out); err != nil {
return err
}
return nil
}
func writeError(w http.ResponseWriter, status int, code, message string) {
var env errorEnvelope
env.Error.Code = code
env.Error.Message = message
writeJSON(w, status, env)
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func parseLimit(raw string) int {
if strings.TrimSpace(raw) == "" {
return 100
}
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n <= 0 {
return 100
}
if n > 500 {
return 500
}
return n
}

View File

@@ -0,0 +1,269 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"lijiaoqiao/platform-token-runtime/internal/auth/service"
)
func TestTokenAPIIssueAndIntrospect(t *testing.T) {
t.Parallel()
runtime := service.NewInMemoryTokenRuntime(nil)
auditor := service.NewMemoryAuditEmitter()
api := NewTokenAPI(runtime, auditor, func() time.Time {
return time.Date(2026, 3, 30, 15, 50, 0, 0, time.UTC)
})
mux := http.NewServeMux()
api.Register(mux)
issueBody := map[string]any{
"subject_id": "2001",
"role": "owner",
"ttl_seconds": 600,
"scope": []string{"supply:*"},
}
issueReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, issueBody))
issueReq.Header.Set("X-Request-Id", "req-api-001")
issueReq.Header.Set("Idempotency-Key", "idem-api-001")
issueRec := httptest.NewRecorder()
mux.ServeHTTP(issueRec, issueReq)
if issueRec.Code != http.StatusCreated {
t.Fatalf("unexpected issue status: got=%d want=%d body=%s", issueRec.Code, http.StatusCreated, issueRec.Body.String())
}
issueResp := decodeMap(t, issueRec.Body.Bytes())
data := issueResp["data"].(map[string]any)
accessToken := data["access_token"].(string)
if accessToken == "" {
t.Fatalf("access_token should not be empty")
}
introspectBody := map[string]any{"token": accessToken}
introReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/introspect", mustJSON(t, introspectBody))
introReq.Header.Set("X-Request-Id", "req-api-002")
introRec := httptest.NewRecorder()
mux.ServeHTTP(introRec, introReq)
if introRec.Code != http.StatusOK {
t.Fatalf("unexpected introspect status: got=%d want=%d body=%s", introRec.Code, http.StatusOK, introRec.Body.String())
}
introResp := decodeMap(t, introRec.Body.Bytes())
introData := introResp["data"].(map[string]any)
if introData["role"].(string) != "owner" {
t.Fatalf("unexpected role: got=%s want=owner", introData["role"].(string))
}
}
func TestTokenAPIIssueIdempotencyConflict(t *testing.T) {
t.Parallel()
runtime := service.NewInMemoryTokenRuntime(nil)
api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), time.Now)
mux := http.NewServeMux()
api.Register(mux)
firstBody := map[string]any{
"subject_id": "2001",
"role": "owner",
"ttl_seconds": 600,
"scope": []string{"supply:*"},
}
secondBody := map[string]any{
"subject_id": "2001",
"role": "owner",
"ttl_seconds": 600,
"scope": []string{"supply:read"},
}
firstReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, firstBody))
firstReq.Header.Set("X-Request-Id", "req-api-003-1")
firstReq.Header.Set("Idempotency-Key", "idem-api-003")
firstRec := httptest.NewRecorder()
mux.ServeHTTP(firstRec, firstReq)
if firstRec.Code != http.StatusCreated {
t.Fatalf("first issue should succeed: code=%d body=%s", firstRec.Code, firstRec.Body.String())
}
secondReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, secondBody))
secondReq.Header.Set("X-Request-Id", "req-api-003-2")
secondReq.Header.Set("Idempotency-Key", "idem-api-003")
secondRec := httptest.NewRecorder()
mux.ServeHTTP(secondRec, secondReq)
if secondRec.Code != http.StatusConflict {
t.Fatalf("expected idempotency conflict: code=%d body=%s", secondRec.Code, secondRec.Body.String())
}
}
func TestTokenAPIRefreshAndRevoke(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 30, 16, 0, 0, 0, time.UTC)
runtime := service.NewInMemoryTokenRuntime(func() time.Time { return now })
api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), func() time.Time { return now })
mux := http.NewServeMux()
api.Register(mux)
issueReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, map[string]any{
"subject_id": "2008",
"role": "owner",
"ttl_seconds": 120,
"scope": []string{"supply:*"},
}))
issueReq.Header.Set("X-Request-Id", "req-api-004-1")
issueReq.Header.Set("Idempotency-Key", "idem-api-004")
issueRec := httptest.NewRecorder()
mux.ServeHTTP(issueRec, issueReq)
if issueRec.Code != http.StatusCreated {
t.Fatalf("issue failed: code=%d body=%s", issueRec.Code, issueRec.Body.String())
}
issued := decodeMap(t, issueRec.Body.Bytes())
issuedData := issued["data"].(map[string]any)
tokenID := issuedData["token_id"].(string)
now = now.Add(10 * time.Second)
refreshReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/"+tokenID+"/refresh", mustJSON(t, map[string]any{"ttl_seconds": 300}))
refreshReq.Header.Set("X-Request-Id", "req-api-004-2")
refreshReq.Header.Set("Idempotency-Key", "idem-api-004-r")
refreshRec := httptest.NewRecorder()
mux.ServeHTTP(refreshRec, refreshReq)
if refreshRec.Code != http.StatusOK {
t.Fatalf("refresh failed: code=%d body=%s", refreshRec.Code, refreshRec.Body.String())
}
refreshResp := decodeMap(t, refreshRec.Body.Bytes())
refreshData := refreshResp["data"].(map[string]any)
if refreshData["previous_expires_at"] == nil {
t.Fatalf("previous_expires_at must not be nil")
}
revokeReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/"+tokenID+"/revoke", mustJSON(t, map[string]any{"reason": "operator_request"}))
revokeReq.Header.Set("X-Request-Id", "req-api-004-3")
revokeReq.Header.Set("Idempotency-Key", "idem-api-004-v")
revokeRec := httptest.NewRecorder()
mux.ServeHTTP(revokeRec, revokeReq)
if revokeRec.Code != http.StatusOK {
t.Fatalf("revoke failed: code=%d body=%s", revokeRec.Code, revokeRec.Body.String())
}
revokeResp := decodeMap(t, revokeRec.Body.Bytes())
revokeData := revokeResp["data"].(map[string]any)
if revokeData["status"].(string) != "revoked" {
t.Fatalf("unexpected status after revoke: got=%s", revokeData["status"].(string))
}
}
func TestTokenAPIMissingHeaders(t *testing.T) {
t.Parallel()
runtime := service.NewInMemoryTokenRuntime(nil)
api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), time.Now)
mux := http.NewServeMux()
api.Register(mux)
req := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, map[string]any{
"subject_id": "2001",
"role": "owner",
"ttl_seconds": 120,
"scope": []string{"supply:*"},
}))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("missing headers must be rejected: code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestTokenAPIAuditEventsQuery(t *testing.T) {
t.Parallel()
runtime := service.NewInMemoryTokenRuntime(nil)
auditor := service.NewMemoryAuditEmitter()
api := NewTokenAPI(runtime, auditor, time.Now)
mux := http.NewServeMux()
api.Register(mux)
issueReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, map[string]any{
"subject_id": "2010",
"role": "owner",
"ttl_seconds": 300,
"scope": []string{"supply:*"},
}))
issueReq.Header.Set("X-Request-Id", "req-audit-query-1")
issueReq.Header.Set("Idempotency-Key", "idem-audit-query-1")
issueRec := httptest.NewRecorder()
mux.ServeHTTP(issueRec, issueReq)
if issueRec.Code != http.StatusCreated {
t.Fatalf("issue failed: code=%d body=%s", issueRec.Code, issueRec.Body.String())
}
issueResp := decodeMap(t, issueRec.Body.Bytes())
tokenID := issueResp["data"].(map[string]any)["token_id"].(string)
queryReq := httptest.NewRequest(http.MethodGet, "/api/v1/platform/tokens/audit-events?token_id="+tokenID+"&limit=5", nil)
queryReq.Header.Set("X-Request-Id", "req-audit-query-2")
queryRec := httptest.NewRecorder()
mux.ServeHTTP(queryRec, queryReq)
if queryRec.Code != http.StatusOK {
t.Fatalf("audit query failed: code=%d body=%s", queryRec.Code, queryRec.Body.String())
}
resp := decodeMap(t, queryRec.Body.Bytes())
data := resp["data"].(map[string]any)
items := data["items"].([]any)
if len(items) == 0 {
t.Fatalf("audit query should return at least one event")
}
first := items[0].(map[string]any)
if first["token_id"].(string) != tokenID {
t.Fatalf("unexpected token_id in first item: got=%s want=%s", first["token_id"].(string), tokenID)
}
if strings.Contains(queryRec.Body.String(), "access_token") {
t.Fatalf("audit query response must not contain access_token")
}
}
func TestTokenAPIAuditEventsNotReady(t *testing.T) {
t.Parallel()
runtime := service.NewInMemoryTokenRuntime(nil)
api := NewTokenAPI(runtime, noopAuditEmitter{}, time.Now)
mux := http.NewServeMux()
api.Register(mux)
req := httptest.NewRequest(http.MethodGet, "/api/v1/platform/tokens/audit-events?limit=3", nil)
req.Header.Set("X-Request-Id", "req-audit-query-3")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("expected not implemented: code=%d body=%s", rec.Code, rec.Body.String())
}
}
func mustJSON(t *testing.T, payload any) *bytes.Reader {
t.Helper()
buf, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal json failed: %v", err)
}
return bytes.NewReader(buf)
}
func decodeMap(t *testing.T, raw []byte) map[string]any {
t.Helper()
out := map[string]any{}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode json failed: %v, raw=%s", err, string(raw))
}
return out
}
type noopAuditEmitter struct{}
func (noopAuditEmitter) Emit(context.Context, service.AuditEvent) error {
return nil
}

View File

@@ -0,0 +1,295 @@
package token_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"lijiaoqiao/platform-token-runtime/internal/auth/middleware"
"lijiaoqiao/platform-token-runtime/internal/auth/model"
"lijiaoqiao/platform-token-runtime/internal/auth/service"
)
func TestTOKAud001IssueSuccessEvent(t *testing.T) {
t.Parallel()
auditor := service.NewMemoryAuditEmitter()
rt := service.NewInMemoryTokenRuntime(nil)
record, err := rt.IssueAndAudit(context.Background(), service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 10 * time.Minute,
RequestID: "req-aud-001",
}, auditor)
if err != nil {
t.Fatalf("issue with audit failed: %v", err)
}
event, ok := auditor.LastEvent()
if !ok {
t.Fatalf("expected issue success event")
}
if event.EventName != service.EventTokenIssueSuccess {
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenIssueSuccess)
}
assertAuditRequiredFields(t, event)
if event.TokenID != record.TokenID {
t.Fatalf("unexpected token_id in event: got=%s want=%s", event.TokenID, record.TokenID)
}
}
func TestTOKAud002IssueFailEvent(t *testing.T) {
t.Parallel()
auditor := service.NewMemoryAuditEmitter()
rt := service.NewInMemoryTokenRuntime(nil)
_, err := rt.IssueAndAudit(context.Background(), service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 0,
RequestID: "req-aud-002",
}, auditor)
if err == nil {
t.Fatalf("expected issue failure")
}
event, ok := auditor.LastEvent()
if !ok {
t.Fatalf("expected issue fail event")
}
if event.EventName != service.EventTokenIssueFail {
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenIssueFail)
}
assertAuditRequiredFields(t, event)
if event.ResultCode != "ISSUE_FAILED" {
t.Fatalf("unexpected result_code: got=%s want=ISSUE_FAILED", event.ResultCode)
}
}
func TestTOKAud003AuthnFailEvent(t *testing.T) {
t.Parallel()
auditor := service.NewMemoryAuditEmitter()
rt := service.NewInMemoryTokenRuntime(nil)
authorizer := service.NewScopeRoleAuthorizer()
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
Verifier: rt,
StatusResolver: rt,
Authorizer: authorizer,
Auditor: auditor,
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusUnauthorized)
}
event, ok := auditor.LastEvent()
if !ok {
t.Fatalf("expected audit event for authn failure")
}
if event.EventName != service.EventTokenAuthnFail {
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenAuthnFail)
}
if event.RequestID == "" {
t.Fatalf("request_id must not be empty")
}
}
func TestTOKAud004AuthzDeniedEvent(t *testing.T) {
t.Parallel()
auditor := service.NewMemoryAuditEmitter()
rt := service.NewInMemoryTokenRuntime(nil)
authorizer := service.NewScopeRoleAuthorizer()
ctx := context.Background()
viewer, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2002",
Role: model.RoleViewer,
Scope: []string{"supply:read"},
TTL: 5 * time.Minute,
})
if err != nil {
t.Fatalf("issue viewer token failed: %v", err)
}
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
Verifier: rt,
StatusResolver: rt,
Authorizer: authorizer,
Auditor: auditor,
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages", nil)
req.Header.Set("Authorization", "Bearer "+viewer.AccessToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusForbidden)
}
event, ok := auditor.LastEvent()
if !ok {
t.Fatalf("expected audit event for authz denial")
}
if event.EventName != service.EventTokenAuthzDenied {
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenAuthzDenied)
}
if event.SubjectID != viewer.SubjectID {
t.Fatalf("unexpected subject_id: got=%s want=%s", event.SubjectID, viewer.SubjectID)
}
}
func TestTOKAud005RevokeSuccessEvent(t *testing.T) {
t.Parallel()
auditor := service.NewMemoryAuditEmitter()
rt := service.NewInMemoryTokenRuntime(nil)
record, err := rt.Issue(context.Background(), service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 8 * time.Minute,
})
if err != nil {
t.Fatalf("issue token failed: %v", err)
}
_, err = rt.RevokeAndAudit(context.Background(), record.TokenID, "operator_request", "req-aud-005", record.SubjectID, auditor)
if err != nil {
t.Fatalf("revoke with audit failed: %v", err)
}
event, ok := auditor.LastEvent()
if !ok {
t.Fatalf("expected revoke success event")
}
if event.EventName != service.EventTokenRevokeSuccess {
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenRevokeSuccess)
}
assertAuditRequiredFields(t, event)
if event.TokenID != record.TokenID {
t.Fatalf("unexpected token_id in event: got=%s want=%s", event.TokenID, record.TokenID)
}
}
func TestTOKAud006QueryKeyRejectedEvent(t *testing.T) {
t.Parallel()
auditor := service.NewMemoryAuditEmitter()
rt := service.NewInMemoryTokenRuntime(nil)
authorizer := service.NewScopeRoleAuthorizer()
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
Verifier: rt,
StatusResolver: rt,
Authorizer: authorizer,
Auditor: auditor,
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts?api_key=raw-secret-value", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusUnauthorized)
}
event, ok := auditor.LastEvent()
if !ok {
t.Fatalf("expected query key rejection audit event")
}
if event.EventName != service.EventTokenQueryKeyRejected {
t.Fatalf("unexpected event name: got=%s want=%s", event.EventName, service.EventTokenQueryKeyRejected)
}
serialized := strings.Join([]string{
event.EventID,
event.EventName,
event.RequestID,
event.TokenID,
event.SubjectID,
event.Route,
event.ResultCode,
event.ClientIP,
}, "|")
if strings.Contains(serialized, "raw-secret-value") {
t.Fatalf("audit event must not contain raw query key value")
}
}
func TestTOKAud007EventImmutability(t *testing.T) {
t.Parallel()
auditor := service.NewMemoryAuditEmitter()
rt := service.NewInMemoryTokenRuntime(nil)
issued, err := rt.IssueAndAudit(context.Background(), service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 20 * time.Minute,
RequestID: "req-aud-007-1",
}, auditor)
if err != nil {
t.Fatalf("issue with audit failed: %v", err)
}
_, err = rt.RevokeAndAudit(context.Background(), issued.TokenID, "test", "req-aud-007-2", issued.SubjectID, auditor)
if err != nil {
t.Fatalf("revoke with audit failed: %v", err)
}
firstRead := auditor.Events()
secondRead := auditor.Events()
if len(firstRead) < 2 || len(secondRead) < 2 {
t.Fatalf("expected at least two audit events")
}
for idx := range firstRead {
if firstRead[idx].EventID != secondRead[idx].EventID ||
firstRead[idx].EventName != secondRead[idx].EventName ||
!firstRead[idx].CreatedAt.Equal(secondRead[idx].CreatedAt) {
t.Fatalf("event should be immutable across reads at index=%d", idx)
}
}
for idx := 1; idx < len(firstRead); idx++ {
if firstRead[idx].CreatedAt.Before(firstRead[idx-1].CreatedAt) {
t.Fatalf("event timeline should be ordered by created_at")
}
}
}
func assertAuditRequiredFields(t *testing.T, event service.AuditEvent) {
t.Helper()
if event.EventID == "" {
t.Fatalf("event_id must not be empty")
}
if event.RequestID == "" {
t.Fatalf("request_id must not be empty")
}
if event.ResultCode == "" {
t.Fatalf("result_code must not be empty")
}
if event.Route == "" {
t.Fatalf("route must not be empty")
}
if event.CreatedAt.IsZero() {
t.Fatalf("created_at must not be zero")
}
}

View File

@@ -0,0 +1,87 @@
package token_test
import "testing"
type auditTemplateCase struct {
ID string
Name string
TriggerCase string
Assertions []string
}
func TestTokenAuditTemplateCases(t *testing.T) {
t.Parallel()
cases := []auditTemplateCase{
{
ID: "TOK-AUD-001",
Name: "签发成功事件",
TriggerCase: "TOK-LIFE-001",
Assertions: []string{
"存在 token.issue.success",
"event_id/request_id/result_code/route/created_at 齐全",
},
},
{
ID: "TOK-AUD-002",
Name: "签发失败事件",
TriggerCase: "TOK-LIFE-002",
Assertions: []string{
"存在 token.issue.fail",
"result_code 准确",
},
},
{
ID: "TOK-AUD-003",
Name: "鉴权失败事件",
TriggerCase: "无效 token 访问受保护接口",
Assertions: []string{
"存在 token.authn.fail",
"包含 request_id",
},
},
{
ID: "TOK-AUD-004",
Name: "越权事件",
TriggerCase: "TOK-LIFE-008",
Assertions: []string{
"存在 token.authz.denied",
"包含 subject_id",
},
},
{
ID: "TOK-AUD-005",
Name: "吊销事件",
TriggerCase: "TOK-LIFE-005",
Assertions: []string{
"存在 token.revoke.success",
"包含 token_id",
},
},
{
ID: "TOK-AUD-006",
Name: "query key 拒绝事件",
TriggerCase: "query key 访问受保护接口",
Assertions: []string{
"存在 token.query_key.rejected",
"不含敏感值",
},
},
{
ID: "TOK-AUD-007",
Name: "事件不可篡改",
TriggerCase: "重复读取同 event_id",
Assertions: []string{
"核心字段不可变",
"时间顺序正确",
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.ID, func(t *testing.T) {
t.Skipf("模板用例,待接入实现: %s", tc.Name)
})
}
}

View File

@@ -0,0 +1,332 @@
package token_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"lijiaoqiao/platform-token-runtime/internal/auth/middleware"
"lijiaoqiao/platform-token-runtime/internal/auth/model"
"lijiaoqiao/platform-token-runtime/internal/auth/service"
)
func TestTOKLife001IssueSuccess(t *testing.T) {
t.Parallel()
rt := service.NewInMemoryTokenRuntime(nil)
ctx := context.Background()
first, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 30 * time.Minute,
})
if err != nil {
t.Fatalf("issue token failed: %v", err)
}
second, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 30 * time.Minute,
})
if err != nil {
t.Fatalf("issue second token failed: %v", err)
}
if first.Status != service.TokenStatusActive {
t.Fatalf("unexpected status: got=%s want=%s", first.Status, service.TokenStatusActive)
}
if !first.ExpiresAt.After(first.IssuedAt) {
t.Fatalf("expires_at must be greater than issued_at")
}
if first.TokenID == second.TokenID {
t.Fatalf("token_id should be unique")
}
}
func TestTOKLife002IssueInvalidInput(t *testing.T) {
t.Parallel()
rt := service.NewInMemoryTokenRuntime(nil)
ctx := context.Background()
_, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 0,
})
if err == nil {
t.Fatalf("expected error for invalid ttl_seconds")
}
if got := rt.TokenCount(); got != 0 {
t.Fatalf("unexpected token count after invalid issue: got=%d want=0", got)
}
}
func TestTOKLife003IssueIdempotencyReplay(t *testing.T) {
t.Parallel()
rt := service.NewInMemoryTokenRuntime(nil)
ctx := context.Background()
first, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 30 * time.Minute,
IdempotencyKey: "idem-life-003",
})
if err != nil {
t.Fatalf("first issue failed: %v", err)
}
second, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 30 * time.Minute,
IdempotencyKey: "idem-life-003",
})
if err != nil {
t.Fatalf("replay issue failed: %v", err)
}
if first.TokenID != second.TokenID {
t.Fatalf("replayed issue must return same token_id: first=%s second=%s", first.TokenID, second.TokenID)
}
if got := rt.TokenCount(); got != 1 {
t.Fatalf("idempotent replay must not create duplicate token: got=%d want=1", got)
}
_, err = rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:read"},
TTL: 30 * time.Minute,
IdempotencyKey: "idem-life-003",
})
if err == nil {
t.Fatalf("expected payload mismatch conflict for same idempotency key")
}
}
func TestTOKLife004RefreshSuccess(t *testing.T) {
t.Parallel()
rt := service.NewInMemoryTokenRuntime(nil)
ctx := context.Background()
issued, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 1 * time.Minute,
})
if err != nil {
t.Fatalf("issue token failed: %v", err)
}
previousExpiresAt := issued.ExpiresAt
refreshed, err := rt.Refresh(ctx, issued.TokenID, 15*time.Minute)
if err != nil {
t.Fatalf("refresh token failed: %v", err)
}
if refreshed.Status != service.TokenStatusActive {
t.Fatalf("unexpected status after refresh: got=%s want=%s", refreshed.Status, service.TokenStatusActive)
}
if !refreshed.ExpiresAt.After(previousExpiresAt) {
t.Fatalf("expires_at should be delayed after refresh")
}
}
func TestTOKLife005RevokeSuccess(t *testing.T) {
t.Parallel()
start := time.Now()
rt := service.NewInMemoryTokenRuntime(nil)
ctx := context.Background()
issued, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 10 * time.Minute,
})
if err != nil {
t.Fatalf("issue token failed: %v", err)
}
if _, err := rt.Revoke(ctx, issued.TokenID, "security_event"); err != nil {
t.Fatalf("revoke token failed: %v", err)
}
introspected, err := rt.Introspect(ctx, issued.AccessToken)
if err != nil {
t.Fatalf("introspect failed: %v", err)
}
if introspected.Status != service.TokenStatusRevoked {
t.Fatalf("unexpected status after revoke: got=%s want=%s", introspected.Status, service.TokenStatusRevoked)
}
if time.Since(start) > 5*time.Second {
t.Fatalf("revoke propagation exceeded 5 seconds in in-memory runtime")
}
}
func TestTOKLife006RevokedTokenAccessDenied(t *testing.T) {
t.Parallel()
auditor := service.NewMemoryAuditEmitter()
rt := service.NewInMemoryTokenRuntime(nil)
authorizer := service.NewScopeRoleAuthorizer()
ctx := context.Background()
issued, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 5 * time.Minute,
})
if err != nil {
t.Fatalf("issue token failed: %v", err)
}
if _, err := rt.Revoke(ctx, issued.TokenID, "test_revoke"); err != nil {
t.Fatalf("revoke failed: %v", err)
}
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
Verifier: rt,
StatusResolver: rt,
Authorizer: authorizer,
Auditor: auditor,
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts", nil)
req.Header.Set("Authorization", "Bearer "+issued.AccessToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusUnauthorized)
}
if code := decodeMiddlewareErrorCode(t, rec); code != service.CodeAuthTokenInactive {
t.Fatalf("unexpected error code: got=%s want=%s", code, service.CodeAuthTokenInactive)
}
}
func TestTOKLife007ExpiredTokenInactive(t *testing.T) {
t.Parallel()
current := time.Date(2026, 3, 29, 15, 0, 0, 0, time.UTC)
rt := service.NewInMemoryTokenRuntime(func() time.Time { return current })
ctx := context.Background()
issued, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2001",
Role: model.RoleOwner,
Scope: []string{"supply:*"},
TTL: 2 * time.Second,
})
if err != nil {
t.Fatalf("issue token failed: %v", err)
}
current = current.Add(3 * time.Second)
introspected, err := rt.Introspect(ctx, issued.AccessToken)
if err != nil {
t.Fatalf("introspect failed: %v", err)
}
if introspected.Status != service.TokenStatusExpired {
t.Fatalf("unexpected token status: got=%s want=%s", introspected.Status, service.TokenStatusExpired)
}
auditor := service.NewMemoryAuditEmitter()
authorizer := service.NewScopeRoleAuthorizer()
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
Verifier: rt,
StatusResolver: rt,
Authorizer: authorizer,
Auditor: auditor,
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts", nil)
req.Header.Set("Authorization", "Bearer "+issued.AccessToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusUnauthorized)
}
if code := decodeMiddlewareErrorCode(t, rec); code != service.CodeAuthTokenInactive {
t.Fatalf("unexpected error code: got=%s want=%s", code, service.CodeAuthTokenInactive)
}
}
func TestTOKLife008ViewerWriteDenied(t *testing.T) {
t.Parallel()
auditor := service.NewMemoryAuditEmitter()
rt := service.NewInMemoryTokenRuntime(nil)
authorizer := service.NewScopeRoleAuthorizer()
ctx := context.Background()
viewer, err := rt.Issue(ctx, service.IssueTokenInput{
SubjectID: "2002",
Role: model.RoleViewer,
Scope: []string{"supply:read"},
TTL: 10 * time.Minute,
})
if err != nil {
t.Fatalf("issue viewer token failed: %v", err)
}
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusNoContent)
})
handler := middleware.BuildTokenAuthChain(middleware.AuthMiddlewareConfig{
Verifier: rt,
StatusResolver: rt,
Authorizer: authorizer,
Auditor: auditor,
}, next)
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages", nil)
req.Header.Set("Authorization", "Bearer "+viewer.AccessToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("unexpected status code: got=%d want=%d", rec.Code, http.StatusForbidden)
}
if code := decodeMiddlewareErrorCode(t, rec); code != service.CodeAuthScopeDenied {
t.Fatalf("unexpected error code: got=%s want=%s", code, service.CodeAuthScopeDenied)
}
if nextCalled {
t.Fatalf("write handler should be blocked for viewer token")
}
}
type middlewareErrorEnvelope struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
func decodeMiddlewareErrorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var envelope middlewareErrorEnvelope
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode middleware error response: %v", err)
}
return envelope.Error.Code
}

View File

@@ -0,0 +1,132 @@
package token_test
import "testing"
// 说明:
// 1. 本文件保留完整 TOK-LIFE 模板清单作为覆盖基线。
// 2. 首批可执行用例已在 lifecycle_executable_test.go 落地:
// TOK-LIFE-001 / TOK-LIFE-004 / TOK-LIFE-005 / TOK-LIFE-008。
type lifecycleTemplateCase struct {
ID string
Name string
Preconditions []string
Steps []string
Assertions []string
}
func TestTokenLifecycleTemplateCases(t *testing.T) {
t.Parallel()
cases := []lifecycleTemplateCase{
{
ID: "TOK-LIFE-001",
Name: "签发成功",
Preconditions: []string{
"tenant_id=1001",
"subject_owner=2001",
},
Steps: []string{
"调用 POST /api/v1/platform/tokens/issue",
"记录 token_id/issued_at/expires_at/status",
},
Assertions: []string{
"status=active",
"expires_at>issued_at",
"token_id 唯一",
},
},
{
ID: "TOK-LIFE-002",
Name: "签发参数非法",
Preconditions: []string{
"ttl_seconds 超上限",
},
Steps: []string{
"调用 POST /api/v1/platform/tokens/issue",
},
Assertions: []string{
"返回 400",
"不落 active token",
},
},
{
ID: "TOK-LIFE-003",
Name: "幂等签发重放",
Steps: []string{
"相同 Idempotency-Key 重复调用签发接口",
},
Assertions: []string{
"返回同一 token_id",
"无重复写入",
},
},
{
ID: "TOK-LIFE-004",
Name: "续期成功",
Steps: []string{
"调用 POST /api/v1/platform/tokens/{tokenId}/refresh",
},
Assertions: []string{
"expires_at 延后",
"status=active",
},
},
{
ID: "TOK-LIFE-005",
Name: "吊销成功",
Steps: []string{
"调用 POST /api/v1/platform/tokens/{tokenId}/revoke",
"立即调用 introspect 查询状态",
},
Assertions: []string{
"status 最终为 revoked",
"吊销生效延迟 <=5s",
},
},
{
ID: "TOK-LIFE-006",
Name: "吊销后访问受限接口",
Steps: []string{
"使用已吊销 token 访问受保护接口",
},
Assertions: []string{
"返回 401 AUTH_TOKEN_INACTIVE",
},
},
{
ID: "TOK-LIFE-007",
Name: "过期自动失效",
Steps: []string{
"签发短 TTL token",
"等待 token 过期",
"调用 introspect 查询状态",
},
Assertions: []string{
"status=expired",
"返回不可用错误",
},
},
{
ID: "TOK-LIFE-008",
Name: "viewer 越权写操作",
Preconditions: []string{
"viewer scope=supply:read",
},
Steps: []string{
"viewer token 调用写接口",
},
Assertions: []string{
"返回 403 AUTH_SCOPE_DENIED",
"无写入副作用",
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.ID, func(t *testing.T) {
t.Skipf("模板用例,待接入实现: %s", tc.Name)
})
}
}