summaryrefslogtreecommitdiff
path: root/player-server/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-17 21:24:34 +0300
committerPaul Buetow <paul@buetow.org>2026-05-17 21:24:34 +0300
commita44aab26ae83d35d7bf83a4531c6c7fa77702b0d (patch)
tree7bf4c922c12e268e9563090ff0fa4fdbc0f3f563 /player-server/internal
parenta7fbe6d5008decde1e2402b422300d83d3a3de0e (diff)
Add TokenManager for API token generation and hashing
Diffstat (limited to 'player-server/internal')
-rw-r--r--player-server/internal/auth/interfaces.go6
-rw-r--r--player-server/internal/auth/token.go39
-rw-r--r--player-server/internal/auth/token_test.go66
3 files changed, 111 insertions, 0 deletions
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")
+ }
+ })
+ }
+}