// 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 } // 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 }