// 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 }