| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456 |
- // Package authd implements the in-house multi-tenant auth IdP that
- // backs the M13 admin UI. It issues short-lived access JWTs (HS256,
- // 15m by default) and long-lived refresh tokens (opaque random
- // strings, 7d, stored server-side with rotation + family re-use
- // detection).
- //
- // The refresh-token side of things is mostly SQL functions
- // (migrations/009_auth.up.sql). This package owns:
- //
- // - JWT signing and verification (HS256, shared secret v1).
- // - Magic-link token generation, hashing, and consumption.
- // - Password hashing (bcrypt).
- // - Server-side session validation for incoming requests
- // (used by other services via the VerifyAccessToken call).
- // - Audit log writes for auth.* events.
- //
- // Out of scope for v1 (deferred to v2): JWKS, RS256, SSO mapping,
- // MFA, OAuth2 flows. The HS256 shared secret is the only auth
- // material — it MUST be rotated before any multi-instance deploy.
- //
- // Threading: Authd is safe for concurrent use. The store is
- // stateless; all state lives in Postgres.
- package authd
- import (
- "context"
- "crypto/rand"
- "crypto/sha256"
- "crypto/subtle"
- "encoding/hex"
- "errors"
- "fmt"
- "time"
- "github.com/golang-jwt/jwt/v5"
- "golang.org/x/crypto/bcrypt"
- "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
- )
- // Config is the authd runtime config. Loaded from env in main.
- type Config struct {
- // JWTSecret is the HS256 signing key. Must be at least 32 bytes.
- // Generated by scripts/gen-jwt-secret.sh on first install.
- JWTSecret []byte
- // Issuer is the `iss` claim. Should match across all services
- // that need to verify tokens.
- Issuer string
- // AccessTokenTTL is how long access JWTs are valid. 15m default.
- AccessTokenTTL time.Duration
- // RefreshTokenTTL is how long refresh tokens are valid. 7d default.
- RefreshTokenTTL time.Duration
- // MagicLinkTTL is how long a magic link is valid. 24h default.
- MagicLinkTTL time.Duration
- // BcryptCost is the bcrypt work factor. 12 default.
- BcryptCost int
- }
- // DefaultConfig returns Config with safe defaults. JWTSecret is
- // zero — main() must load it from env.
- func DefaultConfig() Config {
- return Config{
- Issuer: "broad-announce",
- AccessTokenTTL: 15 * time.Minute,
- RefreshTokenTTL: 7 * 24 * time.Hour,
- MagicLinkTTL: 24 * time.Hour,
- BcryptCost: 12,
- }
- }
- // Store returns the underlying Store. Used by HTTP handlers that
- // need direct access to user/tenant lookups not on the high-level
- // API.
- func (a *Authd) Store() *Store { return a.store }
- // Authd is the service object. Construct once at startup, pass to
- // the HTTP handlers.
- type Authd struct {
- cfg Config
- store *Store
- }
- // New constructs an Authd. pool is the pgx pool; cfg must have a
- // non-zero JWTSecret (caller validates). pool may be nil for
- // tests that only exercise the pure-Go paths (bcrypt, JWT); any
- // call that hits the DB will return an error in that mode.
- func New(pool *postgres.Pool, cfg Config) (*Authd, error) {
- if len(cfg.JWTSecret) < 32 {
- return nil, fmt.Errorf("authd: JWTSecret must be at least 32 bytes (got %d)", len(cfg.JWTSecret))
- }
- if cfg.AccessTokenTTL == 0 {
- cfg.AccessTokenTTL = 15 * time.Minute
- }
- if cfg.RefreshTokenTTL == 0 {
- cfg.RefreshTokenTTL = 7 * 24 * time.Hour
- }
- if cfg.MagicLinkTTL == 0 {
- cfg.MagicLinkTTL = 24 * time.Hour
- }
- if cfg.BcryptCost == 0 {
- cfg.BcryptCost = 12
- }
- return &Authd{cfg: cfg, store: NewStore(pool)}, nil
- }
- // ---------------------------------------------------------------------------
- // Errors
- // ---------------------------------------------------------------------------
- // ErrInvalidCredentials is returned when login fails. The HTTP
- // handler maps this to 401 with a generic message — we never
- // disclose whether the email or the password was wrong.
- var ErrInvalidCredentials = errors.New("authd: invalid credentials")
- // ErrUserNotFound is the underlying cause. Handlers should NOT
- // surface this to clients.
- var ErrUserNotFound = errors.New("authd: user not found")
- // ErrUserDisabled is returned when the user exists but is in
- // 'pending' (no password set yet, must use magic link) or 'disabled'
- // (admin-blocked).
- var ErrUserDisabled = errors.New("authd: user not active")
- // ErrMagicLinkInvalid is returned for unknown / expired / consumed
- // magic links.
- var ErrMagicLinkInvalid = errors.New("authd: magic link invalid or expired")
- // ErrTokenReuse is returned when a refresh token is used after it's
- // been rotated. The whole family has been killed.
- var ErrTokenReuse = errors.New("authd: refresh token re-use detected, session killed")
- // ---------------------------------------------------------------------------
- // Passwords
- // ---------------------------------------------------------------------------
- // HashPassword returns a bcrypt hash of the plaintext password. Cost
- // is taken from cfg.BcryptCost.
- func (a *Authd) HashPassword(ctx context.Context, plaintext string) (string, error) {
- hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), a.cfg.BcryptCost)
- if err != nil {
- return "", fmt.Errorf("bcrypt: %w", err)
- }
- return string(hash), nil
- }
- // VerifyPassword reports whether plaintext matches the stored hash.
- // Returns nil on match, bcrypt.ErrMismatchedHashAndPassword on
- // mismatch, or another error if the hash is malformed.
- func (a *Authd) VerifyPassword(hash, plaintext string) error {
- return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext))
- }
- // ---------------------------------------------------------------------------
- // Magic links
- // ---------------------------------------------------------------------------
- // IssueMagicLink generates a magic link token for a user, hashes it,
- // stores the hash, and returns the PLAINTEXT token (the caller
- // emails this — it is never stored).
- //
- // Returns the token (hex, 64 chars), its expiry, and any error.
- func (a *Authd) IssueMagicLink(ctx context.Context, userID, purpose string) (token string, expiresAt time.Time, err error) {
- if purpose != "invite" && purpose != "password_reset" && purpose != "mfa_reset" {
- return "", time.Time{}, fmt.Errorf("authd: invalid magic link purpose %q", purpose)
- }
- // 32 random bytes → 64 hex chars
- raw := make([]byte, 32)
- if _, err := rand.Read(raw); err != nil {
- return "", time.Time{}, fmt.Errorf("rand: %w", err)
- }
- token = hex.EncodeToString(raw)
- hash := sha256.Sum256(raw) // hash the RAW bytes, not the hex string
- expiresAt = time.Now().Add(a.cfg.MagicLinkTTL)
- if err := a.store.InsertMagicLink(ctx, userID, hash[:], purpose, expiresAt); err != nil {
- return "", time.Time{}, err
- }
- return token, expiresAt, nil
- }
- // ConsumeMagicLink validates a magic link token and returns the
- // associated user_id. Marks the link as consumed. Caller should
- // treat ErrMagicLinkInvalid as 401.
- func (a *Authd) ConsumeMagicLink(ctx context.Context, token, ip, ua string) (string, error) {
- raw, err := hex.DecodeString(token)
- if err != nil || len(raw) != 32 {
- return "", ErrMagicLinkInvalid
- }
- hash := sha256.Sum256(raw)
- userID, err := a.store.ConsumeMagicLink(ctx, hash[:], ip, ua)
- if err != nil {
- return "", err
- }
- return userID, nil
- }
- // ---------------------------------------------------------------------------
- // Sessions (refresh tokens, SQL-side)
- // ---------------------------------------------------------------------------
- // LoginResult is what Login + MagicLinkConsume return.
- type LoginResult struct {
- AccessToken string
- RefreshToken string
- UserID string
- TenantID string
- Role string
- ExpiresAt time.Time
- }
- // Login authenticates with email+password, returns a fresh session.
- // On failure, returns ErrInvalidCredentials (or ErrUserDisabled)
- // and writes an audit_log row regardless of outcome.
- func (a *Authd) Login(ctx context.Context, email, password, ip, ua string) (*LoginResult, error) {
- user, err := a.store.GetUserByEmail(ctx, email)
- if err != nil {
- if errors.Is(err, ErrUserNotFound) {
- // Audit the failed attempt. Don't disclose existence.
- _ = a.store.WriteAudit(ctx, "auth.login", "", ip, ua, "", "",
- map[string]any{"email": email, "success": false, "reason": "not_found"})
- return nil, ErrInvalidCredentials
- }
- return nil, err
- }
- if user.Status != "active" {
- _ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
- map[string]any{"email": email, "success": false, "reason": "not_active"})
- return nil, ErrUserDisabled
- }
- if err := a.VerifyPassword(user.PasswordHash, password); err != nil {
- _ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
- map[string]any{"email": email, "success": false, "reason": "bad_password"})
- return nil, ErrInvalidCredentials
- }
- res, err := a.issueSession(ctx, user, ip, ua)
- if err != nil {
- return nil, err
- }
- _ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
- map[string]any{"email": email, "success": true})
- _ = a.store.TouchUserLogin(ctx, user.ID)
- return res, nil
- }
- // issueSession is the shared path: mint JWT + ask Postgres for a
- // refresh token (via the SQL function).
- func (a *Authd) issueSession(ctx context.Context, user *User, ip, ua string) (*LoginResult, error) {
- accessJWT, jti, expiresAt, err := a.mintAccessToken(user)
- if err != nil {
- return nil, err
- }
- refreshToken, err := a.store.IssueRefreshToken(ctx, user.ID, jti, int(a.cfg.RefreshTokenTTL.Seconds()), ip, ua)
- if err != nil {
- return nil, err
- }
- return &LoginResult{
- AccessToken: accessJWT,
- RefreshToken: refreshToken,
- UserID: user.ID,
- TenantID: user.TenantID,
- Role: user.Role,
- ExpiresAt: expiresAt,
- }, nil
- }
- // Refresh swaps a refresh token for a new access+refresh pair. The
- // old refresh is revoked; if the old refresh was already consumed,
- // the WHOLE family is killed (returns ErrTokenReuse).
- func (a *Authd) Refresh(ctx context.Context, presentedToken, ip, ua string) (*LoginResult, error) {
- user, newRefresh, newJTI, err := a.store.RotateRefreshToken(ctx, presentedToken, int(a.cfg.RefreshTokenTTL.Seconds()), ip, ua)
- if err != nil {
- return nil, err
- }
- accessJWT, expiresAt, err := a.mintAccessTokenWithJTI(user, newJTI)
- if err != nil {
- return nil, err
- }
- _ = a.store.WriteAudit(ctx, "auth.refresh", user.ID, ip, ua, "", user.TenantID,
- map[string]any{"success": true})
- return &LoginResult{
- AccessToken: accessJWT,
- RefreshToken: newRefresh,
- UserID: user.ID,
- TenantID: user.TenantID,
- Role: user.Role,
- ExpiresAt: expiresAt,
- }, nil
- }
- // Logout revokes the refresh token. Idempotent — a missing token
- // returns nil (no error) so logout can't be used to probe token
- // validity.
- func (a *Authd) Logout(ctx context.Context, refreshToken, userID, ip, ua string) error {
- if err := a.store.RevokeRefreshToken(ctx, refreshToken); err != nil {
- return err
- }
- _ = a.store.WriteAudit(ctx, "auth.logout", userID, ip, ua, "", "", nil)
- return nil
- }
- // ---------------------------------------------------------------------------
- // JWT
- // ---------------------------------------------------------------------------
- // AccessClaims is what we sign into the access JWT. It carries the
- // minimum to authorize a request, NOT a session token.
- type AccessClaims struct {
- UserID string `json:"sub"`
- TenantID string `json:"tid,omitempty"`
- Role string `json:"role"`
- TokenType string `json:"typ"` // always "access"
- jwt.RegisteredClaims
- }
- // mintAccessToken signs an access JWT for the given user. The JTI
- // is generated internally.
- func (a *Authd) mintAccessToken(user *User) (token string, jti string, expiresAt time.Time, err error) {
- now := time.Now()
- expiresAt = now.Add(a.cfg.AccessTokenTTL)
- jti = newJTI()
- claims := AccessClaims{
- UserID: user.ID,
- TenantID: user.TenantID,
- Role: user.Role,
- TokenType: "access",
- RegisteredClaims: jwt.RegisteredClaims{
- Issuer: a.cfg.Issuer,
- Subject: user.ID,
- ExpiresAt: jwt.NewNumericDate(expiresAt),
- IssuedAt: jwt.NewNumericDate(now),
- NotBefore: jwt.NewNumericDate(now),
- ID: jti,
- },
- }
- t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
- signed, err := t.SignedString(a.cfg.JWTSecret)
- if err != nil {
- return "", "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
- }
- return signed, jti, expiresAt, nil
- }
- // mintAccessTokenWithJTI signs an access JWT using the caller-supplied
- // JTI. Used by Refresh so the new refresh's access_jti matches the
- // row we just inserted.
- func (a *Authd) mintAccessTokenWithJTI(user *User, jti string) (string, time.Time, error) {
- now := time.Now()
- expiresAt := now.Add(a.cfg.AccessTokenTTL)
- claims := AccessClaims{
- UserID: user.ID,
- TenantID: user.TenantID,
- Role: user.Role,
- TokenType: "access",
- RegisteredClaims: jwt.RegisteredClaims{
- Issuer: a.cfg.Issuer,
- Subject: user.ID,
- ExpiresAt: jwt.NewNumericDate(expiresAt),
- IssuedAt: jwt.NewNumericDate(now),
- NotBefore: jwt.NewNumericDate(now),
- ID: jti,
- },
- }
- t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
- signed, err := t.SignedString(a.cfg.JWTSecret)
- if err != nil {
- return "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
- }
- return signed, expiresAt, nil
- }
- // VerifyAccessToken parses and validates an access JWT. Returns the
- // claims on success. Used by other services that want to authorize
- // a request without going through authd.
- //
- // The signing method is enforced to be HMAC (not 'none', not RS256
- // with a confused-deputy attack). The expiry is checked.
- func (a *Authd) VerifyAccessToken(raw string) (*AccessClaims, error) {
- claims := &AccessClaims{}
- tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
- // Reject anything that isn't HMAC. See
- // https://github.com/golang-jwt/jwt#security-considerations
- if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
- return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
- }
- return a.cfg.JWTSecret, nil
- })
- if err != nil {
- return nil, fmt.Errorf("verify jwt: %w", err)
- }
- if !tok.Valid {
- return nil, errors.New("verify jwt: token invalid")
- }
- if claims.TokenType != "access" {
- return nil, fmt.Errorf("verify jwt: wrong token type %q", claims.TokenType)
- }
- return claims, nil
- }
- // ---------------------------------------------------------------------------
- // Invites
- // ---------------------------------------------------------------------------
- // InviteUser creates a pending user in a tenant and issues a magic
- // link token for the invitation. The token is returned to the
- // caller (the HTTP handler) which emails it.
- func (a *Authd) InviteUser(ctx context.Context, tenantID, email, role, inviterUserID, ip, ua string) (magicLinkToken string, userID string, err error) {
- if role != "tenant_admin" && role != "viewer" {
- return "", "", fmt.Errorf("authd: invalid invite role %q (super_admin is not invitable)", role)
- }
- userID, err = a.store.CreateUser(ctx, tenantID, email, role)
- if err != nil {
- return "", "", err
- }
- token, _, err := a.IssueMagicLink(ctx, userID, "invite")
- if err != nil {
- return "", "", err
- }
- _ = a.store.WriteAudit(ctx, "auth.invite", inviterUserID, ip, ua, userID, tenantID,
- map[string]any{"email": email, "role": role})
- return token, userID, nil
- }
- // SetPassword updates the user's password (bcrypt hash). Used both
- // for first-time setup via magic link and for password resets.
- func (a *Authd) SetPassword(ctx context.Context, userID, plaintext string) error {
- hash, err := a.HashPassword(ctx, plaintext)
- if err != nil {
- return err
- }
- return a.store.SetUserPassword(ctx, userID, hash, "active")
- }
- // ---------------------------------------------------------------------------
- // helpers
- // ---------------------------------------------------------------------------
- // newJTI returns a 128-bit random ID encoded as hex. Used as the
- // `jti` claim on access tokens.
- func newJTI() string {
- var b [16]byte
- _, _ = rand.Read(b[:])
- return hex.EncodeToString(b[:])
- }
- // SecureEqual is a constant-time compare. Use it for any byte slice
- // equality that could be timing-attacked (e.g. token prefix checks
- // in tests, never in prod hot paths where Postgres handles equality).
- func SecureEqual(a, b []byte) bool {
- return subtle.ConstantTimeCompare(a, b) == 1
- }
|