| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581 |
- // Command authd is the in-house multi-tenant auth IdP that powers
- // the M13 admin UI. It exposes:
- //
- // POST /v1/auth/login — email + password → access JWT + refresh token
- // POST /v1/auth/refresh — refresh token → new pair
- // POST /v1/auth/logout — refresh token → revoke
- // POST /v1/auth/magic — magic-link token + new password → session
- // POST /v1/users/invite — super/tenant-admin → magic link (email side-effect lives in the caller)
- // GET /v1/users/me — current user info
- // GET /health, /metrics — observability
- //
- // All endpoints are unauthenticated except /v1/users/me and
- // /v1/users/invite (which require a valid access JWT). The auth
- // path is /v1/auth/* (no JWT required to log in, naturally).
- //
- // Configuration: env vars only. See authdEnv() below.
- package main
- import (
- "context"
- cryptorand "crypto/rand"
- "encoding/json"
- "errors"
- "log/slog"
- "net/http"
- "os"
- "os/signal"
- "strconv"
- "syscall"
- "time"
- "git3.techno-world.net/lrosales/broad-announce/internal/authd"
- "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
- "git3.techno-world.net/lrosales/broad-announce/internal/observability"
- "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
- )
- func main() {
- logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
- if err := run(logger); err != nil {
- logger.Error("authd exited with error", "err", err)
- os.Exit(1)
- }
- }
- func run(logger *slog.Logger) error {
- cfg, err := authdEnv()
- if err != nil {
- return err
- }
- logger.Info("authd starting",
- "env", cfg.Env, "addr", cfg.HTTPAddr,
- "issuer", cfg.Issuer, "access_ttl", cfg.AuthdConfig.AccessTokenTTL)
- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
- defer stop()
- pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
- if err != nil {
- return err
- }
- defer pool.Close()
- // Verify the migration is applied (graceful if not — first boot
- // may need to run `go run ./cmd/seed` first).
- if err := pingSchema(ctx, pool); err != nil {
- logger.Warn("auth schema not yet present, login will fail", "err", err)
- }
- // Generate a JWT secret if not provided. The first-run case
- // (no env) gets a random secret written to a file so restarts
- // produce the same tokens. This is dev-only behavior; in
- // production the secret comes from K8s sealed-secrets.
- if len(cfg.JWTSecret) < 32 {
- if !cfg.AllowGeneratedSecret {
- return errors.New("BA_AUTHD_JWT_SECRET must be at least 32 bytes (set in env or sealed-secret)")
- }
- sec, err := loadOrCreateSecret(cfg.SecretFile, logger)
- if err != nil {
- return err
- }
- cfg.JWTSecret = sec
- }
- ad, err := authd.New(pool, cfg.AuthdConfig)
- if err != nil {
- return err
- }
- reg, _ := observability.NewRegistry("authd")
- srv := httpserver.New(httpserver.Config{
- Addr: cfg.HTTPAddr,
- ServiceName: "authd",
- }, logger, observability.MetricsHandler(reg))
- mux := srv.Mux()
- mux.HandleFunc("POST /v1/auth/login", loginHandler(ad, logger))
- mux.HandleFunc("POST /v1/auth/refresh", refreshHandler(ad, logger))
- mux.HandleFunc("POST /v1/auth/logout", logoutHandler(ad, logger))
- mux.HandleFunc("POST /v1/auth/magic", magicConsumeHandler(ad, logger))
- mux.Handle("POST /v1/users/invite", ad.RequireRole("super_admin", "tenant_admin")(inviteHandler(ad, logger)))
- mux.Handle("GET /v1/users/me", ad.RequireAuth(meHandler(ad, logger)))
- // Start in background, wait for signal, then graceful shutdown.
- errCh := make(chan error, 1)
- go func() { errCh <- srv.Start() }()
- select {
- case err := <-errCh:
- return err
- case <-ctx.Done():
- logger.Info("shutdown signal received")
- shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownGrace)
- defer cancel()
- return srv.Shutdown(shutdownCtx)
- }
- }
- // ---------------------------------------------------------------------------
- // Env loading
- // ---------------------------------------------------------------------------
- type env struct {
- Env string
- HTTPAddr string
- Issuer string
- AuthdConfig authd.Config
- PostgresDSN string
- ShutdownGrace time.Duration
- // JWT secret
- JWTSecret []byte
- AllowGeneratedSecret bool
- SecretFile string
- }
- func authdEnv() (env, error) {
- e := env{
- Env: getenvDefault("BA_ENV", "dev"),
- HTTPAddr: getenvDefault("BA_AUTHD_HTTP_ADDR", ":8804"),
- Issuer: getenvDefault("BA_AUTHD_ISSUER", "broad-announce"),
- PostgresDSN: os.Getenv("BA_POSTGRES_DSN"),
- }
- if s := os.Getenv("BA_AUTHD_JWT_SECRET"); s != "" {
- e.JWTSecret = []byte(s)
- }
- e.AllowGeneratedSecret = getenvDefault("BA_AUTHD_ALLOW_GENERATED_SECRET", "") == "1" ||
- os.Getenv("BA_ENV") == "dev"
- e.SecretFile = getenvDefault("BA_AUTHD_SECRET_FILE", "/var/run/broad-announce/authd.jwt")
- if t := os.Getenv("BA_AUTHD_ACCESS_TTL"); t != "" {
- d, err := time.ParseDuration(t)
- if err != nil {
- return e, err
- }
- e.AuthdConfig.AccessTokenTTL = d
- }
- if t := os.Getenv("BA_AUTHD_REFRESH_TTL"); t != "" {
- d, err := time.ParseDuration(t)
- if err != nil {
- return e, err
- }
- e.AuthdConfig.RefreshTokenTTL = d
- }
- if c := os.Getenv("BA_AUTHD_BCRYPT_COST"); c != "" {
- n, err := strconv.Atoi(c)
- if err != nil {
- return e, err
- }
- e.AuthdConfig.BcryptCost = n
- }
- e.AuthdConfig.JWTSecret = e.JWTSecret // may be empty; New() will reject
- e.AuthdConfig.Issuer = e.Issuer
- if e.PostgresDSN == "" {
- return e, errors.New("BA_POSTGRES_DSN must be set")
- }
- grace := 10 * time.Second
- if g := os.Getenv("BA_AUTHD_SHUTDOWN_GRACE"); g != "" {
- d, err := time.ParseDuration(g)
- if err != nil {
- return e, err
- }
- grace = d
- }
- e.ShutdownGrace = grace
- return e, nil
- }
- func getenvDefault(k, def string) string {
- if v := os.Getenv(k); v != "" {
- return v
- }
- return def
- }
- // pingSchema is a best-effort check that the auth schema is migrated.
- // Returns nil if the audit_log table exists, an error otherwise.
- func pingSchema(ctx context.Context, pool *postgres.Pool) error {
- row := pool.QueryRow(ctx,
- `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='auth' AND table_name='users')`)
- var ok bool
- if err := row.Scan(&ok); err != nil {
- return err
- }
- if !ok {
- return errors.New("auth.users not found; run migrations first")
- }
- return nil
- }
- // loadOrCreateSecret reads the JWT secret from disk, or generates a
- // random one and writes it. The file is chmod 600.
- func loadOrCreateSecret(path string, logger *slog.Logger) ([]byte, error) {
- if data, err := os.ReadFile(path); err == nil && len(data) >= 32 {
- return data, nil
- }
- logger.Warn("generating new JWT secret (dev-only)", "file", path)
- if err := os.MkdirAll(parentDir(path), 0o700); err != nil {
- return nil, err
- }
- secret := make([]byte, 48)
- if _, err := readFull(secret); err != nil {
- return nil, err
- }
- if err := os.WriteFile(path, secret, 0o600); err != nil {
- return nil, err
- }
- return secret, nil
- }
- // readFull fills b with cryptographically random bytes.
- func readFull(b []byte) (int, error) {
- return randomRead(b)
- }
- func parentDir(p string) string {
- for i := len(p) - 1; i >= 0; i-- {
- if p[i] == '/' {
- return p[:i]
- }
- }
- return "."
- }
- // randomRead is split out so tests can stub it; default uses crypto/rand.
- var randomRead = func(b []byte) (int, error) {
- return cryptorand.Read(b)
- }
- // ---------------------------------------------------------------------------
- // HTTP handlers
- // ---------------------------------------------------------------------------
- // clientIP pulls the IP from r.RemoteAddr, respecting X-Forwarded-For
- // when BA_AUTHD_TRUST_FORWARDED=1. Returns the bare IP (no port)
- // because the audit_log columns are INET.
- func clientIP(r *http.Request) string {
- raw := r.RemoteAddr
- if os.Getenv("BA_AUTHD_TRUST_FORWARDED") == "1" {
- if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
- // X-Forwarded-For is a list; first is the client
- if i := indexByte(xff, ','); i >= 0 {
- raw = xff[:i]
- } else {
- raw = xff
- }
- }
- }
- // Strip :port if present (host:port)
- if i := lastByte(raw, ':'); i >= 0 {
- // Make sure it's not part of an IPv6 address
- if !containsByte(raw, ']') || i > indexByte(raw, ']') {
- raw = raw[:i]
- }
- }
- return raw
- }
- func indexByte(s string, c byte) int {
- for i := 0; i < len(s); i++ {
- if s[i] == c {
- return i
- }
- }
- return -1
- }
- func lastByte(s string, c byte) int {
- for i := len(s) - 1; i >= 0; i-- {
- if s[i] == c {
- return i
- }
- }
- return -1
- }
- func containsByte(s string, c byte) bool {
- return indexByte(s, c) >= 0
- }
- // writeJSON writes status + JSON body. Sets Content-Type.
- func writeJSON(w http.ResponseWriter, status int, body any) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(body)
- }
- // writeErr writes a JSON error response.
- func writeErr(w http.ResponseWriter, status int, code, msg string) {
- writeJSON(w, status, map[string]string{"error": code, "message": msg})
- }
- // --- /v1/auth/login -------------------------------------------------------
- type loginReq struct {
- Email string `json:"email"`
- Password string `json:"password"`
- }
- type loginResp struct {
- AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token"`
- TokenType string `json:"token_type"`
- ExpiresAt time.Time `json:"expires_at"`
- UserID string `json:"user_id"`
- TenantID string `json:"tenant_id,omitempty"`
- Role string `json:"role"`
- }
- func loginHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req loginReq
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
- return
- }
- if req.Email == "" || req.Password == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "email and password required")
- return
- }
- res, err := ad.Login(r.Context(), req.Email, req.Password, clientIP(r), r.UserAgent())
- if err != nil {
- if errors.Is(err, authd.ErrInvalidCredentials) || errors.Is(err, authd.ErrUserDisabled) {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "invalid credentials")
- return
- }
- logger.Error("login internal error", "err", err)
- writeErr(w, http.StatusInternalServerError, "internal", "internal error")
- return
- }
- writeJSON(w, http.StatusOK, loginResp{
- AccessToken: res.AccessToken,
- RefreshToken: res.RefreshToken,
- TokenType: "Bearer",
- ExpiresAt: res.ExpiresAt,
- UserID: res.UserID,
- TenantID: res.TenantID,
- Role: res.Role,
- })
- }
- }
- // --- /v1/auth/refresh -----------------------------------------------------
- type refreshReq struct {
- RefreshToken string `json:"refresh_token"`
- }
- func refreshHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req refreshReq
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
- return
- }
- if req.RefreshToken == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "refresh_token required")
- return
- }
- res, err := ad.Refresh(r.Context(), req.RefreshToken, clientIP(r), r.UserAgent())
- if err != nil {
- if errors.Is(err, authd.ErrTokenReuse) {
- writeErr(w, http.StatusUnauthorized, "session_killed", "refresh token re-use detected, please log in again")
- return
- }
- if errors.Is(err, authd.ErrUserDisabled) {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "user disabled")
- return
- }
- logger.Error("refresh internal error", "err", err)
- writeErr(w, http.StatusInternalServerError, "internal", "internal error")
- return
- }
- writeJSON(w, http.StatusOK, loginResp{
- AccessToken: res.AccessToken,
- RefreshToken: res.RefreshToken,
- TokenType: "Bearer",
- ExpiresAt: res.ExpiresAt,
- UserID: res.UserID,
- TenantID: res.TenantID,
- Role: res.Role,
- })
- }
- }
- // --- /v1/auth/logout ------------------------------------------------------
- type logoutReq struct {
- RefreshToken string `json:"refresh_token"`
- }
- func logoutHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req logoutReq
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
- return
- }
- // Resolve user id from the access JWT in the Authorization
- // header (best effort — logout is allowed without a valid
- // token so a user with a dead access can still kill their
- // refresh). If no claims, audit logs "user_id=''".
- uid := ""
- if c := authd.ClaimsFromContext(r.Context()); c != nil {
- uid = c.UserID
- }
- if err := ad.Logout(r.Context(), req.RefreshToken, uid, clientIP(r), r.UserAgent()); err != nil {
- logger.Error("logout internal error", "err", err)
- writeErr(w, http.StatusInternalServerError, "internal", "internal error")
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
- }
- }
- // --- /v1/auth/magic -------------------------------------------------------
- type magicReq struct {
- Token string `json:"token"`
- NewPassword string `json:"new_password"`
- }
- func magicConsumeHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req magicReq
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
- return
- }
- if req.Token == "" || req.NewPassword == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "token and new_password required")
- return
- }
- if len(req.NewPassword) < 12 {
- writeErr(w, http.StatusBadRequest, "weak_password", "password must be at least 12 characters")
- return
- }
- uid, err := ad.ConsumeMagicLink(r.Context(), req.Token, clientIP(r), r.UserAgent())
- if err != nil {
- if errors.Is(err, authd.ErrMagicLinkInvalid) {
- writeErr(w, http.StatusUnauthorized, "invalid_link", "magic link invalid or expired")
- return
- }
- logger.Error("magic consume error", "err", err)
- writeErr(w, http.StatusInternalServerError, "internal", "internal error")
- return
- }
- if err := ad.SetPassword(r.Context(), uid, req.NewPassword); err != nil {
- logger.Error("set password error", "err", err)
- writeErr(w, http.StatusInternalServerError, "internal", "internal error")
- return
- }
- // Auto-login: return a session so the user lands on the
- // dashboard without a separate login round-trip.
- user, err := ad.Store().GetUserByID(r.Context(), uid)
- if err != nil {
- writeErr(w, http.StatusInternalServerError, "internal", "internal error")
- return
- }
- // We don't have the plaintext password to call Login(). The
- // simpler path: sign a session directly using a helper.
- // For now, return success and require the user to log in
- // normally — UX is one extra click, security is cleaner.
- _ = user
- writeJSON(w, http.StatusOK, map[string]any{
- "status": "ok",
- "user_id": uid,
- "message": "password set. Please log in.",
- })
- }
- }
- // --- /v1/users/invite -----------------------------------------------------
- type inviteReq struct {
- TenantSlug string `json:"tenant_slug"`
- Email string `json:"email"`
- Role string `json:"role"`
- }
- type inviteResp struct {
- UserID string `json:"user_id"`
- MagicLinkToken string `json:"magic_link_token"`
- ExpiresAt time.Time `json:"expires_at"`
- }
- func inviteHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- claims := authd.ClaimsFromContext(r.Context())
- if claims == nil {
- // Should never happen — RequireAuth/RequireRole ensured
- // this. Belt-and-suspenders.
- writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
- return
- }
- var req inviteReq
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
- return
- }
- if req.Email == "" || req.Role == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "email and role required")
- return
- }
- // Resolve tenant: super_admin can target any tenant by slug;
- // tenant_admin can only target their own tenant.
- var (
- tenantID string
- err error
- )
- if claims.Role == "super_admin" {
- if req.TenantSlug == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant_slug required for super_admin")
- return
- }
- tenantID, err = ad.Store().TenantIDBySlug(r.Context(), req.TenantSlug)
- if err != nil {
- writeErr(w, http.StatusBadRequest, "unknown_tenant", "tenant_slug not found")
- return
- }
- } else {
- tenantID = claims.TenantID
- }
- token, uid, err := ad.InviteUser(r.Context(), tenantID, req.Email, req.Role, claims.UserID, clientIP(r), r.UserAgent())
- if err != nil {
- logger.Error("invite error", "err", err)
- writeErr(w, http.StatusInternalServerError, "internal", "internal error")
- return
- }
- writeJSON(w, http.StatusOK, inviteResp{
- UserID: uid,
- MagicLinkToken: token,
- })
- }
- }
- // --- /v1/users/me ---------------------------------------------------------
- func meHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- claims := authd.ClaimsFromContext(r.Context())
- if claims == nil {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
- return
- }
- u, err := ad.Store().GetUserByID(r.Context(), claims.UserID)
- if err != nil {
- writeErr(w, http.StatusInternalServerError, "internal", "user lookup failed")
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "user_id": u.ID,
- "email": u.Email,
- "role": u.Role,
- "status": u.Status,
- "tenant_id": u.TenantID,
- "display_name": u.DisplayName,
- })
- }
- }
|