|
|
@@ -0,0 +1,610 @@
|
|
|
+// 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.HandleFunc("POST /v1/users/invite", inviteHandler(ad, logger))
|
|
|
+ mux.HandleFunc("GET /v1/users/me", 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) so we can write the audit row.
|
|
|
+ uid, _ := userIDFromAuthHeader(ad, r)
|
|
|
+ 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, err := userClaimsFromAuthHeader(ad, r)
|
|
|
+ if err != nil {
|
|
|
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "valid access token required")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if claims.Role != "super_admin" && claims.Role != "tenant_admin" {
|
|
|
+ writeErr(w, http.StatusForbidden, "forbidden", "invite requires admin role")
|
|
|
+ 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
|
|
|
+ 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, err := userClaimsFromAuthHeader(ad, r)
|
|
|
+ if err != nil {
|
|
|
+ writeErr(w, http.StatusUnauthorized, "unauthorized", "valid access token required")
|
|
|
+ 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,
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// --- helpers for bearer auth ----------------------------------------------
|
|
|
+
|
|
|
+// userIDFromAuthHeader extracts the user id from the access token in
|
|
|
+// the Authorization header. Returns ("", err) if absent or invalid.
|
|
|
+func userIDFromAuthHeader(ad *authd.Authd, r *http.Request) (string, error) {
|
|
|
+ tok, err := bearerFromAuthHeader(r)
|
|
|
+ if err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+ claims, err := ad.VerifyAccessToken(tok)
|
|
|
+ if err != nil {
|
|
|
+ return "", err
|
|
|
+ }
|
|
|
+ return claims.UserID, nil
|
|
|
+}
|
|
|
+
|
|
|
+func userClaimsFromAuthHeader(ad *authd.Authd, r *http.Request) (*authd.AccessClaims, error) {
|
|
|
+ tok, err := bearerFromAuthHeader(r)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ return ad.VerifyAccessToken(tok)
|
|
|
+}
|
|
|
+
|
|
|
+func bearerFromAuthHeader(r *http.Request) (string, error) {
|
|
|
+ h := r.Header.Get("Authorization")
|
|
|
+ if h == "" {
|
|
|
+ return "", errors.New("missing Authorization header")
|
|
|
+ }
|
|
|
+ const prefix = "Bearer "
|
|
|
+ if len(h) <= len(prefix) || h[:len(prefix)] != prefix {
|
|
|
+ return "", errors.New("Authorization must be 'Bearer <token>'")
|
|
|
+ }
|
|
|
+ return h[len(prefix):], nil
|
|
|
+}
|