authkit: initial shared auth module

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 16:28:33 +02:00
commit 6b1bd7c812
7 changed files with 520 additions and 0 deletions
+287
View File
@@ -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 = <prefix>_<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)
}
}
+28
View File
@@ -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
}
+42
View File
@@ -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 <k>".
// 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
}
+3
View File
@@ -0,0 +1,3 @@
module git.timemachine.center/Timemachine/authkit
go 1.23
+51
View File
@@ -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[:])
}
+51
View File
@@ -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)))
})
}
+58
View File
@@ -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
}