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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user