// 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))) // M13b W1: Tenant (company) CRUD. See cmd/authd/tenants.go. // GET /v1/tenants — any auth (scope: super_admin sees all, others see own) // POST /v1/tenants — super_admin only // GET /v1/tenants/{id} — any auth (per-id scope check in handler) // PATCH /v1/tenants/{id} — any auth (per-id scope + field scope in handler) // POST /v1/tenants/{id}/status — super_admin only mux.Handle("GET /v1/tenants", ad.RequireAuth(listTenantsHandler(ad, logger))) mux.Handle("POST /v1/tenants", ad.RequireRole("super_admin")(createTenantHandler(ad, logger))) mux.Handle("GET /v1/tenants/{id}", ad.RequireAuth(getTenantHandler(ad, logger))) mux.Handle("PATCH /v1/tenants/{id}", ad.RequireAuth(updateTenantHandler(ad, logger))) mux.Handle("POST /v1/tenants/{id}/status", ad.RequireRole("super_admin")(setTenantStatusHandler(ad, logger))) // M13b W2: Source CRUD. See cmd/authd/sources.go. // GET /v1/tenants/{id}/sources — any auth, per-tenant scope // POST /v1/tenants/{id}/sources — any auth, per-tenant scope // GET /v1/tenants/{id}/sources/{sid} — any auth, per-tenant scope // PATCH /v1/tenants/{id}/sources/{sid} — any auth, per-tenant scope // POST /v1/tenants/{id}/sources/{sid}/status — any auth, per-tenant scope // POST /v1/tenants/{id}/sources/{sid}/rotate-secrets — any auth, per-tenant scope mux.Handle("GET /v1/tenants/{id}/sources", ad.RequireAuth(listSourcesHandler(ad, logger))) mux.Handle("POST /v1/tenants/{id}/sources", ad.RequireAuth(createSourceHandler(ad, logger))) mux.Handle("GET /v1/tenants/{id}/sources/{sid}", ad.RequireAuth(getSourceHandler(ad, logger))) mux.Handle("PATCH /v1/tenants/{id}/sources/{sid}", ad.RequireAuth(updateSourceHandler(ad, logger))) mux.Handle("POST /v1/tenants/{id}/sources/{sid}/status", ad.RequireAuth(setSourceStatusHandler(ad, logger))) mux.Handle("POST /v1/tenants/{id}/sources/{sid}/rotate-secrets", ad.RequireAuth(rotateSourceSecretsHandler(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, }) } }