Files
mcdsl/auth/auth.go
Kyle Isom 4c7610ce6b Add ClearCache to Authenticator
Used by Metacrypt when sealing to invalidate cached token validations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:42:51 -07:00

304 lines
8.5 KiB
Go

// Package auth provides MCIAS token validation with caching for
// Metacircular services.
//
// Every Metacircular service delegates authentication to MCIAS. This
// package handles the login flow, token validation (with a 30-second
// SHA-256-keyed cache), and logout. It communicates directly with the
// MCIAS REST API.
//
// Security: bearer tokens are never logged or included in error messages.
package auth
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
)
const cacheTTL = 30 * time.Second
// Errors returned by the Authenticator.
var (
// ErrInvalidToken indicates the token is expired, revoked, or otherwise
// invalid.
ErrInvalidToken = errors.New("auth: invalid token")
// ErrInvalidCredentials indicates that the username/password combination
// was rejected by MCIAS.
ErrInvalidCredentials = errors.New("auth: invalid credentials")
// ErrForbidden indicates that MCIAS login policy denied access to this
// service (HTTP 403).
ErrForbidden = errors.New("auth: forbidden by policy")
// ErrUnavailable indicates that MCIAS could not be reached.
ErrUnavailable = errors.New("auth: MCIAS unavailable")
)
// Config holds MCIAS connection settings. This matches the standard [mcias]
// TOML section used by all Metacircular services.
type Config struct {
// ServerURL is the base URL of the MCIAS server
// (e.g., "https://mcias.metacircular.net:8443").
ServerURL string `toml:"server_url"`
// CACert is an optional path to a PEM-encoded CA certificate for
// verifying the MCIAS server's TLS certificate.
CACert string `toml:"ca_cert"`
// ServiceName is this service's identity as registered in MCIAS. It is
// sent with every login request so MCIAS can evaluate service-context
// login policy rules.
ServiceName string `toml:"service_name"`
// Tags are sent with every login request. MCIAS evaluates auth:login
// policy against these tags (e.g., ["env:restricted"]).
Tags []string `toml:"tags"`
}
// TokenInfo holds the validated identity of an authenticated caller.
type TokenInfo struct {
// Username is the MCIAS username (the "sub" claim).
Username string
// AccountType is the MCIAS account type: "human" or "system".
// Used by policy engines that need to distinguish interactive users
// from service accounts.
AccountType string
// Roles is the set of MCIAS roles assigned to the account.
Roles []string
// IsAdmin is true if the account has the "admin" role.
IsAdmin bool
}
// Authenticator validates MCIAS bearer tokens with a short-lived cache.
type Authenticator struct {
httpClient *http.Client
baseURL string
serviceName string
tags []string
logger *slog.Logger
cache *validationCache
}
// New creates an Authenticator that talks to the MCIAS server described
// by cfg. TLS 1.3 is required for all HTTPS connections. If cfg.CACert
// is set, that CA certificate is added to the trust pool.
//
// For plain HTTP URLs (used in tests), TLS configuration is skipped.
func New(cfg Config, logger *slog.Logger) (*Authenticator, error) {
if cfg.ServerURL == "" {
return nil, fmt.Errorf("auth: server_url is required")
}
transport := &http.Transport{}
if !strings.HasPrefix(cfg.ServerURL, "http://") {
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if cfg.CACert != "" {
pem, err := os.ReadFile(cfg.CACert) //nolint:gosec // CA cert path from operator config
if err != nil {
return nil, fmt.Errorf("auth: read CA cert %s: %w", cfg.CACert, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("auth: no valid certificates in %s", cfg.CACert)
}
tlsCfg.RootCAs = pool
}
transport.TLSClientConfig = tlsCfg
}
return &Authenticator{
httpClient: &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
},
baseURL: strings.TrimRight(cfg.ServerURL, "/"),
serviceName: cfg.ServiceName,
tags: cfg.Tags,
logger: logger,
cache: newCache(cacheTTL),
}, nil
}
// Login authenticates a user against MCIAS and returns a bearer token.
// totpCode may be empty for accounts without TOTP configured.
//
// The service name and tags from Config are included in the login request
// so MCIAS can evaluate service-context login policy.
func (a *Authenticator) Login(username, password, totpCode string) (token string, expiresAt time.Time, err error) {
reqBody := map[string]interface{}{
"username": username,
"password": password,
}
if totpCode != "" {
reqBody["totp_code"] = totpCode
}
if a.serviceName != "" {
reqBody["service_name"] = a.serviceName
}
if len(a.tags) > 0 {
reqBody["tags"] = a.tags
}
var resp struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
status, err := a.doJSON(http.MethodPost, "/v1/auth/login", reqBody, &resp)
if err != nil {
return "", time.Time{}, fmt.Errorf("auth: MCIAS login: %w", ErrUnavailable)
}
switch status {
case http.StatusOK:
// Parse the expiry time.
exp, parseErr := time.Parse(time.RFC3339, resp.ExpiresAt)
if parseErr != nil {
exp = time.Now().Add(1 * time.Hour) // fallback
}
return resp.Token, exp, nil
case http.StatusForbidden:
return "", time.Time{}, ErrForbidden
default:
return "", time.Time{}, ErrInvalidCredentials
}
}
// ValidateToken checks a bearer token against MCIAS. Results are cached
// by the SHA-256 hash of the token for 30 seconds.
//
// Returns ErrInvalidToken if the token is expired, revoked, or otherwise
// not valid.
func (a *Authenticator) ValidateToken(token string) (*TokenInfo, error) {
h := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(h[:])
if info, ok := a.cache.get(tokenHash); ok {
return info, nil
}
var resp struct {
Valid bool `json:"valid"`
Sub string `json:"sub"`
Username string `json:"username"`
AccountType string `json:"account_type"`
Roles []string `json:"roles"`
}
status, err := a.doJSON(http.MethodPost, "/v1/token/validate",
map[string]string{"token": token}, &resp)
if err != nil {
return nil, fmt.Errorf("auth: MCIAS validate: %w", ErrUnavailable)
}
if status != http.StatusOK || !resp.Valid {
return nil, ErrInvalidToken
}
info := &TokenInfo{
Username: resp.Username,
AccountType: resp.AccountType,
Roles: resp.Roles,
IsAdmin: hasRole(resp.Roles, "admin"),
}
if info.Username == "" {
info.Username = resp.Sub
}
a.cache.put(tokenHash, info)
return info, nil
}
// ClearCache removes all cached token validation results. This should be
// called when the service transitions to a state where cached tokens may
// no longer be valid (e.g., Metacrypt sealing).
func (a *Authenticator) ClearCache() {
a.cache.clear()
}
// Logout revokes a token on the MCIAS server.
func (a *Authenticator) Logout(token string) error {
req, err := http.NewRequestWithContext(context.Background(),
http.MethodPost, a.baseURL+"/v1/auth/logout", nil)
if err != nil {
return fmt.Errorf("auth: build logout request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("auth: MCIAS logout: %w", ErrUnavailable)
}
_ = resp.Body.Close()
return nil
}
// doJSON makes a JSON request to the MCIAS server and decodes the response.
// It returns the HTTP status code and any transport error.
func (a *Authenticator) doJSON(method, path string, body, out interface{}) (int, error) {
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(context.Background(),
method, a.baseURL+path, reqBody)
if err != nil {
return 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return 0, err
}
defer func() { _ = resp.Body.Close() }()
if out != nil && resp.StatusCode == http.StatusOK {
respBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return resp.StatusCode, fmt.Errorf("read response: %w", readErr)
}
if len(respBytes) > 0 {
if decErr := json.Unmarshal(respBytes, out); decErr != nil {
return resp.StatusCode, fmt.Errorf("decode response: %w", decErr)
}
}
}
return resp.StatusCode, nil
}
func hasRole(roles []string, target string) bool {
for _, r := range roles {
if r == target {
return true
}
}
return false
}