// Package auth provides MCIAS authentication integration with token caching. package auth import ( "crypto/sha256" "encoding/hex" "errors" "log/slog" "sync" "time" mcias "git.wntrmute.dev/kyle/mcias/clients/go" ) var ( ErrInvalidCredentials = errors.New("auth: invalid credentials") ErrInvalidToken = errors.New("auth: invalid token") ) const tokenCacheTTL = 30 * time.Second // TokenInfo holds validated token information. type TokenInfo struct { Username string Roles []string IsAdmin bool } // cachedClaims holds a cached token validation result. type cachedClaims struct { info *TokenInfo expiresAt time.Time } // Authenticator provides MCIAS-backed authentication. type Authenticator struct { client *mcias.Client logger *slog.Logger mu sync.RWMutex cache map[string]*cachedClaims // keyed by SHA-256(token) } // NewAuthenticator creates a new authenticator with the given MCIAS client. func NewAuthenticator(client *mcias.Client, logger *slog.Logger) *Authenticator { return &Authenticator{ client: client, logger: logger, cache: make(map[string]*cachedClaims), } } // Login authenticates a user via MCIAS and returns the token. func (a *Authenticator) Login(username, password, totpCode string) (token string, expiresAt string, err error) { a.logger.Debug("login attempt", "username", username) tok, exp, err := a.client.Login(username, password, totpCode) if err != nil { var authErr *mcias.MciasAuthError if errors.As(err, &authErr) { a.logger.Debug("login failed: invalid credentials", "username", username) return "", "", ErrInvalidCredentials } a.logger.Debug("login failed", "username", username, "error", err) return "", "", err } a.logger.Debug("login succeeded", "username", username) return tok, exp, nil } // ValidateToken validates a bearer token, using a short-lived cache. func (a *Authenticator) ValidateToken(token string) (*TokenInfo, error) { key := tokenHash(token) // Check cache. a.mu.RLock() cached, ok := a.cache[key] a.mu.RUnlock() if ok && time.Now().Before(cached.expiresAt) { a.logger.Debug("token validated from cache") return cached.info, nil } a.logger.Debug("validating token with MCIAS") // Validate with MCIAS. claims, err := a.client.ValidateToken(token) if err != nil { a.logger.Debug("token validation failed", "error", err) return nil, err } if !claims.Valid { a.logger.Debug("token invalid per MCIAS") return nil, ErrInvalidToken } info := &TokenInfo{ Username: claims.Sub, Roles: claims.Roles, IsAdmin: hasAdminRole(claims.Roles), } // Cache the result. a.mu.Lock() a.cache[key] = &cachedClaims{ info: info, expiresAt: time.Now().Add(tokenCacheTTL), } a.mu.Unlock() a.logger.Debug("token validated and cached", "username", info.Username, "is_admin", info.IsAdmin) return info, nil } // Logout invalidates a token via MCIAS. The client must have the token set. func (a *Authenticator) Logout(client *mcias.Client) error { return client.Logout() } // ClearCache removes all cached token validations. func (a *Authenticator) ClearCache() { a.logger.Debug("clearing token cache") a.mu.Lock() a.cache = make(map[string]*cachedClaims) a.mu.Unlock() } func tokenHash(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) } func hasAdminRole(roles []string) bool { for _, r := range roles { if r == "admin" { return true } } return false }