authkit: initial shared auth module
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+287
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user