From 6b1bd7c8122263403e9e4a953e4a14e8128ab3ef Mon Sep 17 00:00:00 2001 From: claude-timemachine Date: Thu, 18 Jun 2026 16:28:33 +0200 Subject: [PATCH] authkit: initial shared auth module Co-Authored-By: Claude Opus 4.8 --- authkit_test.go | 287 ++++++++++++++++++++++++++++++++++++++++++++++++ caller.go | 28 +++++ extract.go | 42 +++++++ go.mod | 3 + keys.go | 51 +++++++++ middleware.go | 51 +++++++++ scopes.go | 58 ++++++++++ 7 files changed, 520 insertions(+) create mode 100644 authkit_test.go create mode 100644 caller.go create mode 100644 extract.go create mode 100644 go.mod create mode 100644 keys.go create mode 100644 middleware.go create mode 100644 scopes.go diff --git a/authkit_test.go b/authkit_test.go new file mode 100644 index 0000000..5dbe392 --- /dev/null +++ b/authkit_test.go @@ -0,0 +1,287 @@ +package authkit + +import ( + "context" + "encoding/hex" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// --- Group 1: ExtractAPIKey --- + +func TestExtractAPIKey_XHeader(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-API-Key", "mykey") + if got := ExtractAPIKey(r); got != "mykey" { + t.Fatalf("got %q want %q", got, "mykey") + } +} + +func TestExtractAPIKey_Bearer(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", "Bearer bearerkey") + if got := ExtractAPIKey(r); got != "bearerkey" { + t.Fatalf("got %q want %q", got, "bearerkey") + } +} + +func TestExtractAPIKey_XHeaderWinsOverBearer(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-API-Key", "xkey") + r.Header.Set("Authorization", "Bearer bearerkey") + if got := ExtractAPIKey(r); got != "xkey" { + t.Fatalf("got %q want %q", got, "xkey") + } +} + +func TestExtractAPIKey_None(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + if got := ExtractAPIKey(r); got != "" { + t.Fatalf("got %q want empty", got) + } +} + +func TestExtractAPIKey_NonBearer(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + if got := ExtractAPIKey(r); got != "" { + t.Fatalf("got %q want empty", got) + } +} + +// --- Group 2: ConstantTimeEqualString --- + +func TestConstantTimeEqualString_Match(t *testing.T) { + if !ConstantTimeEqualString("abc", "abc") { + t.Fatal("expected true") + } +} + +func TestConstantTimeEqualString_MismatchSameLen(t *testing.T) { + if ConstantTimeEqualString("abc", "abd") { + t.Fatal("expected false") + } +} + +func TestConstantTimeEqualString_DifferentLen(t *testing.T) { + if ConstantTimeEqualString("abc", "abcd") { + t.Fatal("expected false") + } +} + +// --- Group 3: ConstantTimeEqualHashedKey --- + +func TestConstantTimeEqualHashedKey_Match(t *testing.T) { + plain := "sm_" + strings.Repeat("ff", 24) + h := HashKey(plain) + if !ConstantTimeEqualHashedKey(plain, h) { + t.Fatal("expected true") + } +} + +func TestConstantTimeEqualHashedKey_BadHex(t *testing.T) { + if ConstantTimeEqualHashedKey("anything", "notvalidhex!!") { + t.Fatal("expected false on bad hex") + } +} + +// --- Group 4: GenerateAPIKey --- + +func TestGenerateAPIKey_Format(t *testing.T) { + for _, svc := range []string{"bot", "server-manager", "portal", "addon", "mc-wrapper", "gate-waker", "cloud-svc"} { + pt, h, pfx, err := GenerateAPIKey(svc) + if err != nil { + t.Fatalf("service %q: %v", svc, err) + } + // plaintext = _<48hex> + idx := strings.Index(pt, "_") + if idx < 0 { + t.Fatalf("service %q: no underscore in %q", svc, pt) + } + hexPart := pt[idx+1:] + if len(hexPart) != 48 { + t.Fatalf("service %q: hex part len=%d want 48", svc, len(hexPart)) + } + if _, err := hex.DecodeString(hexPart); err != nil { + t.Fatalf("service %q: hex part not valid hex: %v", svc, err) + } + // hash = sha256(plaintext) + if h != HashKey(pt) { + t.Fatalf("service %q: hash mismatch", svc) + } + // prefix = plaintext[:8] + if pfx != pt[:8] { + t.Fatalf("service %q: prefix %q want %q", svc, pfx, pt[:8]) + } + } +} + +func TestGenerateAPIKey_UnknownService(t *testing.T) { + _, _, _, err := GenerateAPIKey("nonexistent") + if err == nil { + t.Fatal("expected error for unknown service") + } +} + +// --- Group 5: DefaultScopes + HasScope --- + +func TestDefaultScopes_Golden(t *testing.T) { + cases := []struct { + service string + want []string + }{ + {"bot", []string{"user:*", "login:manage", "kick", "events:subscribe", "keys:read"}}, + {"server-manager", []string{"events:publish", "keys:provision", "keys:read"}}, + {"portal", []string{"user:read", "server:read"}}, + {"gate-waker", []string{"user:read", "server:read"}}, + {"addon", []string{"user:read", "user:stats", "server:read", "player:disconnect", "login:request", "events:subscribe"}}, + {"mc-wrapper", []string{"user:read", "server:read", "server:state", "events:subscribe"}}, + } + for _, tc := range cases { + got := DefaultScopes(tc.service) + if len(got) != len(tc.want) { + t.Fatalf("service %q: scopes=%v want=%v", tc.service, got, tc.want) + } + for i, s := range got { + if s != tc.want[i] { + t.Fatalf("service %q scope[%d]: got %q want %q", tc.service, i, s, tc.want[i]) + } + } + } +} + +func TestDefaultScopes_Unknown(t *testing.T) { + if DefaultScopes("unknown") != nil { + t.Fatal("expected nil for unknown service") + } +} + +func TestHasScope_ExactMatch(t *testing.T) { + if !HasScope([]string{"server:read", "kick"}, "kick") { + t.Fatal("expected true") + } +} + +func TestHasScope_WildcardMatch(t *testing.T) { + if !HasScope([]string{"user:*"}, "user:read") { + t.Fatal("user:* should match user:read") + } + if !HasScope([]string{"user:*"}, "user:stats") { + t.Fatal("user:* should match user:stats") + } +} + +func TestHasScope_WildcardNoMatch(t *testing.T) { + if HasScope([]string{"user:*"}, "server:read") { + t.Fatal("user:* should not match server:read") + } +} + +func TestHasScope_NotPresent(t *testing.T) { + if HasScope([]string{"server:read"}, "kick") { + t.Fatal("expected false") + } +} + +// --- Group 6: RequireScope middleware --- + +func makeResolver(c *Caller, err error) KeyResolver { + return func(_ context.Context, _ string) (*Caller, error) { + return c, err + } +} + +func TestRequireScope_MissingKey(t *testing.T) { + h := RequireScope("kick", makeResolver(&Caller{Scopes: []string{"kick"}}, nil), nil, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + h.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("got %d want 401", w.Code) + } + if !strings.Contains(w.Body.String(), "missing api key") { + t.Fatalf("body: %q", w.Body.String()) + } +} + +func TestRequireScope_InvalidKey(t *testing.T) { + h := RequireScope("kick", makeResolver(nil, nil), nil, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-API-Key", "bad") + h.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("got %d want 401", w.Code) + } + if !strings.Contains(w.Body.String(), "invalid api key") { + t.Fatalf("body: %q", w.Body.String()) + } +} + +func TestRequireScope_ForbiddenScope(t *testing.T) { + caller := &Caller{Service: "addon", Scopes: []string{"user:read"}} + h := RequireScope("kick", makeResolver(caller, nil), nil, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-API-Key", "somekey") + h.ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Fatalf("got %d want 403", w.Code) + } + if !strings.Contains(w.Body.String(), "missing scope kick") { + t.Fatalf("body: %q", w.Body.String()) + } +} + +func TestRequireScope_OK(t *testing.T) { + caller := &Caller{Service: "bot", Scopes: []string{"kick"}} + var ctxCaller *Caller + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctxCaller, _ = CallerFromContext(r.Context()) + w.WriteHeader(200) + }) + h := RequireScope("kick", makeResolver(caller, nil), nil, next) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-API-Key", "validkey") + h.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatalf("got %d want 200", w.Code) + } + if ctxCaller == nil || ctxCaller.Service != "bot" { + t.Fatal("caller not stored in context") + } +} + +func TestRequireScope_ResolverError(t *testing.T) { + h := RequireScope("kick", makeResolver(nil, context.DeadlineExceeded), nil, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-API-Key", "somekey") + h.ServeHTTP(w, r) + if w.Code != http.StatusInternalServerError { + t.Fatalf("got %d want 500", w.Code) + } + if !strings.Contains(w.Body.String(), "internal error") { + t.Fatalf("body: %q", w.Body.String()) + } +} + +// --- Group 7: Cross-language vector --- + +const crossLangVector = "8888239ebe0105baac4d0e32428b4ea868d4fe504ce374feae74a15326087925" + +func TestCrossLanguageVector(t *testing.T) { + input := "mcw_" + strings.Repeat("ab", 24) + got := HashKey(input) + t.Logf("cross-language vector: HashKey(%q) = %s", input, got) + if got != crossLangVector { + t.Fatalf("got %s want %s", got, crossLangVector) + } +} diff --git a/caller.go b/caller.go new file mode 100644 index 0000000..f8cdc11 --- /dev/null +++ b/caller.go @@ -0,0 +1,28 @@ +package authkit + +import "context" + +// Caller carries the identity of an authenticated API-key caller. +type Caller struct { + Service string + ServerName string + Scopes []string +} + +// Has reports whether the caller has the given scope. +func (c *Caller) Has(scope string) bool { + return HasScope(c.Scopes, scope) +} + +type ctxKeyCaller struct{} + +// WithCaller stores c in ctx under the authkit caller key. +func WithCaller(ctx context.Context, c *Caller) context.Context { + return context.WithValue(ctx, ctxKeyCaller{}, c) +} + +// CallerFromContext retrieves the Caller stored by WithCaller. +func CallerFromContext(ctx context.Context) (*Caller, bool) { + c, ok := ctx.Value(ctxKeyCaller{}).(*Caller) + return c, ok +} diff --git a/extract.go b/extract.go new file mode 100644 index 0000000..c400ce1 --- /dev/null +++ b/extract.go @@ -0,0 +1,42 @@ +package authkit + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "net/http" + "strings" +) + +// ExtractAPIKey pulls the API key from r. +// Checks X-API-Key first, then "Authorization: Bearer ". +// Returns "" if neither is present. +func ExtractAPIKey(r *http.Request) string { + if v := strings.TrimSpace(r.Header.Get("X-API-Key")); v != "" { + return v + } + auth := strings.TrimSpace(r.Header.Get("Authorization")) + if strings.HasPrefix(auth, "Bearer ") { + return strings.TrimSpace(auth[len("Bearer "):]) + } + return "" +} + +// ConstantTimeEqualString compares a and b in constant time. +func ConstantTimeEqualString(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// ConstantTimeEqualHashedKey hashes candidatePlain with sha256 and compares +// against storedHashHex in constant time. Returns false on bad hex; never panics. +func ConstantTimeEqualHashedKey(candidatePlain, storedHashHex string) bool { + stored, err := hex.DecodeString(storedHashHex) + if err != nil || len(stored) != sha256.Size { + return false + } + sum := sha256.Sum256([]byte(candidatePlain)) + return subtle.ConstantTimeCompare(sum[:], stored) == 1 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0e872f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.timemachine.center/Timemachine/authkit + +go 1.23 diff --git a/keys.go b/keys.go new file mode 100644 index 0000000..472223f --- /dev/null +++ b/keys.go @@ -0,0 +1,51 @@ +package authkit + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +var servicePrefixes = map[string]string{ + "bot": "bot", + "server-manager": "sm", + "portal": "ptl", + "addon": "adn", + "mc-wrapper": "mcw", + "gate-waker": "gwk", + "cloud-svc": "cld", +} + +// PrefixFor returns the key prefix for a service name. +func PrefixFor(service string) (string, bool) { + p, ok := servicePrefixes[service] + return p, ok +} + +// GenerateAPIKey mints a new API key for the given service. +// Returns plaintext, sha256-hex hash, and the first-8-char prefix. +// Unknown service or crypto/rand failure returns a non-nil error. +func GenerateAPIKey(service string) (plaintext, hash, prefix string, err error) { + pfx, ok := servicePrefixes[service] + if !ok { + return "", "", "", fmt.Errorf("authkit: unknown service %q", service) + } + + var raw [24]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", "", "", fmt.Errorf("authkit: crypto/rand: %w", err) + } + + hexPart := hex.EncodeToString(raw[:]) + plaintext = pfx + "_" + hexPart + hash = HashKey(plaintext) + prefix = plaintext[:8] + return plaintext, hash, prefix, nil +} + +// HashKey returns the sha256 hex digest of plaintext. +func HashKey(plaintext string) string { + sum := sha256.Sum256([]byte(plaintext)) + return hex.EncodeToString(sum[:]) +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..3105582 --- /dev/null +++ b/middleware.go @@ -0,0 +1,51 @@ +package authkit + +import ( + "context" + "fmt" + "net/http" +) + +// ErrorWriter writes an auth failure response. +type ErrorWriter func(w http.ResponseWriter, status int, msg string) + +// PlainError is the default ErrorWriter; writes plain text. +func PlainError(w http.ResponseWriter, status int, msg string) { + http.Error(w, msg, status) +} + +// KeyResolver maps a plaintext key to a Caller. +// Return (nil, nil) for an unknown/invalid key (→ 401). +// Return a non-nil error only for an internal failure (→ 500). +type KeyResolver func(ctx context.Context, plaintextKey string) (*Caller, error) + +// RequireScope enforces that the request carries a valid key with the given +// scope. On success it stores the Caller in the request context and calls next. +func RequireScope(scope string, resolve KeyResolver, errw ErrorWriter, next http.Handler) http.Handler { + if errw == nil { + errw = PlainError + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := ExtractAPIKey(r) + if key == "" { + errw(w, http.StatusUnauthorized, "missing api key") + return + } + + caller, err := resolve(r.Context(), key) + if err != nil { + errw(w, http.StatusInternalServerError, "internal error") + return + } + if caller == nil { + errw(w, http.StatusUnauthorized, "invalid api key") + return + } + if !caller.Has(scope) { + errw(w, http.StatusForbidden, fmt.Sprintf("forbidden: missing scope %s", scope)) + return + } + + next.ServeHTTP(w, r.WithContext(WithCaller(r.Context(), caller))) + }) +} diff --git a/scopes.go b/scopes.go new file mode 100644 index 0000000..08e8ea1 --- /dev/null +++ b/scopes.go @@ -0,0 +1,58 @@ +package authkit + +import "strings" + +const ( + ScopeUserRead = "user:read" + ScopeUserWrite = "user:write" + ScopeUserStats = "user:stats" + ScopeUserPassword = "user:password" + ScopeLoginManage = "login:manage" + ScopeLoginRequest = "login:request" + ScopeKick = "kick" + ScopePlayerDisconnect = "player:disconnect" + ScopeEventsPublish = "events:publish" + ScopeEventsSubscribe = "events:subscribe" + ScopeServerRead = "server:read" + ScopeServerState = "server:state" + ScopeKeysProvision = "keys:provision" + ScopeKeysRead = "keys:read" +) + +var defaultScopes = map[string][]string{ + "bot": {"user:*", "login:manage", "kick", "events:subscribe", "keys:read"}, + "server-manager": {"events:publish", "keys:provision", "keys:read"}, + "portal": {"user:read", "server:read"}, + "gate-waker": {"user:read", "server:read"}, + "addon": {"user:read", "user:stats", "server:read", "player:disconnect", "login:request", "events:subscribe"}, + "mc-wrapper": {"user:read", "server:read", "server:state", "events:subscribe"}, +} + +// DefaultScopes returns the canonical scope set for a service, or nil for unknown. +func DefaultScopes(service string) []string { + s, ok := defaultScopes[service] + if !ok { + return nil + } + out := make([]string, len(s)) + copy(out, s) + return out +} + +// HasScope reports whether want is satisfied by the scopes slice. +// A granted scope ending in "*" matches any want sharing the same prefix +// (e.g. "user:*" matches "user:read"). +func HasScope(scopes []string, want string) bool { + for _, g := range scopes { + if g == want { + return true + } + if strings.HasSuffix(g, "*") { + pfx := g[:len(g)-1] + if strings.HasPrefix(want, pfx) { + return true + } + } + } + return false +}