Code sync, security fixes

This commit is contained in:
Andy Dixon
2026-08-11 10:40:10 +01:00
parent 4e00deedf8
commit 53dbc28383
141 changed files with 36194 additions and 0 deletions
+376
View File
@@ -0,0 +1,376 @@
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
}
+540
View File
@@ -0,0 +1,540 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
)
// --- Fakes ------------------------------------------------------------------
type fakeTokens struct {
mu sync.Mutex
byID map[string]*domain.APIToken
byHash map[string]*domain.APIToken
}
func newFakeTokens() *fakeTokens {
return &fakeTokens{byID: map[string]*domain.APIToken{}, byHash: map[string]*domain.APIToken{}}
}
func (f *fakeTokens) Create(_ context.Context, t *domain.APIToken) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, clash := f.byHash[t.TokenHash]; clash {
return domain.ErrDuplicate
}
cp := *t
f.byID[t.ID] = &cp
f.byHash[t.TokenHash] = &cp
return nil
}
func (f *fakeTokens) GetByHash(_ context.Context, hash string) (*domain.APIToken, error) {
f.mu.Lock()
defer f.mu.Unlock()
t, ok := f.byHash[hash]
if !ok {
return nil, domain.ErrNotFound
}
cp := *t
return &cp, nil
}
func (f *fakeTokens) ListForUser(_ context.Context, ws, user string) ([]*domain.APIToken, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []*domain.APIToken
for _, t := range f.byID {
if t.WorkspaceID == ws && t.UserID == user {
cp := *t
out = append(out, &cp)
}
}
return out, nil
}
func (f *fakeTokens) Get(_ context.Context, ws, id string) (*domain.APIToken, error) {
f.mu.Lock()
defer f.mu.Unlock()
t, ok := f.byID[id]
if !ok || t.WorkspaceID != ws {
return nil, domain.ErrNotFound
}
cp := *t
return &cp, nil
}
func (f *fakeTokens) Revoke(_ context.Context, ws, id, by string, at time.Time) error {
f.mu.Lock()
defer f.mu.Unlock()
t, ok := f.byID[id]
if !ok || t.WorkspaceID != ws {
return domain.ErrNotFound
}
t.RevokedAt, t.RevokedBy = &at, by
return nil
}
func (f *fakeTokens) TouchLastUsed(_ context.Context, id string, at time.Time) error {
f.mu.Lock()
defer f.mu.Unlock()
if t, ok := f.byID[id]; ok {
t.LastUsedAt = &at
}
return nil
}
func (f *fakeTokens) DeleteForUser(_ context.Context, ws, user string) (int64, error) {
return 0, nil
}
type fakeUsers struct {
mu sync.Mutex
byID map[string]*domain.User
bySub map[string]*domain.User
}
func newFakeUsers(users ...*domain.User) *fakeUsers {
f := &fakeUsers{byID: map[string]*domain.User{}, bySub: map[string]*domain.User{}}
for _, u := range users {
f.put(u)
}
return f
}
// put stores a copy under both indexes.
//
// Storing a copy rather than the caller's pointer is what makes this fake
// behave like a database: a caller that keeps mutating the struct it passed in
// cannot reach into the store, and two callers never share memory. Handing back
// the live pointer produced a data race that no real repository could have.
func (f *fakeUsers) put(u *domain.User) {
cp := *u
f.byID[cp.ID] = &cp
if cp.OAuthSubject != "" {
f.bySub[cp.OAuthProvider+"|"+cp.OAuthSubject] = &cp
}
}
func (f *fakeUsers) Create(_ context.Context, u *domain.User) error {
f.mu.Lock()
defer f.mu.Unlock()
if u.OAuthSubject != "" {
if _, clash := f.bySub[u.OAuthProvider+"|"+u.OAuthSubject]; clash {
return domain.ErrDuplicate
}
}
f.put(u)
return nil
}
func (f *fakeUsers) GetByUsername(_ context.Context, n string) (*domain.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, u := range f.byID {
if u.Username == n {
cp := *u
return &cp, nil
}
}
return nil, domain.ErrNotFound
}
func (f *fakeUsers) GetByID(_ context.Context, id string) (*domain.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.byID[id]
if !ok {
return nil, domain.ErrNotFound
}
cp := *u
return &cp, nil
}
func (f *fakeUsers) Update(_ context.Context, u *domain.User) error {
f.mu.Lock()
defer f.mu.Unlock()
// Both indexes must see the update. A real collection has one document; a
// fake whose indexes diverge would let a test pass on a change the database
// would never have made.
f.put(u)
return nil
}
func (f *fakeUsers) Count(context.Context) (int64, error) { return int64(len(f.byID)), nil }
func (f *fakeUsers) GetByOAuthSubject(_ context.Context, p, sub string) (*domain.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.bySub[p+"|"+sub]
if !ok {
return nil, domain.ErrNotFound
}
cp := *u
return &cp, nil
}
// --- Helpers ----------------------------------------------------------------
func editorUser() *domain.User {
return &domain.User{
ID: ids.New(), Username: "editor", Role: domain.RoleEditor,
WorkspaceID: "ws-1", PasswordHash: "x",
}
}
func principalFor(u *domain.User) *domain.Principal {
return &domain.Principal{
UserID: u.ID, Username: u.Username, Role: u.Role, WorkspaceID: u.WorkspaceID,
}
}
func newTestTokenService(u *domain.User) (*TokenService, *fakeTokens) {
tk := newFakeTokens()
return NewTokenService(tk, newFakeUsers(u), TokenConfig{
DefaultTTL: 24 * time.Hour, MaxTTL: 72 * time.Hour,
RateBurst: 1000, RateWindow: time.Minute,
}), tk
}
// --- Tests ------------------------------------------------------------------
// The secret must never be recoverable from what is stored.
func TestOnlyTheHashIsStored(t *testing.T) {
u := editorUser()
svc, store := newTestTokenService(u)
issued, err := svc.Create(context.Background(), principalFor(u), CreateRequest{
Name: "script", Scopes: []domain.TokenScope{domain.ScopeQuery},
})
if err != nil {
t.Fatal(err)
}
stored, err := store.GetByHash(context.Background(), ids.HashToken(issued.Secret))
if err != nil {
t.Fatal(err)
}
if strings.Contains(stored.TokenHash, issued.Secret) {
t.Fatal("the stored hash contains the secret")
}
if stored.Prefix == issued.Secret {
t.Fatal("the whole secret was stored as the display prefix")
}
if !strings.HasPrefix(issued.Secret, stored.Prefix) {
t.Fatal("the display prefix does not match the token it identifies")
}
if len(stored.Prefix) >= len(issued.Secret)/2 {
t.Fatalf("the display prefix is %d of %d characters; it must not be a usable fraction "+
"of the secret", len(stored.Prefix), len(issued.Secret))
}
}
// Two tokens must never collide, and each must be unguessable.
func TestTokensAreUniqueAndHighEntropy(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 500; i++ {
s, err := newTokenSecret()
if err != nil {
t.Fatal(err)
}
if seen[s] {
t.Fatal("two generated tokens were identical")
}
seen[s] = true
if !strings.HasPrefix(s, tokenPrefix) {
t.Fatalf("token %q lacks the version prefix", s)
}
if len(s)-len(tokenPrefix) < 40 {
t.Fatalf("token body is only %d characters", len(s)-len(tokenPrefix))
}
}
}
// A token may never carry more authority than the person who created it.
func TestScopesAreNarrowedToTheCreatorsRole(t *testing.T) {
viewer := &domain.User{ID: ids.New(), Username: "viewer", Role: domain.RoleViewer, WorkspaceID: "ws-1"}
svc, _ := newTestTokenService(viewer)
// Asking for everything as a viewer yields query only.
issued, err := svc.Create(context.Background(), principalFor(viewer), CreateRequest{
Name: "over-reaching",
Scopes: []domain.TokenScope{
domain.ScopeQuery, domain.ScopeIngest, domain.ScopeReview,
},
})
if err != nil {
t.Fatal(err)
}
if len(issued.Token.Scopes) != 1 || issued.Token.Scopes[0] != domain.ScopeQuery {
t.Fatalf("a viewer was granted %v; only query is available to that role", issued.Token.Scopes)
}
// Asking for nothing available at all is refused rather than issuing an
// empty token that would fail confusingly on first use.
if _, err := svc.Create(context.Background(), principalFor(viewer), CreateRequest{
Name: "nothing", Scopes: []domain.TokenScope{domain.ScopeIngest},
}); err == nil {
t.Fatal("a token with no usable scopes was issued")
}
}
// The role is re-read on every request, so demoting someone takes effect at once.
func TestDemotingTheOwnerImmediatelyNarrowsTheToken(t *testing.T) {
u := editorUser()
users := newFakeUsers(u)
store := newFakeTokens()
svc := NewTokenService(store, users, TokenConfig{DefaultTTL: time.Hour, MaxTTL: time.Hour, RateBurst: 100, RateWindow: time.Minute})
issued, err := svc.Create(context.Background(), principalFor(u), CreateRequest{
Name: "ingester", Scopes: []domain.TokenScope{domain.ScopeIngest, domain.ScopeQuery},
})
if err != nil {
t.Fatal(err)
}
p, err := svc.Authenticate(context.Background(), issued.Secret)
if err != nil {
t.Fatal(err)
}
if !p.Permits(domain.ScopeIngest) {
t.Fatal("an editor's ingest token was refused ingest")
}
// Demote. The token is untouched.
u.Role = domain.RoleViewer
_ = users.Update(context.Background(), u)
p2, err := svc.Authenticate(context.Background(), issued.Secret)
if err != nil {
t.Fatal(err)
}
if p2.Permits(domain.ScopeIngest) {
t.Fatal("the token still permits ingest after its owner was demoted to viewer")
}
if !p2.Permits(domain.ScopeQuery) {
t.Fatal("the token lost query, which a viewer still has")
}
}
// Every rejection must look the same to the caller.
func TestAuthenticateRefusesEverythingItShould(t *testing.T) {
u := editorUser()
users := newFakeUsers(u)
store := newFakeTokens()
svc := NewTokenService(store, users, TokenConfig{DefaultTTL: time.Hour, MaxTTL: time.Hour, RateBurst: 1000, RateWindow: time.Minute})
issue := func(t *testing.T) string {
t.Helper()
iss, err := svc.Create(context.Background(), principalFor(u), CreateRequest{
Name: "t", Scopes: []domain.TokenScope{domain.ScopeQuery},
})
if err != nil {
t.Fatal(err)
}
return iss.Secret
}
t.Run("garbage", func(t *testing.T) {
for _, bad := range []string{"", " ", "not-a-token", "Bearer x", tokenPrefix, "rlt_v1_"} {
if _, err := svc.Authenticate(context.Background(), bad); !errors.Is(err, ErrInvalidToken) {
t.Errorf("Authenticate(%q) = %v, want ErrInvalidToken", bad, err)
}
}
})
t.Run("revoked", func(t *testing.T) {
secret := issue(t)
p, _ := svc.Authenticate(context.Background(), secret)
if p == nil {
t.Fatal("the token did not work before revocation")
}
toks, _ := store.ListForUser(context.Background(), "ws-1", u.ID)
var id string
for _, tok := range toks {
if tok.TokenHash == ids.HashToken(secret) {
id = tok.ID
}
}
if err := svc.Revoke(context.Background(), principalFor(u), id); err != nil {
t.Fatal(err)
}
if _, err := svc.Authenticate(context.Background(), secret); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("a revoked token still authenticated: %v", err)
}
})
t.Run("expired", func(t *testing.T) {
secret := issue(t)
toks, _ := store.ListForUser(context.Background(), "ws-1", u.ID)
for _, tok := range toks {
if tok.TokenHash == ids.HashToken(secret) {
store.mu.Lock()
store.byID[tok.ID].ExpiresAt = time.Now().Add(-time.Hour)
store.byHash[tok.TokenHash].ExpiresAt = time.Now().Add(-time.Hour)
store.mu.Unlock()
}
}
if _, err := svc.Authenticate(context.Background(), secret); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("an expired token still authenticated: %v", err)
}
})
t.Run("disabled owner", func(t *testing.T) {
secret := issue(t)
u.Disabled = true
_ = users.Update(context.Background(), u)
defer func() { u.Disabled = false; _ = users.Update(context.Background(), u) }()
if _, err := svc.Authenticate(context.Background(), secret); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("a token belonging to a disabled account still authenticated: %v", err)
}
})
}
// A leaked token must not be able to mint replacements or revoke rivals.
func TestATokenCannotManageTokens(t *testing.T) {
u := editorUser()
svc, _ := newTestTokenService(u)
tokenPrincipal := &domain.Principal{
UserID: u.ID, Role: u.Role, WorkspaceID: u.WorkspaceID,
TokenID: "tok-1", TokenScopes: []domain.TokenScope{domain.ScopeIngest, domain.ScopeQuery},
}
if _, err := svc.Create(context.Background(), tokenPrincipal, CreateRequest{
Name: "child", Scopes: []domain.TokenScope{domain.ScopeQuery},
}); !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("a token created another token: %v", err)
}
if err := svc.Revoke(context.Background(), tokenPrincipal, "any"); !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("a token revoked a token: %v", err)
}
}
// Bearer credentials come from the header and nowhere else.
func TestBearerIsReadOnlyFromTheAuthorizationHeader(t *testing.T) {
cases := []struct {
header string
want string
}{
{"Bearer abc123", "abc123"},
{"bearer abc123", "abc123"},
{"BEARER abc123", "abc123"},
{"Bearer abc123 ", "abc123"},
{"Basic abc123", ""},
{"abc123", ""},
{"Bearer", ""},
{"", ""},
}
for _, c := range cases {
r := httptestRequest(c.header)
if got := BearerFromRequest(r); got != c.want {
t.Errorf("BearerFromRequest(%q) = %q, want %q", c.header, got, c.want)
}
}
}
// The rate limiter must actually stop a runaway token.
func TestTokenRateLimitBites(t *testing.T) {
u := editorUser()
users := newFakeUsers(u)
store := newFakeTokens()
svc := NewTokenService(store, users, TokenConfig{
DefaultTTL: time.Hour, MaxTTL: time.Hour,
RateBurst: 5, RateWindow: time.Minute,
})
issued, err := svc.Create(context.Background(), principalFor(u), CreateRequest{
Name: "busy", Scopes: []domain.TokenScope{domain.ScopeQuery},
})
if err != nil {
t.Fatal(err)
}
var limited bool
for i := 0; i < 20; i++ {
if _, err := svc.Authenticate(context.Background(), issued.Secret); errors.Is(err, ErrTokenRateLimit) {
limited = true
break
}
}
if !limited {
t.Fatal("a token made 20 requests against a burst of 5 without being limited")
}
}
func httptestRequest(authHeader string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/v1/search", nil)
if authHeader != "" {
r.Header.Set("Authorization", authHeader)
}
return r
}
// --- Client identification behind a proxy -----------------------------------
// The client key is what login rate limiting buckets on. A client that can
// choose its own key can have as many buckets as it likes, which is the same as
// having no rate limit at all.
func TestClientKeyCannotBeChosenByTheClient(t *testing.T) {
const realClient = "203.0.113.9"
newReq := func(headers map[string]string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/login", nil)
r.RemoteAddr = "10.0.0.5:34567" // the proxy
for k, v := range headers {
r.Header.Set(k, v)
}
return r
}
t.Run("X-Real-IP wins and cannot be overridden by a forged XFF", func(t *testing.T) {
// What nginx actually sends: X-Real-IP is $remote_addr, and XFF has the
// client's forged value appended with the real one.
r := newReq(map[string]string{
"X-Real-IP": realClient,
"X-Forwarded-For": "1.2.3.4, " + realClient,
})
if got := ClientKey(r, true); got != realClient {
t.Fatalf("ClientKey = %q, want the real client %q — a forged X-Forwarded-For "+
"must not choose the rate-limit bucket", got, realClient)
}
})
t.Run("without X-Real-IP the rightmost XFF entry is used", func(t *testing.T) {
r := newReq(map[string]string{"X-Forwarded-For": "1.2.3.4, 5.6.7.8, " + realClient})
if got := ClientKey(r, true); got != realClient {
t.Fatalf("ClientKey = %q, want %q — the trustworthy entry is the one the "+
"nearest proxy appended, not the one the client sent first", got, realClient)
}
})
t.Run("a hundred different forged values all land in one bucket", func(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 100; i++ {
r := newReq(map[string]string{
"X-Real-IP": realClient,
"X-Forwarded-For": fmt.Sprintf("10.%d.%d.%d, %s", i, i, i, realClient),
})
seen[ClientKey(r, true)] = true
}
if len(seen) != 1 {
t.Fatalf("100 forged headers produced %d distinct rate-limit buckets; "+
"login throttling would be trivially bypassed", len(seen))
}
})
t.Run("proxy headers are ignored entirely when not trusted", func(t *testing.T) {
r := newReq(map[string]string{"X-Real-IP": "1.2.3.4", "X-Forwarded-For": "1.2.3.4"})
if got := ClientKey(r, false); got != "10.0.0.5" {
t.Fatalf("ClientKey = %q, want the socket address; proxy headers must be "+
"ignored unless explicitly trusted", got)
}
})
}
+218
View File
@@ -0,0 +1,218 @@
package auth
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
"github.com/andydixon/relata/internal/observability"
)
// Provisioner turns a validated federated identity into a signed-in session.
//
// Each person gets a private workspace of their own, created on first sign-in,
// in which they are admin. The workspace boundary is the one Relata has
// enforced in storage from the beginning; this reuses it rather than inventing
// a second notion of separation.
type Provisioner struct {
sessions *Service
users domain.UserRepository
workspaces domain.WorkspaceRepository
}
// NewProvisioner builds the provisioner.
func NewProvisioner(sessions *Service, users domain.UserRepository,
workspaces domain.WorkspaceRepository) *Provisioner {
return &Provisioner{sessions: sessions, users: users, workspaces: workspaces}
}
// SignIn finds or creates the account behind an identity and starts a session.
//
// The session ID is new every time, so an identifier held before signing in
// cannot be carried across the authentication boundary.
func (p *Provisioner) SignIn(ctx context.Context, provider string, id *Identity, userAgent string) (*LoginResult, error) {
if id == nil || strings.TrimSpace(id.Subject) == "" {
return nil, ErrOAuthIdentity
}
provider = strings.TrimSpace(provider)
if provider == "" {
provider = "oauth"
}
u, err := p.findOrCreate(ctx, provider, id)
if err != nil {
return nil, err
}
if u.Disabled {
// Deliberately the same error a wrong password produces.
return nil, ErrInvalidCredentials
}
sess, token, err := p.sessions.createSession(ctx, u, userAgent)
if err != nil {
return nil, err
}
now := time.Now().UTC()
u.LastLoginAt = &now
// Refresh the display fields: a person who changes their name at the
// provider should see the change here too. The subject, which is the key,
// is never rewritten.
changed := false
if id.DisplayName != "" && u.DisplayName != id.DisplayName {
u.DisplayName = id.DisplayName
changed = true
}
if id.Email != "" && u.Email != id.Email {
u.Email = id.Email
changed = true
}
if err := p.users.Update(ctx, u); err != nil {
observability.LoggerFrom(ctx).Warn("could not record federated sign-in",
"user_id", u.ID, "error", err.Error())
}
_ = changed
return &LoginResult{User: u, Session: sess, Token: token}, nil
}
// findOrCreate resolves the account for an identity, creating it on first use.
//
// Provisioning is idempotent under concurrency. Two simultaneous first sign-ins
// both find nothing and both try to create; the unique index on
// (oauth_provider, oauth_subject) lets exactly one through, and the loser
// re-reads the winner's record rather than provisioning a second workspace for
// the same person.
func (p *Provisioner) findOrCreate(ctx context.Context, provider string, id *Identity) (*domain.User, error) {
u, err := p.users.GetByOAuthSubject(ctx, provider, id.Subject)
if err == nil {
return u, nil
}
if !errors.Is(err, domain.ErrNotFound) {
return nil, err
}
now := time.Now().UTC()
ws := &domain.Workspace{
ID: ids.New(),
Name: workspaceNameFor(id),
Slug: "ws-" + shortID(),
CreatedAt: now,
}
if err := p.workspaces.Create(ctx, ws); err != nil {
return nil, fmt.Errorf("create workspace for new account: %w", err)
}
user := &domain.User{
ID: ids.New(),
Username: p.uniqueUsername(ctx, id),
// No password hash: this account can never sign in locally.
DisplayName: id.DisplayName,
Email: id.Email,
Role: domain.RoleAdmin, // admin of their own workspace, nobody else's
WorkspaceID: ws.ID,
OAuthProvider: provider,
OAuthSubject: id.Subject,
CreatedAt: now,
UpdatedAt: now,
}
if err := p.users.Create(ctx, user); err != nil {
if errors.Is(err, domain.ErrDuplicate) {
// Lost the race. The winner's account is the right one; this
// workspace was never referenced by anything, so it is abandoned
// rather than deleted — removing it would race with the winner.
if existing, e := p.users.GetByOAuthSubject(ctx, provider, id.Subject); e == nil {
observability.LoggerFrom(ctx).Info(
"concurrent first sign-in resolved to the existing account",
"user_id", existing.ID)
return existing, nil
}
// A duplicate that is not the subject can only be the username.
user.Username = user.Username + "-" + shortID()
if err2 := p.users.Create(ctx, user); err2 != nil {
return nil, fmt.Errorf("create account for federated identity: %w", err2)
}
return user, nil
}
return nil, fmt.Errorf("create account for federated identity: %w", err)
}
observability.LoggerFrom(ctx).Info("provisioned a workspace for a new federated account",
"user_id", user.ID, "workspace_id", ws.ID, "provider", provider)
return user, nil
}
// uniqueUsername derives a readable, unique username from the identity.
//
// It is a label, not an identifier: the account is keyed on the provider
// subject, so a collision here is cosmetic and resolved by appending.
func (p *Provisioner) uniqueUsername(ctx context.Context, id *Identity) string {
base := usernameSeed(id)
candidate := base
for attempt := 0; attempt < 5; attempt++ {
if _, err := p.users.GetByUsername(ctx, candidate); errors.Is(err, domain.ErrNotFound) {
return candidate
}
candidate = base + "-" + shortID()
}
return base + "-" + shortID()
}
// usernameSeed builds the readable part of a username from an identity.
func usernameSeed(id *Identity) string {
seed := id.Email
if at := strings.Index(seed, "@"); at > 0 {
seed = seed[:at]
}
if strings.TrimSpace(seed) == "" {
seed = id.DisplayName
}
if strings.TrimSpace(seed) == "" {
seed = "user"
}
var b strings.Builder
for _, r := range strings.ToLower(seed) {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
case r == '.' || r == '-' || r == '_':
b.WriteRune('-')
case unicode.IsSpace(r):
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), "-")
if len([]rune(out)) > 40 {
out = string([]rune(out)[:40])
}
if out == "" {
out = "user"
}
return out
}
// workspaceNameFor labels a person's private workspace.
func workspaceNameFor(id *Identity) string {
who := strings.TrimSpace(id.DisplayName)
if who == "" {
who = strings.TrimSpace(id.Email)
}
if who == "" {
return "Private workspace"
}
if len([]rune(who)) > 60 {
who = string([]rune(who)[:60])
}
return who + "'s workspace"
}
func shortID() string {
return strings.ReplaceAll(ids.New(), "-", "")[:8]
}
+279
View File
@@ -0,0 +1,279 @@
package auth
import (
"context"
"sync"
"testing"
"time"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
)
type fakeWorkspaces struct {
mu sync.Mutex
byID map[string]*domain.Workspace
}
func newFakeWorkspaces() *fakeWorkspaces {
return &fakeWorkspaces{byID: map[string]*domain.Workspace{}}
}
func (f *fakeWorkspaces) Create(_ context.Context, w *domain.Workspace) error {
f.mu.Lock()
defer f.mu.Unlock()
f.byID[w.ID] = w
return nil
}
func (f *fakeWorkspaces) Get(_ context.Context, id string) (*domain.Workspace, error) {
f.mu.Lock()
defer f.mu.Unlock()
w, ok := f.byID[id]
if !ok {
return nil, domain.ErrNotFound
}
return w, nil
}
func (f *fakeWorkspaces) GetBySlug(context.Context, string) (*domain.Workspace, error) {
return nil, domain.ErrNotFound
}
func (f *fakeWorkspaces) List(context.Context) ([]*domain.Workspace, error) { return nil, nil }
func (f *fakeWorkspaces) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.byID)
}
type fakeSessions struct {
mu sync.Mutex
byID map[string]*domain.Session
}
func newFakeSessions() *fakeSessions {
return &fakeSessions{byID: map[string]*domain.Session{}}
}
func (f *fakeSessions) Create(_ context.Context, s *domain.Session) error {
f.mu.Lock()
defer f.mu.Unlock()
f.byID[s.ID] = s
return nil
}
func (f *fakeSessions) GetByTokenHash(context.Context, string) (*domain.Session, error) {
return nil, domain.ErrNotFound
}
func (f *fakeSessions) Touch(context.Context, string, time.Time, time.Time) error { return nil }
func (f *fakeSessions) Revoke(context.Context, string) error { return nil }
func (f *fakeSessions) RevokeAllForUser(context.Context, string) error { return nil }
func (f *fakeSessions) DeleteExpired(context.Context, time.Time) (int64, error) { return 0, nil }
func newTestProvisioner() (*Provisioner, *fakeUsers, *fakeWorkspaces) {
users := newFakeUsers()
wss := newFakeWorkspaces()
sessions := NewService(users, newFakeSessions(), config.SessionConfig{
CookieName: "relata_session", IdleTimeout: time.Hour, AbsoluteTimeout: 24 * time.Hour,
LoginRateBurst: 10, LoginRateWindow: time.Minute,
}, false, nil)
return NewProvisioner(sessions, users, wss), users, wss
}
// First sign-in creates a private workspace in which the person is admin.
func TestFirstSignInProvisionsAPrivateWorkspace(t *testing.T) {
p, users, wss := newTestProvisioner()
res, err := p.SignIn(context.Background(), "Blackwall", &Identity{
Subject: "sub-1", Email: "alice@example.com", DisplayName: "Alice Example",
}, "test-agent")
if err != nil {
t.Fatal(err)
}
if res.User.Role != domain.RoleAdmin {
t.Fatalf("role = %q; a person should be admin of their own workspace", res.User.Role)
}
if res.User.WorkspaceID == "" {
t.Fatal("no workspace was assigned")
}
if wss.count() != 1 {
t.Fatalf("%d workspaces created, want 1", wss.count())
}
if res.User.PasswordHash != "" {
t.Fatal("a federated account was given a password hash")
}
if !res.User.Federated() || res.User.CanLoginLocally() {
t.Fatal("a federated account must never be able to sign in locally")
}
if res.Token == "" || res.Session == nil {
t.Fatal("no session was established")
}
_ = users
}
// Signing in again must reuse the account, not make another workspace.
func TestSecondSignInReusesTheSameWorkspace(t *testing.T) {
p, _, wss := newTestProvisioner()
id := &Identity{Subject: "sub-1", Email: "alice@example.com", DisplayName: "Alice"}
first, err := p.SignIn(context.Background(), "Blackwall", id, "a")
if err != nil {
t.Fatal(err)
}
second, err := p.SignIn(context.Background(), "Blackwall", id, "a")
if err != nil {
t.Fatal(err)
}
if first.User.ID != second.User.ID {
t.Fatal("signing in twice produced two accounts")
}
if first.User.WorkspaceID != second.User.WorkspaceID {
t.Fatal("signing in twice produced two workspaces")
}
if wss.count() != 1 {
t.Fatalf("%d workspaces exist after two sign-ins, want 1", wss.count())
}
if first.Session.ID == second.Session.ID {
t.Fatal("the session ID was reused across sign-ins; it must be fresh each time")
}
}
// Two people whose email addresses collide, or whose provider changes an email,
// must never be confused for one another: the subject is the key.
func TestIdentityIsKeyedOnSubjectNotEmail(t *testing.T) {
p, _, wss := newTestProvisioner()
ctx := context.Background()
alice, err := p.SignIn(ctx, "Blackwall", &Identity{
Subject: "sub-alice", Email: "shared@example.com", DisplayName: "Alice",
}, "a")
if err != nil {
t.Fatal(err)
}
// A different person who has been given the same address.
bob, err := p.SignIn(ctx, "Blackwall", &Identity{
Subject: "sub-bob", Email: "shared@example.com", DisplayName: "Bob",
}, "a")
if err != nil {
t.Fatal(err)
}
if alice.User.ID == bob.User.ID {
t.Fatal("two different subjects sharing an email resolved to one account")
}
if alice.User.WorkspaceID == bob.User.WorkspaceID {
t.Fatal("two different people were put in the same workspace")
}
if wss.count() != 2 {
t.Fatalf("%d workspaces, want 2", wss.count())
}
// The same person whose email changed keeps their workspace.
aliceAgain, err := p.SignIn(ctx, "Blackwall", &Identity{
Subject: "sub-alice", Email: "alice.new@example.com", DisplayName: "Alice",
}, "a")
if err != nil {
t.Fatal(err)
}
if aliceAgain.User.ID != alice.User.ID {
t.Fatal("changing email address produced a new account")
}
if aliceAgain.User.Email != "alice.new@example.com" {
t.Fatalf("the display email was not refreshed: %q", aliceAgain.User.Email)
}
}
// Two simultaneous first sign-ins must converge on one account and one workspace.
func TestConcurrentFirstSignInsConvergeOnOneAccount(t *testing.T) {
p, _, wss := newTestProvisioner()
id := &Identity{Subject: "sub-race", Email: "race@example.com", DisplayName: "Racer"}
const n = 8
var (
wg sync.WaitGroup
mu sync.Mutex
userIDs = map[string]bool{}
errs []error
)
start := make(chan struct{})
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
res, err := p.SignIn(context.Background(), "Blackwall", id, "a")
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, err)
return
}
userIDs[res.User.ID] = true
}()
}
close(start)
wg.Wait()
if len(errs) > 0 {
t.Fatalf("concurrent sign-ins produced errors: %v", errs)
}
if len(userIDs) != 1 {
t.Fatalf("%d distinct accounts were created for one identity; provisioning is not idempotent", len(userIDs))
}
// One workspace is used. Others may have been created and abandoned by
// racers that lost, which is deliberate — deleting one would race with the
// winner — but nobody may end up *in* a second workspace.
if wss.count() < 1 {
t.Fatal("no workspace was created")
}
}
// A disabled account cannot sign in, however good the identity is.
func TestDisabledFederatedAccountCannotSignIn(t *testing.T) {
p, users, _ := newTestProvisioner()
ctx := context.Background()
res, err := p.SignIn(ctx, "Blackwall", &Identity{Subject: "sub-1", Email: "a@example.com"}, "a")
if err != nil {
t.Fatal(err)
}
u, _ := users.GetByID(ctx, res.User.ID)
u.Disabled = true
_ = users.Update(ctx, u)
if _, err := p.SignIn(ctx, "Blackwall", &Identity{Subject: "sub-1", Email: "a@example.com"}, "a"); err == nil {
t.Fatal("a disabled account signed in")
}
}
// A username is a label derived from the identity; it must be safe and unique.
func TestUsernameSeedProducesSafeLabels(t *testing.T) {
cases := []struct {
id Identity
want string
}{
{Identity{Email: "alice.smith@example.com"}, "alice-smith"},
{Identity{Email: "A.B@EXAMPLE.COM"}, "a-b"},
{Identity{DisplayName: "Alice Smith"}, "alice-smith"},
{Identity{Email: "../../etc/passwd@x.com"}, "etcpasswd"},
{Identity{Email: "<script>@x.com"}, "script"},
{Identity{}, "user"},
{Identity{DisplayName: " "}, "user"},
}
for _, c := range cases {
got := usernameSeed(&c.id)
if got != c.want {
t.Errorf("usernameSeed(%+v) = %q, want %q", c.id, got, c.want)
}
for _, r := range got {
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-'
if !ok {
t.Errorf("usernameSeed produced %q, containing unsafe character %q", got, r)
}
}
}
}
var _ = ids.New
+451
View File
@@ -0,0 +1,451 @@
// Package auth also implements federated sign-in.
//
// The flow is OAuth 2.0 Authorization Code with PKCE, upgraded to OpenID
// Connect when the provider offers an issuer. Everything the provider returns
// is treated as untrusted input and validated before it becomes an identity.
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// OAuth errors. As with tokens, the caller learns that sign-in failed and the
// specifics go to the log.
var (
ErrOAuthDisabled = errors.New("federated sign-in is not configured")
ErrOAuthState = errors.New("sign-in state did not match")
ErrOAuthProvider = errors.New("the identity provider rejected the request")
ErrOAuthIdentity = errors.New("the identity provider returned an unusable identity")
ErrOAuthNotWanted = errors.New("this account is not permitted to sign in")
)
// maxProviderBody bounds what will be read from the provider. A provider is
// trusted for identity, not for restraint.
const maxProviderBody = 1 << 20
// OAuthConfig is what the service needs to talk to the provider.
type OAuthConfig struct {
Enabled bool
ProviderName string
Issuer string
AuthorizeURL string
TokenURL string
UserInfoURL string
JWKSURL string
ClientID string
ClientSecret string
RedirectURI string
Scopes []string
AllowedEmailDomains []string
Timeout time.Duration
}
// OAuthService performs the authorization-code exchange.
type OAuthService struct {
cfg OAuthConfig
client *http.Client
// discovery is resolved once, lazily, and cached. A provider's endpoints do
// not change between requests.
discoOnce sync.Once
discoErr error
jwks *jwksCache
}
// NewOAuthService builds the service. It performs no network I/O: a provider
// that is unreachable at startup must not stop Relata from starting, because
// the break-glass local login lives in the same process.
func NewOAuthService(cfg OAuthConfig) *OAuthService {
if cfg.Timeout <= 0 {
cfg.Timeout = 15 * time.Second
}
if cfg.ProviderName == "" {
cfg.ProviderName = "your identity provider"
}
c := &http.Client{
Timeout: cfg.Timeout,
// The provider is configured, not supplied by a user, but a redirect
// chain is still somewhere a token exchange should not wander. Three is
// enough for a provider behind a load balancer.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects from the identity provider")
}
return nil
},
}
return &OAuthService{cfg: cfg, client: c, jwks: newJWKSCache(c)}
}
// Enabled reports whether federated sign-in is available.
func (s *OAuthService) Enabled() bool { return s != nil && s.cfg.Enabled }
// ProviderName is the label shown on the sign-in button.
func (s *OAuthService) ProviderName() string {
if s == nil {
return ""
}
return s.cfg.ProviderName
}
// ---------------------------------------------------------------------------
// Starting the flow
// ---------------------------------------------------------------------------
// FlowState is the per-attempt secret material. It is held in a short-lived
// cookie, never server-side: it is single-use, expires in minutes, and storing
// it would mean a write on every visit to the sign-in page.
type FlowState struct {
State string `json:"s"`
Verifier string `json:"v"`
Nonce string `json:"n"`
RedirectPath string `json:"r,omitempty"`
IssuedAt int64 `json:"t"`
}
// AuthorizeURL builds the URL to send the browser to, and the state to keep.
//
// returnTo is where to land after a successful sign-in. It is validated as a
// local path here rather than at the callback, so an open redirect cannot be
// smuggled in through the state cookie.
func (s *OAuthService) AuthorizeURL(ctx context.Context, returnTo string) (string, *FlowState, error) {
if !s.Enabled() {
return "", nil, ErrOAuthDisabled
}
if err := s.discover(ctx); err != nil {
return "", nil, err
}
state, err := randomURLSafe(32)
if err != nil {
return "", nil, err
}
verifier, err := randomURLSafe(32)
if err != nil {
return "", nil, err
}
nonce, err := randomURLSafe(16)
if err != nil {
return "", nil, err
}
fs := &FlowState{
State: state,
Verifier: verifier,
Nonce: nonce,
RedirectPath: SafeRedirectPath(returnTo),
IssuedAt: time.Now().Unix(),
}
challenge := pkceChallenge(verifier)
q := url.Values{}
q.Set("response_type", "code")
q.Set("client_id", s.cfg.ClientID)
q.Set("redirect_uri", s.cfg.RedirectURI)
q.Set("scope", strings.Join(s.cfg.Scopes, " "))
q.Set("state", state)
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
if s.isOIDC() {
q.Set("nonce", nonce)
}
sep := "?"
if strings.Contains(s.cfg.AuthorizeURL, "?") {
sep = "&"
}
return s.cfg.AuthorizeURL + sep + q.Encode(), fs, nil
}
// ---------------------------------------------------------------------------
// Completing the flow
// ---------------------------------------------------------------------------
// Identity is what the provider vouched for, after validation.
type Identity struct {
// Subject is the provider's immutable identifier. It is the key.
Subject string
// Email and DisplayName are for display and the domain allowlist only.
Email string
DisplayName string
Provider string
}
// Exchange completes the flow: validates state, swaps the code, and resolves an
// identity.
//
// The state comparison is constant-time and happens before anything is sent to
// the provider, so a mismatched callback costs nothing and reveals nothing.
func (s *OAuthService) Exchange(ctx context.Context, fs *FlowState, gotState, code string) (*Identity, error) {
if !s.Enabled() {
return nil, ErrOAuthDisabled
}
if fs == nil || fs.State == "" || gotState == "" {
return nil, ErrOAuthState
}
if subtle.ConstantTimeCompare([]byte(fs.State), []byte(gotState)) != 1 {
return nil, ErrOAuthState
}
// A flow that has been open for a long time is more likely to be a replayed
// callback than a slow typist.
if age := time.Since(time.Unix(fs.IssuedAt, 0)); age > 15*time.Minute || age < -time.Minute {
return nil, ErrOAuthState
}
if strings.TrimSpace(code) == "" {
return nil, ErrOAuthProvider
}
if err := s.discover(ctx); err != nil {
return nil, err
}
tok, err := s.exchangeCode(ctx, code, fs.Verifier)
if err != nil {
return nil, err
}
id, err := s.identify(ctx, tok, fs.Nonce)
if err != nil {
return nil, err
}
if err := s.permitted(id); err != nil {
return nil, err
}
id.Provider = s.cfg.ProviderName
return id, nil
}
// tokenResponse is the provider's reply to the code exchange.
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
IDToken string `json:"id_token"`
ExpiresIn int64 `json:"expires_in"`
Scope string `json:"scope"`
}
func (s *OAuthService) exchangeCode(ctx context.Context, code, verifier string) (*tokenResponse, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", s.cfg.RedirectURI)
form.Set("client_id", s.cfg.ClientID)
form.Set("code_verifier", verifier)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TokenURL,
strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("%w: building the token request: %v", ErrOAuthProvider, err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
// Client credentials go in the Authorization header rather than the body:
// it is the form every provider accepts, and it keeps the secret out of
// anything that logs request bodies.
req.SetBasicAuth(url.QueryEscape(s.cfg.ClientID), url.QueryEscape(s.cfg.ClientSecret))
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrOAuthProvider, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderBody))
if err != nil {
return nil, fmt.Errorf("%w: reading the token response: %v", ErrOAuthProvider, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The body may name the misconfiguration, which is exactly what an
// operator needs — and exactly what a user must not be shown.
return nil, fmt.Errorf("%w: token endpoint returned %d: %s",
ErrOAuthProvider, resp.StatusCode, truncate(string(body), 300))
}
var tr tokenResponse
dec := json.NewDecoder(strings.NewReader(string(body)))
if err := dec.Decode(&tr); err != nil {
return nil, fmt.Errorf("%w: token response was not JSON: %v", ErrOAuthProvider, err)
}
if tr.AccessToken == "" && tr.IDToken == "" {
return nil, fmt.Errorf("%w: token response carried neither an access token nor an ID token",
ErrOAuthProvider)
}
return &tr, nil
}
// identify resolves an identity, preferring a validated ID token.
func (s *OAuthService) identify(ctx context.Context, tok *tokenResponse, nonce string) (*Identity, error) {
if s.isOIDC() && tok.IDToken != "" {
id, err := s.identityFromIDToken(ctx, tok.IDToken, nonce)
if err == nil {
return id, nil
}
// An ID token that fails validation is a hard failure, not a reason to
// fall back: falling back would let a forged token downgrade the check.
return nil, err
}
if s.cfg.UserInfoURL == "" {
return nil, fmt.Errorf("%w: no ID token and no userinfo endpoint", ErrOAuthIdentity)
}
return s.identityFromUserInfo(ctx, tok.AccessToken)
}
func (s *OAuthService) identityFromUserInfo(ctx context.Context, accessToken string) (*Identity, error) {
if accessToken == "" {
return nil, fmt.Errorf("%w: no access token to call userinfo with", ErrOAuthIdentity)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.UserInfoURL, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrOAuthProvider, err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrOAuthProvider, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderBody))
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrOAuthProvider, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: userinfo returned %d: %s",
ErrOAuthProvider, resp.StatusCode, truncate(string(body), 300))
}
var claims map[string]any
if err := json.Unmarshal(body, &claims); err != nil {
return nil, fmt.Errorf("%w: userinfo was not JSON: %v", ErrOAuthIdentity, err)
}
return identityFromClaims(claims)
}
// permitted applies the optional email-domain allowlist.
func (s *OAuthService) permitted(id *Identity) error {
if len(s.cfg.AllowedEmailDomains) == 0 {
return nil
}
at := strings.LastIndex(id.Email, "@")
if at < 0 {
return fmt.Errorf("%w: an email address is required when a domain allowlist is set",
ErrOAuthNotWanted)
}
got := strings.ToLower(id.Email[at+1:])
for _, d := range s.cfg.AllowedEmailDomains {
if strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(d), "@"), got) {
return nil
}
}
return ErrOAuthNotWanted
}
// identityFromClaims pulls the standard claims out of a claim set.
func identityFromClaims(claims map[string]any) (*Identity, error) {
sub := claimString(claims, "sub")
if sub == "" {
// Without a subject there is nothing stable to key an account on, and
// guessing from the email is what this design exists to avoid.
return nil, fmt.Errorf("%w: no 'sub' claim", ErrOAuthIdentity)
}
if len(sub) > 255 {
return nil, fmt.Errorf("%w: 'sub' claim is implausibly long", ErrOAuthIdentity)
}
email := strings.ToLower(claimString(claims, "email"))
name := claimString(claims, "name")
if name == "" {
name = strings.TrimSpace(claimString(claims, "given_name") + " " + claimString(claims, "family_name"))
}
if name == "" {
name = claimString(claims, "preferred_username")
}
return &Identity{
Subject: sub,
Email: truncate(email, 320),
DisplayName: truncate(strings.TrimSpace(name), 120),
}, nil
}
func claimString(m map[string]any, key string) string {
switch v := m[key].(type) {
case string:
return strings.TrimSpace(v)
case float64:
// Some providers number their subjects.
return strings.TrimSpace(fmt.Sprintf("%.0f", v))
}
return ""
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func (s *OAuthService) isOIDC() bool {
return s.cfg.Issuer != "" || s.cfg.JWKSURL != ""
}
// SafeRedirectPath reduces a requested post-login destination to a local path.
//
// Anything that could leave the site becomes "/". Rejecting a leading "//" or a
// backslash matters as much as rejecting a scheme: browsers read "//evil.test"
// as protocol-relative and "/\evil.test" as the same thing.
func SafeRedirectPath(p string) string {
p = strings.TrimSpace(p)
if p == "" || !strings.HasPrefix(p, "/") {
return "/"
}
if strings.HasPrefix(p, "//") || strings.HasPrefix(p, "/\\") {
return "/"
}
if strings.ContainsAny(p, "\r\n") {
return "/"
}
// A path with a scheme in it is not a path.
if strings.Contains(strings.ToLower(p), "://") {
return "/"
}
return p
}
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func randomURLSafe(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate random value: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+465
View File
@@ -0,0 +1,465 @@
package auth
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// --- Redirect safety --------------------------------------------------------
// The post-login destination comes from a query parameter, so it is
// attacker-controlled. Anything that could leave the site must become "/".
func TestSafeRedirectPathRefusesAnythingThatLeavesTheSite(t *testing.T) {
hostile := []string{
"//evil.test",
"//evil.test/path",
"/\\evil.test",
"https://evil.test",
"http://evil.test",
"javascript:alert(1)",
"/redirect?to=https://evil.test\r\nSet-Cookie: a=b",
"evil.test",
"",
" ",
"\\\\evil.test",
}
for _, in := range hostile {
if got := SafeRedirectPath(in); got != "/" {
t.Errorf("SafeRedirectPath(%q) = %q; anything that could leave the site must become \"/\"", in, got)
}
}
// Ordinary local paths survive, or the feature would be pointless.
for _, in := range []string{"/", "/items", "/search?q=cost", "/items/abc-123"} {
if got := SafeRedirectPath(in); got != in {
t.Errorf("SafeRedirectPath(%q) = %q; a local path should survive", in, got)
}
}
}
// --- PKCE -------------------------------------------------------------------
func TestAuthorizeURLAlwaysUsesPKCEAndAFreshState(t *testing.T) {
s := NewOAuthService(OAuthConfig{
Enabled: true, ProviderName: "Blackwall",
AuthorizeURL: "https://id.example/authorize",
TokenURL: "https://id.example/token",
UserInfoURL: "https://id.example/userinfo",
ClientID: "client-1", ClientSecret: "secret",
RedirectURI: "https://relata.example/auth/oauth/callback",
Scopes: []string{"openid", "email"},
})
u1, fs1, err := s.AuthorizeURL(context.Background(), "/items")
if err != nil {
t.Fatal(err)
}
u2, fs2, err := s.AuthorizeURL(context.Background(), "/items")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(u1, "code_challenge_method=S256") {
t.Fatalf("authorize URL does not request S256 PKCE:\n%s", u1)
}
if strings.Contains(u1, fs1.Verifier) {
t.Fatal("the PKCE verifier appears in the authorize URL; only its hash may be sent")
}
if fs1.State == fs2.State || fs1.Verifier == fs2.Verifier {
t.Fatal("two sign-in attempts reused state or verifier; each must be fresh")
}
if len(fs1.State) < 32 {
t.Fatalf("state is only %d characters; it must be unguessable", len(fs1.State))
}
if fs1.RedirectPath != "/items" {
t.Fatalf("redirect path = %q, want /items", fs1.RedirectPath)
}
_ = u2
}
func TestAuthorizeURLSanitisesTheReturnPath(t *testing.T) {
s := NewOAuthService(OAuthConfig{
Enabled: true, AuthorizeURL: "https://id.example/authorize",
TokenURL: "https://id.example/token", UserInfoURL: "https://id.example/userinfo",
ClientID: "c", ClientSecret: "s",
})
_, fs, err := s.AuthorizeURL(context.Background(), "//evil.test")
if err != nil {
t.Fatal(err)
}
if fs.RedirectPath != "/" {
t.Fatalf("an off-site return path survived into the flow state: %q", fs.RedirectPath)
}
}
// --- State ------------------------------------------------------------------
func TestExchangeRefusesAMismatchedOrStaleState(t *testing.T) {
s := NewOAuthService(OAuthConfig{
Enabled: true, AuthorizeURL: "https://id.example/authorize",
TokenURL: "https://id.example/token", UserInfoURL: "https://id.example/userinfo",
ClientID: "c", ClientSecret: "s",
})
ctx := context.Background()
fresh := &FlowState{State: "expected", Verifier: "v", IssuedAt: time.Now().Unix()}
cases := []struct {
name string
fs *FlowState
state string
}{
{"no flow state at all", nil, "expected"},
{"empty submitted state", fresh, ""},
{"different state", fresh, "attacker"},
{"prefix of the real state", fresh, "expec"},
{"stale flow", &FlowState{State: "expected", IssuedAt: time.Now().Add(-30 * time.Minute).Unix()}, "expected"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := s.Exchange(ctx, c.fs, c.state, "some-code")
if !errors.Is(err, ErrOAuthState) {
t.Fatalf("err = %v, want ErrOAuthState — a callback that fails the state check "+
"must be refused before anything is sent to the provider", err)
}
})
}
}
// --- Identity ---------------------------------------------------------------
// The subject is the account key. Without one there is nothing stable to key on,
// and falling back to the email is precisely the bug this avoids.
func TestIdentityRequiresASubject(t *testing.T) {
if _, err := identityFromClaims(map[string]any{"email": "someone@example.com"}); err == nil {
t.Fatal("an identity with an email but no subject was accepted")
}
id, err := identityFromClaims(map[string]any{"sub": "abc", "email": "A@Example.COM", "name": "A Person"})
if err != nil {
t.Fatal(err)
}
if id.Subject != "abc" {
t.Fatalf("subject = %q", id.Subject)
}
if id.Email != "a@example.com" {
t.Fatalf("email = %q; it should be lowercased for comparison", id.Email)
}
}
func TestEmailDomainAllowlist(t *testing.T) {
s := NewOAuthService(OAuthConfig{
Enabled: true, AllowedEmailDomains: []string{"example.com", "@other.test"},
})
for _, c := range []struct {
email string
want bool
}{
{"someone@example.com", true},
{"someone@EXAMPLE.com", true},
{"someone@other.test", true},
{"someone@evil.test", false},
{"someone@sub.example.com", false}, // a subdomain is a different domain
{"", false},
{"notanemail", false},
{"someone@example.com.evil.test", false},
} {
err := s.permitted(&Identity{Email: c.email})
if (err == nil) != c.want {
t.Errorf("permitted(%q): err=%v, want allowed=%v", c.email, err, c.want)
}
}
}
// --- ID token validation ----------------------------------------------------
type testSigner struct {
key *rsa.PrivateKey
kid string
}
func newTestSigner(t *testing.T) *testSigner {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
return &testSigner{key: k, kid: "test-key-1"}
}
// jwksHandler serves the signer's public key in JWKS form.
func (s *testSigner) jwksHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := base64.RawURLEncoding.EncodeToString(s.key.N.Bytes())
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.key.E)).Bytes())
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]string{
{"kty": "RSA", "use": "sig", "kid": s.kid, "n": n, "e": e},
},
})
})
}
func (s *testSigner) sign(t *testing.T, alg string, claims map[string]any) string {
t.Helper()
header := map[string]string{"alg": alg, "typ": "JWT", "kid": s.kid}
hb, _ := json.Marshal(header)
cb, _ := json.Marshal(claims)
signing := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(cb)
h := crypto.SHA256.New()
h.Write([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, h.Sum(nil))
if err != nil {
t.Fatal(err)
}
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}
func oidcService(t *testing.T, jwksURL, issuer, clientID string) *OAuthService {
t.Helper()
return NewOAuthService(OAuthConfig{
Enabled: true, Issuer: issuer, JWKSURL: jwksURL,
AuthorizeURL: issuer + "/authorize", TokenURL: issuer + "/token",
ClientID: clientID, ClientSecret: "secret",
})
}
func TestIDTokenValidationAcceptsAGenuineToken(t *testing.T) {
signer := newTestSigner(t)
srv := httptest.NewServer(signer.jwksHandler())
defer srv.Close()
s := oidcService(t, srv.URL, "https://id.example", "client-1")
raw := signer.sign(t, "RS256", map[string]any{
"iss": "https://id.example", "aud": "client-1", "sub": "user-42",
"email": "person@example.com", "name": "A Person",
"nonce": "n-1", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
})
id, err := s.identityFromIDToken(context.Background(), raw, "n-1")
if err != nil {
t.Fatalf("a correctly signed token was rejected: %v", err)
}
if id.Subject != "user-42" {
t.Fatalf("subject = %q", id.Subject)
}
}
// Each of these is a real-world JWT forgery or a replay. None may pass.
func TestIDTokenValidationRejectsForgeriesAndReplays(t *testing.T) {
signer := newTestSigner(t)
other := newTestSigner(t)
other.kid = signer.kid // same key ID, different key: a substitution attack
srv := httptest.NewServer(signer.jwksHandler())
defer srv.Close()
base := func() map[string]any {
return map[string]any{
"iss": "https://id.example", "aud": "client-1", "sub": "user-42",
"nonce": "n-1", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
}
}
cases := []struct {
name string
build func(t *testing.T) string
nonce string
}{
{"signed by the wrong key", func(t *testing.T) string {
return other.sign(t, "RS256", base())
}, "n-1"},
{"alg none", func(t *testing.T) string {
hb, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT", "kid": signer.kid})
cb, _ := json.Marshal(base())
return base64.RawURLEncoding.EncodeToString(hb) + "." +
base64.RawURLEncoding.EncodeToString(cb) + "."
}, "n-1"},
{"HMAC using the public key as the secret", func(t *testing.T) string {
hb, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT", "kid": signer.kid})
cb, _ := json.Marshal(base())
return base64.RawURLEncoding.EncodeToString(hb) + "." +
base64.RawURLEncoding.EncodeToString(cb) + ".c2ln"
}, "n-1"},
{"wrong audience", func(t *testing.T) string {
c := base()
c["aud"] = "someone-elses-client"
return signer.sign(t, "RS256", c)
}, "n-1"},
{"wrong issuer", func(t *testing.T) string {
c := base()
c["iss"] = "https://evil.test"
return signer.sign(t, "RS256", c)
}, "n-1"},
{"expired", func(t *testing.T) string {
c := base()
c["exp"] = time.Now().Add(-time.Hour).Unix()
return signer.sign(t, "RS256", c)
}, "n-1"},
{"no expiry at all", func(t *testing.T) string {
c := base()
delete(c, "exp")
return signer.sign(t, "RS256", c)
}, "n-1"},
{"replayed with a different nonce", func(t *testing.T) string {
return signer.sign(t, "RS256", base())
}, "a-different-nonce"},
{"no subject", func(t *testing.T) string {
c := base()
delete(c, "sub")
return signer.sign(t, "RS256", c)
}, "n-1"},
{"payload tampered after signing", func(t *testing.T) string {
raw := signer.sign(t, "RS256", base())
parts := strings.Split(raw, ".")
c := base()
c["sub"] = "someone-else"
cb, _ := json.Marshal(c)
return parts[0] + "." + base64.RawURLEncoding.EncodeToString(cb) + "." + parts[2]
}, "n-1"},
{"not a JWT", func(t *testing.T) string { return "garbage" }, "n-1"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s := oidcService(t, srv.URL, "https://id.example", "client-1")
if _, err := s.identityFromIDToken(context.Background(), c.build(t), c.nonce); err == nil {
t.Fatal("the token was accepted; it must be rejected")
}
})
}
}
// A key too small to be safe must not be usable, however well-formed the JWKS is.
func TestJWKSRejectsUndersizedKeys(t *testing.T) {
small, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatal(err)
}
n := base64.RawURLEncoding.EncodeToString(small.N.Bytes())
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(small.E)).Bytes())
if _, err := rsaKeyFromJWK(n, e); err == nil {
t.Fatal("a 1024-bit RSA key was accepted as a signing key")
}
}
// The discovery document must not be able to point the flow at another issuer.
func TestDiscoveryRefusesAnIssuerMismatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": "https://evil.test",
"authorization_endpoint": "https://evil.test/authorize",
"token_endpoint": "https://evil.test/token",
})
}))
defer srv.Close()
s := NewOAuthService(OAuthConfig{
Enabled: true, Issuer: srv.URL, ClientID: "c", ClientSecret: "s",
})
err := s.discover(context.Background())
if err == nil {
t.Fatal("a discovery document declaring a different issuer was accepted")
}
if !strings.Contains(err.Error(), "issuer") {
t.Fatalf("error does not name the problem: %v", err)
}
}
// A provider that replaces a signing key while keeping its key ID looks, from
// the cache's point of view, entirely fresh. Without a refetch on a failed
// signature, every sign-in would fail until the cache expired — up to an hour
// of nobody being able to get in.
func TestKeyRotationUnderTheSameKeyIDRecovers(t *testing.T) {
first := newTestSigner(t)
second := newTestSigner(t)
second.kid = first.kid // same ID, different key
serving := first
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
serving.jwksHandler().ServeHTTP(w, r)
}))
defer srv.Close()
s := oidcService(t, srv.URL, "https://id.example", "client-1")
claims := func() map[string]any {
return map[string]any{
"iss": "https://id.example", "aud": "client-1", "sub": "user-1",
"nonce": "n", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
}
}
// Warm the cache with the first key.
if _, err := s.identityFromIDToken(context.Background(), first.sign(t, "RS256", claims()), "n"); err != nil {
t.Fatalf("the first key did not work: %v", err)
}
// The provider rotates, reusing the key ID.
serving = second
id, err := s.identityFromIDToken(context.Background(), second.sign(t, "RS256", claims()), "n")
if err != nil {
t.Fatalf("a token signed with the rotated key was rejected: %v", err)
}
if id.Subject != "user-1" {
t.Fatalf("subject = %q", id.Subject)
}
}
// The refetch must not turn a forged token into a way to hammer the provider,
// and must not make a genuinely bad signature acceptable.
func TestRotationRefetchDoesNotAcceptForgeries(t *testing.T) {
signer := newTestSigner(t)
forger := newTestSigner(t)
forger.kid = signer.kid
var fetches int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fetches++
signer.jwksHandler().ServeHTTP(w, r)
}))
defer srv.Close()
s := oidcService(t, srv.URL, "https://id.example", "client-1")
claims := map[string]any{
"iss": "https://id.example", "aud": "client-1", "sub": "user-1",
"nonce": "n", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(),
}
// Warm the cache.
if _, err := s.identityFromIDToken(context.Background(), signer.sign(t, "RS256", claims), "n"); err != nil {
t.Fatal(err)
}
afterWarm := fetches
// Twenty forged tokens must all be refused, and must not cause twenty fetches.
for i := 0; i < 20; i++ {
if _, err := s.identityFromIDToken(context.Background(), forger.sign(t, "RS256", claims), "n"); err == nil {
t.Fatal("a token signed by the wrong key was accepted")
}
}
if extra := fetches - afterWarm; extra > 2 {
t.Fatalf("20 forged tokens caused %d key-set fetches; the refetch must be throttled", extra)
}
}
+439
View File
@@ -0,0 +1,439 @@
package auth
import (
"context"
"crypto"
"crypto/rsa"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"sync"
"time"
// crypto.SHA256/384/512 resolve through the registry, and Hash.New panics
// on an algorithm whose package was never linked in. These imports are what
// make the RS384 and RS512 branches below work rather than crash.
_ "crypto/sha256"
_ "crypto/sha512"
)
// discover fills in any endpoint the operator did not configure, from the
// provider's OpenID discovery document.
//
// It runs once. An explicitly configured endpoint always wins over a discovered
// one, so an operator can override a provider that advertises something wrong.
func (s *OAuthService) discover(ctx context.Context) error {
s.discoOnce.Do(func() {
if s.cfg.Issuer == "" {
return
}
if s.cfg.AuthorizeURL != "" && s.cfg.TokenURL != "" && s.cfg.JWKSURL != "" {
return // nothing left to learn
}
u := strings.TrimRight(s.cfg.Issuer, "/") + "/.well-known/openid-configuration"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
s.discoErr = fmt.Errorf("%w: %v", ErrOAuthProvider, err)
return
}
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
s.discoErr = fmt.Errorf("%w: fetching %s: %v", ErrOAuthProvider, u, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.discoErr = fmt.Errorf("%w: %s returned %d", ErrOAuthProvider, u, resp.StatusCode)
return
}
var doc struct {
Issuer string `json:"issuer"`
AuthzURL string `json:"authorization_endpoint"`
TokenURL string `json:"token_endpoint"`
UserInfoURL string `json:"userinfo_endpoint"`
JWKSURL string `json:"jwks_uri"`
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderBody))
if err != nil {
s.discoErr = fmt.Errorf("%w: %v", ErrOAuthProvider, err)
return
}
if err := json.Unmarshal(body, &doc); err != nil {
s.discoErr = fmt.Errorf("%w: discovery document was not JSON: %v", ErrOAuthProvider, err)
return
}
// The document's own issuer must match the one configured, or a
// compromised discovery endpoint could redirect the whole flow.
if doc.Issuer != "" && strings.TrimRight(doc.Issuer, "/") != strings.TrimRight(s.cfg.Issuer, "/") {
s.discoErr = fmt.Errorf("%w: discovery document declares issuer %q, expected %q",
ErrOAuthProvider, doc.Issuer, s.cfg.Issuer)
return
}
if s.cfg.AuthorizeURL == "" {
s.cfg.AuthorizeURL = doc.AuthzURL
}
if s.cfg.TokenURL == "" {
s.cfg.TokenURL = doc.TokenURL
}
if s.cfg.UserInfoURL == "" {
s.cfg.UserInfoURL = doc.UserInfoURL
}
if s.cfg.JWKSURL == "" {
s.cfg.JWKSURL = doc.JWKSURL
}
})
if s.discoErr != nil {
return s.discoErr
}
if s.cfg.AuthorizeURL == "" || s.cfg.TokenURL == "" {
return fmt.Errorf("%w: no authorization or token endpoint is configured or discoverable",
ErrOAuthProvider)
}
return nil
}
// identityFromIDToken validates an OIDC ID token and reads the identity from it.
//
// Validation is signature first, then claims. A token whose signature does not
// verify is never parsed for meaning.
func (s *OAuthService) identityFromIDToken(ctx context.Context, raw, nonce string) (*Identity, error) {
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("%w: ID token is not a three-part JWT", ErrOAuthIdentity)
}
headerRaw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("%w: ID token header is not base64url", ErrOAuthIdentity)
}
var header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
if err := json.Unmarshal(headerRaw, &header); err != nil {
return nil, fmt.Errorf("%w: ID token header is not JSON", ErrOAuthIdentity)
}
// Only RSA signing is accepted. "none" is the classic JWT forgery, and an
// HMAC algorithm here would let the public key be used as the shared
// secret — both are refused by allowing an explicit list rather than
// trusting the token to name its own algorithm.
var hash crypto.Hash
switch header.Alg {
case "RS256":
hash = crypto.SHA256
case "RS384":
hash = crypto.SHA384
case "RS512":
hash = crypto.SHA512
default:
return nil, fmt.Errorf("%w: ID token algorithm %q is not accepted", ErrOAuthIdentity, header.Alg)
}
// Belt and braces: if the algorithm's package were ever dropped, this
// returns an error instead of panicking inside a request handler.
if !hash.Available() {
return nil, fmt.Errorf("%w: hash for %s is not available in this build",
ErrOAuthIdentity, header.Alg)
}
if s.cfg.JWKSURL == "" {
return nil, fmt.Errorf("%w: no JWKS endpoint to verify the ID token with", ErrOAuthIdentity)
}
key, fromCache, err := s.jwks.key(ctx, s.cfg.JWKSURL, header.Kid)
if err != nil {
return nil, err
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("%w: ID token signature is not base64url", ErrOAuthIdentity)
}
signed := parts[0] + "." + parts[1]
h := hash.New()
h.Write([]byte(signed))
digest := h.Sum(nil)
if err := rsa.VerifyPKCS1v15(key, hash, digest, sig); err != nil {
// A cached key that no longer verifies is what a rotation looks like
// when the provider keeps the key ID. Refetch once and try again;
// anything else is a genuinely bad signature.
if !fromCache {
return nil, fmt.Errorf("%w: ID token signature does not verify", ErrOAuthIdentity)
}
rotated, rerr := s.jwks.rotatedKey(ctx, s.cfg.JWKSURL, header.Kid)
if rerr != nil {
return nil, fmt.Errorf("%w: ID token signature does not verify", ErrOAuthIdentity)
}
if err := rsa.VerifyPKCS1v15(rotated, hash, digest, sig); err != nil {
return nil, fmt.Errorf("%w: ID token signature does not verify", ErrOAuthIdentity)
}
}
payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("%w: ID token payload is not base64url", ErrOAuthIdentity)
}
var claims map[string]any
if err := json.Unmarshal(payloadRaw, &claims); err != nil {
return nil, fmt.Errorf("%w: ID token payload is not JSON", ErrOAuthIdentity)
}
if err := s.checkIDTokenClaims(claims, nonce); err != nil {
return nil, err
}
return identityFromClaims(claims)
}
// checkIDTokenClaims enforces issuer, audience, expiry and nonce.
func (s *OAuthService) checkIDTokenClaims(claims map[string]any, nonce string) error {
if iss := claimString(claims, "iss"); s.cfg.Issuer != "" &&
strings.TrimRight(iss, "/") != strings.TrimRight(s.cfg.Issuer, "/") {
return fmt.Errorf("%w: ID token issuer %q does not match %q",
ErrOAuthIdentity, iss, s.cfg.Issuer)
}
if !audienceContains(claims["aud"], s.cfg.ClientID) {
return fmt.Errorf("%w: ID token audience does not include this client", ErrOAuthIdentity)
}
now := time.Now()
// A little leeway for clock skew, in both directions.
const skew = 2 * time.Minute
if exp, ok := claimTime(claims, "exp"); ok && now.After(exp.Add(skew)) {
return fmt.Errorf("%w: ID token has expired", ErrOAuthIdentity)
} else if !ok {
return fmt.Errorf("%w: ID token has no expiry", ErrOAuthIdentity)
}
if iat, ok := claimTime(claims, "iat"); ok && now.Add(skew).Before(iat) {
return fmt.Errorf("%w: ID token is issued in the future", ErrOAuthIdentity)
}
// The nonce binds the token to this browser's sign-in attempt, which is
// what stops a token obtained elsewhere being replayed here.
if nonce != "" {
got := claimString(claims, "nonce")
if subtle.ConstantTimeCompare([]byte(got), []byte(nonce)) != 1 {
return fmt.Errorf("%w: ID token nonce does not match this sign-in attempt", ErrOAuthIdentity)
}
}
return nil
}
func audienceContains(aud any, clientID string) bool {
switch v := aud.(type) {
case string:
return subtle.ConstantTimeCompare([]byte(v), []byte(clientID)) == 1
case []any:
for _, item := range v {
if s, ok := item.(string); ok &&
subtle.ConstantTimeCompare([]byte(s), []byte(clientID)) == 1 {
return true
}
}
}
return false
}
func claimTime(m map[string]any, key string) (time.Time, bool) {
switch v := m[key].(type) {
case float64:
return time.Unix(int64(v), 0), true
case int64:
return time.Unix(v, 0), true
}
return time.Time{}, false
}
// ---------------------------------------------------------------------------
// JWKS
// ---------------------------------------------------------------------------
// jwksCache holds the provider's signing keys.
//
// Keys are cached because fetching them per sign-in would make the identity
// provider a hard dependency of every login, and refreshed on an unknown key ID
// because that is exactly what a key rotation looks like.
type jwksCache struct {
client *http.Client
mu sync.RWMutex
keys map[string]*rsa.PublicKey
fetchedAt time.Time
// lastMiss throttles refetching, so an unknown key ID cannot be used to
// make Relata hammer the provider.
lastMiss time.Time
// lastRotationFetch throttles the refetch that follows a failed signature.
lastRotationFetch time.Time
}
const (
jwksTTL = time.Hour
jwksMissDelay = time.Minute
)
func newJWKSCache(c *http.Client) *jwksCache {
return &jwksCache{client: c, keys: map[string]*rsa.PublicKey{}}
}
func (c *jwksCache) key(ctx context.Context, url, kid string) (*rsa.PublicKey, bool, error) {
c.mu.RLock()
k, ok := c.keys[kid]
fresh := time.Since(c.fetchedAt) < jwksTTL
missRecently := time.Since(c.lastMiss) < jwksMissDelay
c.mu.RUnlock()
if ok && fresh {
return k, true, nil
}
if !ok && fresh && missRecently {
return nil, false, fmt.Errorf("%w: no signing key %q in the provider's key set", ErrOAuthIdentity, kid)
}
if err := c.refresh(ctx, url); err != nil {
// A stale key still verifies a token signed before the fetch failed.
if ok {
return k, true, nil
}
return nil, false, err
}
c.mu.RLock()
k, ok = c.keys[kid]
c.mu.RUnlock()
if !ok {
c.mu.Lock()
c.lastMiss = time.Now()
c.mu.Unlock()
return nil, false, fmt.Errorf("%w: no signing key %q in the provider's key set", ErrOAuthIdentity, kid)
}
return k, false, nil
}
// rotatedKey refetches the key set and returns the key for kid.
//
// It exists for one case: a provider that replaces a key while keeping its key
// ID. Nothing about the cached entry looks stale, so without this every sign-in
// would fail until the cache expired — up to an hour of nobody being able to
// get in. It is called only after a signature has already failed against a
// cached key, and it is throttled, so it cannot be used to make Relata hammer
// the provider.
func (c *jwksCache) rotatedKey(ctx context.Context, url, kid string) (*rsa.PublicKey, error) {
c.mu.Lock()
if time.Since(c.lastRotationFetch) < jwksMissDelay {
c.mu.Unlock()
return nil, fmt.Errorf("%w: signing key %q did not verify and the key set was "+
"refetched too recently to try again", ErrOAuthIdentity, kid)
}
c.lastRotationFetch = time.Now()
c.mu.Unlock()
if err := c.refresh(ctx, url); err != nil {
return nil, err
}
c.mu.RLock()
k, ok := c.keys[kid]
c.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("%w: no signing key %q in the provider's key set", ErrOAuthIdentity, kid)
}
return k, nil
}
func (c *jwksCache) refresh(ctx context.Context, url string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("%w: %v", ErrOAuthProvider, err)
}
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("%w: fetching signing keys: %v", ErrOAuthProvider, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%w: key set endpoint returned %d", ErrOAuthProvider, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderBody))
if err != nil {
return fmt.Errorf("%w: %v", ErrOAuthProvider, err)
}
var set struct {
Keys []struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.Unmarshal(body, &set); err != nil {
return fmt.Errorf("%w: key set was not JSON: %v", ErrOAuthProvider, err)
}
keys := map[string]*rsa.PublicKey{}
for _, k := range set.Keys {
if k.Kty != "RSA" || (k.Use != "" && k.Use != "sig") {
continue
}
pub, err := rsaKeyFromJWK(k.N, k.E)
if err != nil {
continue // one unusable key must not discard the rest
}
keys[k.Kid] = pub
}
if len(keys) == 0 {
return fmt.Errorf("%w: key set contained no usable RSA signing keys", ErrOAuthProvider)
}
c.mu.Lock()
c.keys = keys
c.fetchedAt = time.Now()
c.mu.Unlock()
return nil
}
func rsaKeyFromJWK(nB64, eB64 string) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
if err != nil {
return nil, err
}
eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
if err != nil {
return nil, err
}
if len(eBytes) == 0 || len(eBytes) > 8 {
return nil, fmt.Errorf("implausible RSA exponent")
}
// Left-pad the exponent to eight bytes so it can be read as a uint64.
padded := make([]byte, 8)
copy(padded[8-len(eBytes):], eBytes)
e := binary.BigEndian.Uint64(padded)
if e == 0 || e > 1<<31 {
return nil, fmt.Errorf("implausible RSA exponent")
}
key := &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: int(e)}
if key.N.BitLen() < 2048 {
return nil, fmt.Errorf("RSA key smaller than 2048 bits")
}
return key, nil
}
+427
View File
@@ -0,0 +1,427 @@
package auth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
"github.com/andydixon/relata/internal/observability"
)
// Errors returned by the session service.
var (
// ErrInvalidCredentials is returned for both an unknown username and a
// wrong password. The two cases are never distinguished to a caller.
ErrInvalidCredentials = errors.New("the username or password is incorrect")
ErrAccountDisabled = errors.New("this account is disabled")
ErrTooManyAttempts = errors.New("too many sign-in attempts; please wait and try again")
ErrNoSession = errors.New("no active session")
)
// sessionTokenBytes is the entropy in a session cookie value.
const sessionTokenBytes = 32
// Service performs authentication and session management.
type Service struct {
users domain.UserRepository
sessions domain.SessionRepository
cfg config.SessionConfig
secure bool
limiter *loginLimiter
metrics *observability.Metrics
}
// NewService builds the authentication service.
func NewService(users domain.UserRepository, sessions domain.SessionRepository,
cfg config.SessionConfig, secureCookies bool, m *observability.Metrics) *Service {
return &Service{
users: users,
sessions: sessions,
cfg: cfg,
secure: secureCookies,
limiter: newLoginLimiter(cfg.LoginRateBurst, cfg.LoginRateWindow),
metrics: m,
}
}
// CookieName returns the session cookie's name.
func (s *Service) CookieName() string { return s.cfg.CookieName }
// LoginResult carries what a successful login produced.
type LoginResult struct {
User *domain.User
Session *domain.Session
// Token is the raw cookie value. It exists only for the length of the
// response and is never stored or logged.
Token string
}
// Login authenticates a username and password and creates a new session.
//
// Several properties are load-bearing:
// - rateKey (normally the client IP) is throttled before any database work;
// - an unknown username still costs a full Argon2id verification, so timing
// does not disclose which usernames exist;
// - every failure returns the same error, whatever actually went wrong;
// - a brand new session ID is minted, so a pre-existing session identifier
// cannot be carried across the authentication boundary (session fixation).
func (s *Service) Login(ctx context.Context, username, password, rateKey, userAgent string) (*LoginResult, error) {
if !s.limiter.allow(rateKey) {
s.count("rate_limited")
return nil, ErrTooManyAttempts
}
u, err := s.users.GetByUsername(ctx, username)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
// Spend comparable time so the response does not reveal that the
// username is unknown.
SpendDummyVerification(password)
s.count("unknown_user")
return nil, ErrInvalidCredentials
}
return nil, err
}
// A federated account has no password hash. Refuse it here explicitly
// rather than relying on hash verification to fail on an empty string: the
// guarantee that an OAuth identity cannot be impersonated with a password
// should not depend on the behaviour of a parser.
if !u.CanLoginLocally() {
SpendDummyVerification(password)
s.count("federated_account")
return nil, ErrInvalidCredentials
}
ok, err := VerifyPassword(password, u.PasswordHash)
if err != nil {
// A corrupt stored hash must not be reported to the client as
// something distinguishable from a wrong password.
observability.LoggerFrom(ctx).Error("password hash could not be verified",
"user_id", u.ID, "error", err.Error())
s.count("hash_error")
return nil, ErrInvalidCredentials
}
if !ok {
s.count("bad_password")
return nil, ErrInvalidCredentials
}
if u.Disabled {
s.count("disabled")
return nil, ErrInvalidCredentials
}
sess, token, err := s.createSession(ctx, u, userAgent)
if err != nil {
return nil, err
}
// A successful login clears the attempt budget for this client.
s.limiter.reset(rateKey)
s.count("success")
now := time.Now().UTC()
u.LastLoginAt = &now
if err := s.users.Update(ctx, u); err != nil {
// Not fatal: the session exists and the user is signed in.
observability.LoggerFrom(ctx).Warn("could not record last login time", "error", err.Error())
}
return &LoginResult{User: u, Session: sess, Token: token}, nil
}
func (s *Service) count(outcome string) {
if s.metrics != nil {
s.metrics.AuthAttempts.WithLabelValues(outcome).Inc()
}
}
// createSession mints a fresh session and its opaque token.
func (s *Service) createSession(ctx context.Context, u *domain.User, userAgent string) (*domain.Session, string, error) {
token, err := ids.Token(sessionTokenBytes)
if err != nil {
return nil, "", err
}
csrf, err := ids.Token(32)
if err != nil {
return nil, "", err
}
now := time.Now().UTC()
sess := &domain.Session{
ID: ids.New(),
TokenHash: ids.HashToken(token),
CSRFToken: csrf,
UserID: u.ID,
WorkspaceID: u.WorkspaceID,
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(s.cfg.IdleTimeout),
AbsoluteExpiry: now.Add(s.cfg.AbsoluteTimeout),
UserAgentHash: hashUserAgent(userAgent),
}
if err := s.sessions.Create(ctx, sess); err != nil {
return nil, "", err
}
return sess, token, nil
}
// Resolve validates a session token and returns the authenticated principal.
//
// It extends the idle expiry as a side effect, but only once per minute, so
// that a busy page does not write to the database on every request.
func (s *Service) Resolve(ctx context.Context, token string) (*domain.Principal, *domain.Session, error) {
if token == "" {
return nil, nil, ErrNoSession
}
sess, err := s.sessions.GetByTokenHash(ctx, ids.HashToken(token))
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, nil, ErrNoSession
}
return nil, nil, err
}
now := time.Now().UTC()
if !sess.Active(now) {
return nil, nil, ErrNoSession
}
u, err := s.users.GetByID(ctx, sess.UserID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, nil, ErrNoSession
}
return nil, nil, err
}
if u.Disabled {
_ = s.sessions.Revoke(ctx, sess.ID)
return nil, nil, ErrNoSession
}
// The session's workspace is authoritative for the request. Should a user's
// workspace change, existing sessions are revoked rather than silently
// re-scoped, so an in-flight session cannot straddle two workspaces.
if u.WorkspaceID != sess.WorkspaceID {
_ = s.sessions.Revoke(ctx, sess.ID)
return nil, nil, ErrNoSession
}
if now.Sub(sess.LastSeenAt) > time.Minute {
newExpiry := now.Add(s.cfg.IdleTimeout)
if newExpiry.After(sess.AbsoluteExpiry) {
newExpiry = sess.AbsoluteExpiry
}
if err := s.sessions.Touch(ctx, sess.ID, now, newExpiry); err != nil {
observability.LoggerFrom(ctx).Debug("could not extend session", "error", err.Error())
}
sess.LastSeenAt = now
sess.ExpiresAt = newExpiry
}
return &domain.Principal{
UserID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: u.Role,
WorkspaceID: sess.WorkspaceID,
SessionID: sess.ID,
}, sess, nil
}
// Logout revokes a session.
func (s *Service) Logout(ctx context.Context, sessionID string) error {
return s.sessions.Revoke(ctx, sessionID)
}
// SweepExpired removes sessions past their absolute expiry.
func (s *Service) SweepExpired(ctx context.Context) (int64, error) {
return s.sessions.DeleteExpired(ctx, time.Now().UTC())
}
// SetSessionCookie writes the session cookie.
//
// HttpOnly keeps the token away from JavaScript, SameSite=Lax blocks it from
// cross-site form posts while still allowing ordinary top-level navigation, and
// Secure is set whenever the deployment is served over HTTPS.
func (s *Service) SetSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: s.cfg.CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
}
// ClearSessionCookie expires the session cookie.
func (s *Service) ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: s.cfg.CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
}
// TokenFromRequest reads the session token from the request cookie.
func (s *Service) TokenFromRequest(r *http.Request) string {
c, err := r.Cookie(s.cfg.CookieName)
if err != nil {
return ""
}
return c.Value
}
// CheckCSRF compares a submitted CSRF token with the session's, in constant
// time.
func CheckCSRF(sessionToken, submitted string) bool {
if sessionToken == "" || submitted == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(sessionToken), []byte(submitted)) == 1
}
func hashUserAgent(ua string) string {
if ua == "" {
return ""
}
if len(ua) > 512 {
ua = ua[:512]
}
sum := sha256.Sum256([]byte(ua))
return hex.EncodeToString(sum[:8])
}
// ---------------------------------------------------------------------------
// Login rate limiting
// ---------------------------------------------------------------------------
// loginLimiter throttles sign-in attempts per client key.
//
// It is an in-process fixed-window counter: Relata is a single-process
// application, so a shared store would add a dependency without adding
// protection. The limit is on attempts, and a successful login clears the
// counter, so a legitimate user who mistypes a few times is not locked out for
// long.
type loginLimiter struct {
mu sync.Mutex
buckets map[string]*bucket
burst int
window time.Duration
// lastSweep bounds map growth from a flood of distinct keys.
lastSweep time.Time
}
type bucket struct {
count int
start time.Time
}
func newLoginLimiter(burst int, window time.Duration) *loginLimiter {
if burst <= 0 {
burst = 8
}
if window <= 0 {
window = 5 * time.Minute
}
return &loginLimiter{
buckets: make(map[string]*bucket),
burst: burst,
window: window,
lastSweep: time.Now(),
}
}
func (l *loginLimiter) allow(key string) bool {
if key == "" {
key = "unknown"
}
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.sweepLocked(now)
b, ok := l.buckets[key]
if !ok || now.Sub(b.start) > l.window {
l.buckets[key] = &bucket{count: 1, start: now}
return true
}
if b.count >= l.burst {
return false
}
b.count++
return true
}
func (l *loginLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.buckets, key)
}
// sweepLocked drops expired buckets. Without it, an attacker cycling source
// addresses would grow the map without bound.
func (l *loginLimiter) sweepLocked(now time.Time) {
if now.Sub(l.lastSweep) < l.window {
return
}
l.lastSweep = now
for k, b := range l.buckets {
if now.Sub(b.start) > l.window {
delete(l.buckets, k)
}
}
}
// ClientKey derives the rate-limiting key for a request.
//
// Proxy headers are honoured only when the operator has explicitly said the
// deployment sits behind a trusted proxy. Trusting them by default would let
// anyone defeat the rate limit by varying X-Forwarded-For.
func ClientKey(r *http.Request, trustProxy bool) string {
if trustProxy {
// X-Real-IP first. A reverse proxy sets it to the address it actually
// accepted the connection from, replacing anything the client sent, so
// it is the one header here a client cannot choose.
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
// Otherwise the RIGHTMOST entry of X-Forwarded-For — the hop appended
// by the nearest proxy, which is the last one we trust.
//
// The leftmost entry is the obvious choice and the wrong one: proxies
// append rather than replace, so a client that sends
// "X-Forwarded-For: 1.2.3.4" puts that value at the front. Reading it
// would give every forged header its own rate-limit bucket and defeat
// login throttling entirely — the attack the throttle exists to stop.
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.LastIndex(xff, ","); i >= 0 {
return strings.TrimSpace(xff[i+1:])
}
return strings.TrimSpace(xff)
}
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return strings.Trim(host, "[]")
}