Vendor dependencies and expose mcproxyctl binary via nix build. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
304 lines
8.5 KiB
Go
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
|
|
}
|