Files
cloud-svc/auth.go
T
claude-timemachine 1752ef05a6
CI / validate (push) Successful in 26s
CI / docker (push) Failing after 8s
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.
2026-06-02 18:52:25 +02:00

161 lines
4.0 KiB
Go

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