textmachine/platform/internal/auth/session.go

58 lines
2.3 KiB
Go

// Package auth is the session layer: ONE server-side session (D39.84), presented either as a
// __Host cookie by the browser or as a Bearer token by the desktop and CLI clients. No handler may
// look at either — the principal is created in middleware and read from the request context.
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"time"
)
// CookieName is the browser presentation. The __Host prefix is not decoration: it forbids a Domain
// attribute and requires Secure + Path=/, which is what stops a sibling subdomain from writing a
// session cookie for the app.
const CookieName = "__Host-tm_session"
// tokenBytes is 256 bits of entropy (STACK_DECISIONS §5). Opaque: it encodes nothing, so a stolen
// token cannot be read, and a JWT's "verify without the database" property is exactly what we do
// not want — revocation must be immediate.
const tokenBytes = 32
// ErrNoSession is returned when nothing live matches the presented token. Callers must not
// distinguish "unknown token" from "expired token" on the wire: the difference is an oracle.
var ErrNoSession = errors.New("auth: no live session")
// Session is what the store knows about a live session.
type Session struct {
UserID string
IdleExpiresAt time.Time
AbsoluteExpiresAt time.Time
}
// SessionStore is the persistence the middleware needs. Lookup MUST apply expiry and revocation
// itself, so a store that forgets a clause cannot be papered over here.
type SessionStore interface {
Lookup(ctx context.Context, digest []byte, now time.Time) (Session, error)
Touch(ctx context.Context, digest []byte, now time.Time, idleTTL time.Duration) error
}
// NewToken mints a credential. The plaintext exists only in this return value and in the client:
// what reaches the database is Digest(token). No error return — crypto/rand.Read never fails, it
// crashes the program instead.
func NewToken() string {
b := make([]byte, tokenBytes)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// Digest is the stored form. Plain SHA-256 with no salt or stretching is correct HERE and only
// here: the input is 256 uniform random bits, so there is no guessable space to slow an attacker
// down in — unlike a password.
func Digest(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}