Migrate db, auth to mcdsl; remove mcias client dependency
- db.Open: delegate to mcdsl/db.Open - db.Migrate: convert to mcdsl/db.Migration format, delegate - auth: type aliases for TokenInfo/Authenticator/Config from mcdsl, re-export error sentinels, Logout helper - cmd/server: construct auth.Authenticator from Config (not mcias.Client) - server/routes.go logout: use auth.Logout(authenticator, token) - grpcserver/auth.go: same logout pattern, fix Login return type (time.Time not string) - webserver: replace mcias.Client with mcdsl/auth for service token validation; resolveUser degrades to raw UUID (TODO: restore when mcias client library is properly tagged) - Dockerfiles: bump to golang:1.25-alpine, remove gcc/musl-dev, add VERSION build arg - Deploy: add docker-compose-rift.yml with localhost-only port mapping - Remove git.wntrmute.dev/kyle/mcias/clients/go dependency entirely - All tests pass, net -185 lines Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,137 +1,49 @@
|
||||
// Package auth provides MCIAS authentication integration with token caching.
|
||||
// Package auth provides MCIAS authentication integration, delegating to
|
||||
// the mcdsl/auth package for token validation with caching.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
||||
)
|
||||
|
||||
// TokenInfo is an alias for the mcdsl auth.TokenInfo type.
|
||||
type TokenInfo = mcdslauth.TokenInfo
|
||||
|
||||
// Authenticator is an alias for the mcdsl auth.Authenticator type.
|
||||
type Authenticator = mcdslauth.Authenticator
|
||||
|
||||
// Config is an alias for the mcdsl auth.Config type.
|
||||
type Config = mcdslauth.Config
|
||||
|
||||
// Errors re-exported from mcdsl/auth for compatibility.
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("auth: invalid credentials")
|
||||
ErrInvalidToken = errors.New("auth: invalid token")
|
||||
ErrInvalidCredentials = mcdslauth.ErrInvalidCredentials
|
||||
ErrInvalidToken = mcdslauth.ErrInvalidToken
|
||||
ErrForbidden = mcdslauth.ErrForbidden
|
||||
ErrUnavailable = mcdslauth.ErrUnavailable
|
||||
)
|
||||
|
||||
const tokenCacheTTL = 30 * time.Second
|
||||
|
||||
// TokenInfo holds validated token information.
|
||||
type TokenInfo struct {
|
||||
Username string
|
||||
Roles []string
|
||||
IsAdmin bool
|
||||
// NewAuthenticator creates a new Authenticator backed by mcdsl/auth.
|
||||
// This is a convenience wrapper matching the old constructor signature.
|
||||
func NewAuthenticator(cfg mcdslauth.Config, logger *slog.Logger) (*Authenticator, error) {
|
||||
return mcdslauth.New(cfg, logger)
|
||||
}
|
||||
|
||||
// cachedClaims holds a cached token validation result.
|
||||
type cachedClaims struct {
|
||||
info *TokenInfo
|
||||
expiresAt time.Time
|
||||
// Logout revokes a token on the MCIAS server.
|
||||
func Logout(authenticator *Authenticator, token string) error {
|
||||
return authenticator.Logout(token)
|
||||
}
|
||||
|
||||
// Authenticator provides MCIAS-backed authentication.
|
||||
type Authenticator struct {
|
||||
client *mcias.Client
|
||||
logger *slog.Logger
|
||||
cache map[string]*cachedClaims
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// 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
|
||||
// ContextWithTokenInfo stores TokenInfo in a context.
|
||||
var ContextWithTokenInfo = mcdslauth.ContextWithTokenInfo
|
||||
|
||||
// TokenInfoFromContext extracts TokenInfo from a context.
|
||||
var TokenInfoFromContext = mcdslauth.TokenInfoFromContext
|
||||
|
||||
// IsInvalidCredentials checks if an error is ErrInvalidCredentials.
|
||||
func IsInvalidCredentials(err error) bool {
|
||||
return errors.Is(err, ErrInvalidCredentials)
|
||||
}
|
||||
|
||||
@@ -1,53 +1,21 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenHash(t *testing.T) {
|
||||
h1 := tokenHash("token-abc")
|
||||
h2 := tokenHash("token-abc")
|
||||
h3 := tokenHash("token-def")
|
||||
|
||||
if h1 != h2 {
|
||||
t.Error("same input should produce same hash")
|
||||
func TestErrorsExported(t *testing.T) {
|
||||
// Verify the error sentinels are accessible and non-nil.
|
||||
if ErrInvalidCredentials == nil {
|
||||
t.Error("ErrInvalidCredentials is nil")
|
||||
}
|
||||
if h1 == h3 {
|
||||
t.Error("different inputs should produce different hashes")
|
||||
if ErrInvalidToken == nil {
|
||||
t.Error("ErrInvalidToken is nil")
|
||||
}
|
||||
if len(h1) != 64 { // SHA-256 hex
|
||||
t.Errorf("hash length: got %d, want 64", len(h1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAdminRole(t *testing.T) {
|
||||
if !hasAdminRole([]string{"user", "admin"}) {
|
||||
t.Error("should detect admin role")
|
||||
}
|
||||
if hasAdminRole([]string{"user", "operator"}) {
|
||||
t.Error("should not detect admin role when absent")
|
||||
}
|
||||
if hasAdminRole(nil) {
|
||||
t.Error("nil roles should not be admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthenticator(t *testing.T) {
|
||||
a := NewAuthenticator(nil, slog.Default())
|
||||
if a == nil {
|
||||
t.Fatal("NewAuthenticator returned nil")
|
||||
}
|
||||
if a.cache == nil {
|
||||
t.Error("cache should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCache(t *testing.T) {
|
||||
a := NewAuthenticator(nil, slog.Default())
|
||||
a.cache["test"] = &cachedClaims{info: &TokenInfo{Username: "test"}}
|
||||
a.ClearCache()
|
||||
if len(a.cache) != 0 {
|
||||
t.Error("cache should be empty after clear")
|
||||
if ErrForbidden == nil {
|
||||
t.Error("ErrForbidden is nil")
|
||||
}
|
||||
if ErrUnavailable == nil {
|
||||
t.Error("ErrUnavailable is nil")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user