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