1752ef05a6
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.
161 lines
4.0 KiB
Go
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):])
|
|
}
|