package auth import ( "bytes" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/hex" "encoding/json" "fmt" "net/http" "os" "strings" "time" ) const cacheTTL = 30 * time.Second // Client communicates with an MCIAS server for authentication and token // validation. It caches successful validation results for 30 seconds. type Client struct { httpClient *http.Client baseURL string serviceName string tags []string cache *validationCache } // NewClient creates an auth Client that talks to the MCIAS server at // serverURL. If caCert is non-empty, it is loaded as a PEM file and // used as the only trusted root CA. TLS 1.3 is required for all HTTPS // connections. // // For plain HTTP URLs (used in tests), TLS configuration is skipped. func NewClient(serverURL, caCert, serviceName string, tags []string) (*Client, error) { transport := &http.Transport{} if !strings.HasPrefix(serverURL, "http://") { tlsCfg := &tls.Config{ MinVersion: tls.VersionTLS13, } if caCert != "" { pem, err := os.ReadFile(caCert) //nolint:gosec // CA cert path is operator-supplied if err != nil { return nil, fmt.Errorf("auth: read CA cert %s: %w", caCert, err) } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("auth: no valid certificates in %s", caCert) } tlsCfg.RootCAs = pool } transport.TLSClientConfig = tlsCfg } return &Client{ httpClient: &http.Client{ Transport: transport, Timeout: 10 * time.Second, }, baseURL: strings.TrimRight(serverURL, "/"), serviceName: serviceName, tags: tags, cache: newCache(cacheTTL), }, nil } // loginRequest is the JSON body sent to MCIAS /v1/auth/login. type loginRequest struct { Username string `json:"username"` Password string `json:"password"` ServiceName string `json:"service_name"` Tags []string `json:"tags,omitempty"` } // loginResponse is the JSON body returned by MCIAS /v1/auth/login. type loginResponse struct { Token string `json:"token"` ExpiresIn int `json:"expires_in"` } // Login authenticates a user against MCIAS and returns a bearer token. func (c *Client) Login(username, password string) (token string, expiresIn int, err error) { body, err := json.Marshal(loginRequest{ //nolint:gosec // G117: password is intentionally sent to MCIAS for authentication Username: username, Password: password, ServiceName: c.serviceName, Tags: c.tags, }) if err != nil { return "", 0, fmt.Errorf("auth: marshal login request: %w", err) } resp, err := c.httpClient.Post( c.baseURL+"/v1/auth/login", "application/json", bytes.NewReader(body), ) if err != nil { return "", 0, fmt.Errorf("auth: MCIAS login: %w", ErrMCIASUnavailable) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", 0, ErrUnauthorized } var lr loginResponse if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil { return "", 0, fmt.Errorf("auth: decode login response: %w", err) } return lr.Token, lr.ExpiresIn, nil } // validateRequest is the JSON body sent to MCIAS /v1/token/validate. type validateRequest struct { Token string `json:"token"` } // validateResponse is the JSON body returned by MCIAS /v1/token/validate. type validateResponse struct { Valid bool `json:"valid"` Claims struct { Subject string `json:"subject"` AccountType string `json:"account_type"` Roles []string `json:"roles"` } `json:"claims"` } // ValidateToken checks a bearer token against MCIAS. Results are cached // by SHA-256 hash for 30 seconds. func (c *Client) ValidateToken(token string) (*Claims, error) { h := sha256.Sum256([]byte(token)) tokenHash := hex.EncodeToString(h[:]) if claims, ok := c.cache.get(tokenHash); ok { return claims, nil } body, err := json.Marshal(validateRequest{Token: token}) if err != nil { return nil, fmt.Errorf("auth: marshal validate request: %w", err) } resp, err := c.httpClient.Post( c.baseURL+"/v1/token/validate", "application/json", bytes.NewReader(body), ) if err != nil { return nil, fmt.Errorf("auth: MCIAS validate: %w", ErrMCIASUnavailable) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, ErrUnauthorized } var vr validateResponse if err := json.NewDecoder(resp.Body).Decode(&vr); err != nil { return nil, fmt.Errorf("auth: decode validate response: %w", err) } if !vr.Valid { return nil, ErrUnauthorized } claims := &Claims{ Subject: vr.Claims.Subject, AccountType: vr.Claims.AccountType, Roles: vr.Claims.Roles, } c.cache.put(tokenHash, claims) return claims, nil }