Files
2026-08-11 10:40:10 +01:00

377 lines
10 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
)
// Errors returned by the token service. They are deliberately few: a caller
// presenting a bad token learns that it was refused, not why.
var (
ErrInvalidToken = errors.New("api token is not valid")
ErrTokenScope = errors.New("api token lacks the required scope")
ErrTokenRateLimit = errors.New("api token is making too many requests")
)
const (
// tokenPrefix marks the credential and carries a version, so a future
// format change is distinguishable rather than ambiguous.
tokenPrefix = "rlt_v1_"
// tokenBytes is the entropy behind a token. 32 bytes is far beyond
// guessing, which is the point: there is no rate limit that makes a
// low-entropy secret safe.
tokenBytes = 32
// displayPrefixLen is how much of the token is kept in clear so a person
// can tell their tokens apart. Short enough to be useless as a credential.
displayPrefixLen = 12
// touchInterval throttles last-used writes. A busy token would otherwise
// turn every read request into a write.
touchInterval = time.Minute
// maxTokenNameLen bounds the person's own label.
maxTokenNameLen = 80
// maxTokensPerUser bounds how many live tokens one person may hold.
maxTokensPerUser = 25
)
// TokenService issues and verifies API tokens.
type TokenService struct {
tokens domain.APITokenRepository
users domain.UserRepository
cfg TokenConfig
// touched remembers when each token's last-used time was last written, so
// the throttle survives across requests without a database read.
mu sync.Mutex
touched map[string]time.Time
limiter *tokenLimiter
}
// TokenConfig bounds token lifetimes and request rates.
type TokenConfig struct {
DefaultTTL time.Duration
MaxTTL time.Duration
// RateBurst requests are allowed per RateWindow per token.
RateBurst int
RateWindow time.Duration
}
// NewTokenService builds the service.
func NewTokenService(tokens domain.APITokenRepository, users domain.UserRepository, cfg TokenConfig) *TokenService {
if cfg.DefaultTTL <= 0 {
cfg.DefaultTTL = 90 * 24 * time.Hour
}
if cfg.MaxTTL <= 0 {
cfg.MaxTTL = 365 * 24 * time.Hour
}
if cfg.RateBurst <= 0 {
cfg.RateBurst = 600
}
if cfg.RateWindow <= 0 {
cfg.RateWindow = time.Minute
}
return &TokenService{
tokens: tokens,
users: users,
cfg: cfg,
touched: map[string]time.Time{},
limiter: newTokenLimiter(cfg.RateBurst, cfg.RateWindow),
}
}
// IssuedToken is returned once, at creation. The Secret is never recoverable
// afterwards because only its hash is stored.
type IssuedToken struct {
Token *domain.APIToken
Secret string
}
// CreateRequest describes a token to issue.
type CreateRequest struct {
Name string
Scopes []domain.TokenScope
TTL time.Duration
}
// Create issues a token for the principal.
//
// The granted scopes are the intersection of what was asked for and what the
// person's role permits. Asking for more is not an error — it is narrowed and
// the caller is shown what was actually granted — because the alternative is a
// form that fails for reasons the person cannot see.
func (s *TokenService) Create(ctx context.Context, p *domain.Principal, req CreateRequest) (*IssuedToken, error) {
if p == nil || p.UserID == "" || p.WorkspaceID == "" {
return nil, domain.ErrForbidden
}
// A token cannot mint another token: that would let a leaked credential
// extend its own life indefinitely.
if p.ViaToken() {
return nil, fmt.Errorf("%w: API tokens cannot create API tokens", domain.ErrForbidden)
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fmt.Errorf("%w: a token needs a name so you can tell it from the others", domain.ErrInvalidInput)
}
if len([]rune(name)) > maxTokenNameLen {
return nil, fmt.Errorf("%w: a token name may be at most %d characters", domain.ErrInvalidInput, maxTokenNameLen)
}
granted := make([]domain.TokenScope, 0, len(req.Scopes))
seen := map[domain.TokenScope]bool{}
for _, sc := range req.Scopes {
if !sc.Valid() {
return nil, fmt.Errorf("%w: %q is not a recognised scope", domain.ErrInvalidInput, sc)
}
if seen[sc] || !domain.RoleGrants(p.Role, sc) {
continue
}
seen[sc] = true
granted = append(granted, sc)
}
if len(granted) == 0 {
return nil, fmt.Errorf("%w: none of the requested scopes are available to a %s",
domain.ErrInvalidInput, p.Role)
}
ttl := req.TTL
if ttl <= 0 {
ttl = s.cfg.DefaultTTL
}
if ttl > s.cfg.MaxTTL {
ttl = s.cfg.MaxTTL
}
existing, err := s.tokens.ListForUser(ctx, p.WorkspaceID, p.UserID)
if err != nil {
return nil, err
}
live := 0
now := time.Now().UTC()
for _, t := range existing {
if t.Active(now) {
live++
}
}
if live >= maxTokensPerUser {
return nil, fmt.Errorf("%w: you already hold %d active tokens; revoke one first",
domain.ErrInvalidInput, live)
}
secret, err := newTokenSecret()
if err != nil {
return nil, err
}
tok := &domain.APIToken{
ID: ids.New(),
WorkspaceID: p.WorkspaceID,
UserID: p.UserID,
Name: name,
Prefix: secret[:displayPrefixLen],
TokenHash: ids.HashToken(secret),
Scopes: granted,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
if err := s.tokens.Create(ctx, tok); err != nil {
return nil, err
}
return &IssuedToken{Token: tok, Secret: secret}, nil
}
// List returns the principal's own tokens.
func (s *TokenService) List(ctx context.Context, p *domain.Principal) ([]*domain.APIToken, error) {
if p == nil || p.UserID == "" {
return nil, domain.ErrForbidden
}
return s.tokens.ListForUser(ctx, p.WorkspaceID, p.UserID)
}
// Revoke marks one of the principal's tokens unusable.
//
// A person may only revoke their own tokens: the lookup is scoped to the
// workspace and the owner is checked, so an ID guessed from elsewhere resolves
// to nothing rather than to someone else's credential.
func (s *TokenService) Revoke(ctx context.Context, p *domain.Principal, id string) error {
if p == nil || p.UserID == "" {
return domain.ErrForbidden
}
if p.ViaToken() {
return fmt.Errorf("%w: API tokens cannot revoke API tokens", domain.ErrForbidden)
}
tok, err := s.tokens.Get(ctx, p.WorkspaceID, id)
if err != nil {
return err
}
if tok.UserID != p.UserID && p.Role != domain.RoleAdmin {
// Indistinguishable from a token that does not exist.
return domain.ErrNotFound
}
return s.tokens.Revoke(ctx, p.WorkspaceID, id, p.UserID, time.Now().UTC())
}
// Authenticate resolves a bearer credential into a principal.
//
// Every refusal returns ErrInvalidToken. A caller learns that the credential
// was rejected and nothing more — not whether the token existed, expired, was
// revoked, or belonged to a disabled account.
func (s *TokenService) Authenticate(ctx context.Context, secret string) (*domain.Principal, error) {
secret = strings.TrimSpace(secret)
if secret == "" || !strings.HasPrefix(secret, tokenPrefix) {
return nil, ErrInvalidToken
}
tok, err := s.tokens.GetByHash(ctx, ids.HashToken(secret))
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, ErrInvalidToken
}
return nil, err
}
now := time.Now().UTC()
if !tok.Active(now) {
return nil, ErrInvalidToken
}
if !s.limiter.allow(tok.ID) {
return nil, ErrTokenRateLimit
}
// The user is re-read on every request rather than cached on the token, so
// disabling an account or changing a role takes effect immediately instead
// of at the token's expiry.
u, err := s.users.GetByID(ctx, tok.UserID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, ErrInvalidToken
}
return nil, err
}
if u.Disabled || u.WorkspaceID != tok.WorkspaceID {
return nil, ErrInvalidToken
}
s.touch(ctx, tok.ID, now)
return &domain.Principal{
UserID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: u.Role,
WorkspaceID: u.WorkspaceID,
TokenID: tok.ID,
TokenScopes: tok.Scopes,
}, nil
}
// touch records use, at most once per touchInterval per token.
func (s *TokenService) touch(ctx context.Context, id string, now time.Time) {
s.mu.Lock()
last, ok := s.touched[id]
if ok && now.Sub(last) < touchInterval {
s.mu.Unlock()
return
}
s.touched[id] = now
// Bound the map: it is a throttle, not a record.
if len(s.touched) > 10_000 {
for k, t := range s.touched {
if now.Sub(t) > touchInterval {
delete(s.touched, k)
}
}
}
s.mu.Unlock()
// Best-effort. Failing to record use must not fail the request that used it.
_ = s.tokens.TouchLastUsed(ctx, id, now)
}
// BearerFromRequest extracts the credential from an Authorization header.
//
// Only the Bearer scheme is accepted, and only from the header — never from a
// query parameter, which would end up in access logs, browser history and
// referrer headers.
func BearerFromRequest(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
const scheme = "bearer "
if len(h) <= len(scheme) || !strings.EqualFold(h[:len(scheme)], scheme) {
return ""
}
return strings.TrimSpace(h[len(scheme):])
}
// newTokenSecret mints a token. The encoding is base64url without padding so
// the value is safe in a header, a shell variable and a configuration file.
func newTokenSecret() (string, error) {
buf := make([]byte, tokenBytes)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate api token: %w", err)
}
return tokenPrefix + base64.RawURLEncoding.EncodeToString(buf), nil
}
// ---------------------------------------------------------------------------
// Rate limiting
// ---------------------------------------------------------------------------
// tokenLimiter is a fixed-window counter per token. It exists to bound the
// damage a leaked or runaway token can do, not to shape traffic.
type tokenLimiter struct {
mu sync.Mutex
burst int
window time.Duration
seen map[string]*tokenWindow
}
type tokenWindow struct {
count int
start time.Time
}
func newTokenLimiter(burst int, window time.Duration) *tokenLimiter {
return &tokenLimiter{burst: burst, window: window, seen: map[string]*tokenWindow{}}
}
func (l *tokenLimiter) allow(id string) bool {
if id == "" {
return false
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
w, ok := l.seen[id]
if !ok || now.Sub(w.start) >= l.window {
if len(l.seen) > 10_000 {
for k, v := range l.seen {
if now.Sub(v.start) >= l.window {
delete(l.seen, k)
}
}
}
l.seen[id] = &tokenWindow{count: 1, start: now}
return true
}
if w.count >= l.burst {
return false
}
w.count++
return true
}