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)
}
}