From a44aab26ae83d35d7bf83a4531c6c7fa77702b0d Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 17 May 2026 21:24:34 +0300 Subject: Add TokenManager for API token generation and hashing --- player-server/internal/auth/interfaces.go | 6 +++ player-server/internal/auth/token.go | 39 ++++++++++++++++++ player-server/internal/auth/token_test.go | 66 +++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 player-server/internal/auth/token.go create mode 100644 player-server/internal/auth/token_test.go diff --git a/player-server/internal/auth/interfaces.go b/player-server/internal/auth/interfaces.go index 3b6ea67..790c534 100644 --- a/player-server/internal/auth/interfaces.go +++ b/player-server/internal/auth/interfaces.go @@ -13,3 +13,9 @@ type SessionManager interface { DeleteSession(ctx context.Context, id string) error Cleanup(ctx context.Context) error } + +// TokenManager is the abstraction for API token generation and hashing. +type TokenManager interface { + Generate() (plaintext, hash string) + Hash(plaintext string) string +} diff --git a/player-server/internal/auth/token.go b/player-server/internal/auth/token.go new file mode 100644 index 0000000..bdb7e6b --- /dev/null +++ b/player-server/internal/auth/token.go @@ -0,0 +1,39 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +const tokenByteLength = 32 + +// Compile-time check that *tokenManager satisfies the TokenManager interface. +var _ TokenManager = (*tokenManager)(nil) + +type tokenManager struct{} + +// NewTokenManager creates a TokenManager. +func NewTokenManager() TokenManager { + return &tokenManager{} +} + +// Generate creates a plaintext API token and its stored hash. +func (m *tokenManager) Generate() (plaintext, hash string) { + b := make([]byte, tokenByteLength) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("generate token: %v", err)) + } + + plaintext = hex.EncodeToString(b) + return plaintext, m.Hash(plaintext) +} + +// Hash returns the SHA-256 hash of a plaintext API token. +func (m *tokenManager) Hash(plaintext string) string { + // SHA-256 is used instead of bcrypt because API tokens are high-entropy + // random bytes; bcrypt adds CPU cost without meaningful security benefit. + sum := sha256.Sum256([]byte(plaintext)) + return hex.EncodeToString(sum[:]) +} diff --git a/player-server/internal/auth/token_test.go b/player-server/internal/auth/token_test.go new file mode 100644 index 0000000..bf18aa5 --- /dev/null +++ b/player-server/internal/auth/token_test.go @@ -0,0 +1,66 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestTokenManager_Generate(t *testing.T) { + tm := NewTokenManager() + seen := make(map[string]string) + + for i := 0; i < 1000; i++ { + plaintext, hash := tm.Generate() + if len(plaintext) != tokenByteLength*2 { + t.Fatalf("expected plaintext length %d, got %d", tokenByteLength*2, len(plaintext)) + } + decoded, err := hex.DecodeString(plaintext) + if err != nil { + t.Fatalf("expected hex plaintext: %v", err) + } + if len(decoded) != tokenByteLength { + t.Fatalf("expected %d decoded bytes, got %d", tokenByteLength, len(decoded)) + } + if hash != tm.Hash(plaintext) { + t.Fatal("expected hash to match plaintext") + } + if previousHash, ok := seen[plaintext]; ok { + t.Fatalf("duplicate token generated with hashes %q and %q", previousHash, hash) + } + seen[plaintext] = hash + } +} + +func TestTokenManager_Hash(t *testing.T) { + tm := NewTokenManager() + tests := []struct { + name string + plaintext string + }{ + {"empty token", ""}, + {"hex token", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + {"plain string", "api-token"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash := tm.Hash(tt.plaintext) + if hash == "" { + t.Fatal("expected non-empty hash") + } + if len(hash) != sha256.Size*2 { + t.Fatalf("expected hash length %d, got %d", sha256.Size*2, len(hash)) + } + if _, err := hex.DecodeString(hash); err != nil { + t.Fatalf("expected hex hash: %v", err) + } + if hash != tm.Hash(tt.plaintext) { + t.Fatal("expected deterministic hash") + } + if tt.plaintext != "" && hash == tm.Hash(tt.plaintext+"x") { + t.Fatal("expected different plaintext to have different hash") + } + }) + } +} -- cgit v1.2.3