initial: Steam-Cloud-style per-user state sync skeleton
HTTP API + on-disk storage + auth-service token verification + dev mode. 31 tests pass, vet clean. See DESIGN.md for the architecture and README.md for the operator surface. Pending: pg-backed per-user quota override, snapshot retention / blob GC, tarball-vs-manifest content cross-check, end-to-end deploy on john.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthInfo is what cloud-svc cares about for a verified bearer token.
|
||||
// User is the Discord ID; scopes lets us check that the key actually has
|
||||
// "cloud:rw" rather than some narrower scope used elsewhere.
|
||||
type AuthInfo struct {
|
||||
User string
|
||||
Scopes []string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (a *AuthInfo) HasScope(s string) bool {
|
||||
for _, x := range a.Scopes {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Verifier is the contract the HTTP handlers depend on. Implementations:
|
||||
//
|
||||
// - HTTPVerifier: real auth-service caller
|
||||
// - DevVerifier: trusts any non-empty bearer, returns it as the user ID (tests + local dev)
|
||||
type Verifier interface {
|
||||
Verify(ctx context.Context, token string) (*AuthInfo, error)
|
||||
}
|
||||
|
||||
// HTTPVerifier calls auth-service's /auth/verify-key endpoint. Caches
|
||||
// verified tokens in-memory for ttl to limit upstream pressure.
|
||||
type HTTPVerifier struct {
|
||||
endpoint string
|
||||
serviceKey string // cloud-svc's own service token (X-API-Key when calling auth-service)
|
||||
client *http.Client
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]*AuthInfo
|
||||
}
|
||||
|
||||
func NewHTTPVerifier(endpoint, serviceKey string, ttl time.Duration) *HTTPVerifier {
|
||||
return &HTTPVerifier{
|
||||
endpoint: strings.TrimRight(endpoint, "/"),
|
||||
serviceKey: serviceKey,
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
ttl: ttl,
|
||||
cache: map[string]*AuthInfo{},
|
||||
}
|
||||
}
|
||||
|
||||
func (v *HTTPVerifier) Verify(ctx context.Context, token string) (*AuthInfo, error) {
|
||||
if token == "" {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
|
||||
// Cache hit?
|
||||
v.mu.RLock()
|
||||
cached, ok := v.cache[token]
|
||||
v.mu.RUnlock()
|
||||
if ok && time.Now().Before(cached.expires) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"key": token})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
v.endpoint+"/auth/verify-key", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build verify request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if v.serviceKey != "" {
|
||||
req.Header.Set("X-API-Key", v.serviceKey)
|
||||
}
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth-service unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusUnauthorized:
|
||||
return nil, ErrUnauthenticated
|
||||
case http.StatusForbidden:
|
||||
return nil, ErrRevoked
|
||||
default:
|
||||
return nil, fmt.Errorf("auth-service returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
UserID string `json:"user_id"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode auth response: %w", err)
|
||||
}
|
||||
if out.UserID == "" {
|
||||
return nil, fmt.Errorf("auth-service returned empty user_id")
|
||||
}
|
||||
|
||||
info := &AuthInfo{
|
||||
User: out.UserID,
|
||||
Scopes: out.Scopes,
|
||||
expires: time.Now().Add(v.ttl),
|
||||
}
|
||||
v.mu.Lock()
|
||||
v.cache[token] = info
|
||||
v.mu.Unlock()
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// InvalidateToken drops a token from the cache (e.g. on 401 from a client
|
||||
// re-trying with a token the cache says is still good).
|
||||
func (v *HTTPVerifier) InvalidateToken(token string) {
|
||||
v.mu.Lock()
|
||||
delete(v.cache, token)
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
// DevVerifier accepts any non-empty token and returns it as the user ID with
|
||||
// cloud:rw scope. Use for local dev / tests only; production wires
|
||||
// HTTPVerifier.
|
||||
type DevVerifier struct{}
|
||||
|
||||
func (DevVerifier) Verify(_ context.Context, token string) (*AuthInfo, error) {
|
||||
if token == "" {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
return &AuthInfo{
|
||||
User: token,
|
||||
Scopes: []string{"cloud:rw"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrRevoked = errors.New("token revoked")
|
||||
)
|
||||
|
||||
// extractBearer pulls a token from `Authorization: Bearer <token>` headers.
|
||||
// Returns "" if not present.
|
||||
func extractBearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(h, prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
Reference in New Issue
Block a user