Code sync, security fixes
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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] + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "[]")
|
||||
}
|
||||
Reference in New Issue
Block a user