package api import ( "context" "encoding/json" "net/http" "strings" ) // Role names as stored in the PocketBase users.role select field. A missing or // empty value is treated as roleUser. const ( roleUser = "user" roleAdmin = "admin" roleSuperadmin = "superadmin" ) // publicPaths bypass authentication. Everything else under /api/ requires a // valid PocketBase token. var publicPaths = map[string]bool{ "/api/health": true, "/api/status": true, "/api/auth/login": true, "/api/auth/validate": true, "/healthz": true, } type ctxKey int const ctxCaller ctxKey = iota // callerIdentity is who the request token belongs to. It is resolved from // PocketBase on each request, so a role change or a deletion takes effect // immediately rather than lingering until a token expires. type callerIdentity struct { ID string Email string Name string Role string OrgID string // organization record id ("" when the user belongs to no org) } func (c *callerIdentity) isSuperadmin() bool { return c != nil && c.Role == roleSuperadmin } func (c *callerIdentity) isManager() bool { return c != nil && (c.Role == roleAdmin || c.Role == roleSuperadmin) } // caller returns the identity stashed on the request context by withAuth. func caller(r *http.Request) *callerIdentity { if v, ok := r.Context().Value(ctxCaller).(*callerIdentity); ok { return v } return nil } // currentUserID returns the authenticated user's id. Handlers on non-public // paths can treat "" as "not authenticated" — withAuth already rejected those. func (s *Server) currentUserID(r *http.Request) string { if who := caller(r); who != nil { return who.ID } return "" } // bearerToken extracts the caller's token, accepting both "Bearer " and // a raw token (PocketBase's own SDKs send the latter). func bearerToken(r *http.Request) string { h := r.Header.Get("Authorization") if h == "" { return "" } if after, ok := strings.CutPrefix(h, "Bearer "); ok { return strings.TrimSpace(after) } return strings.TrimSpace(h) } // identify resolves the caller's id/email/name/role/org from their PocketBase // token by asking PocketBase to refresh it. A non-200 status means the token is // invalid or expired. func (s *Server) identify(ctx context.Context, token string) (*callerIdentity, int, error) { raw, status, err := s.pb.AuthRefresh(ctx, s.usersCollection(), token) if err != nil { return nil, 0, err } if status != http.StatusOK { return nil, status, nil } var out struct { Record struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Role string `json:"role"` Organization string `json:"organization"` } `json:"record"` } if err := json.Unmarshal(raw, &out); err != nil { return nil, status, err } role := out.Record.Role if role == "" { role = roleUser } return &callerIdentity{ ID: out.Record.ID, Email: out.Record.Email, Name: out.Record.Name, Role: role, OrgID: out.Record.Organization, }, http.StatusOK, nil } // withAuth rejects requests to non-public /api/ paths that lack a valid // PocketBase token, and stashes the resolved identity on the request context. func (s *Server) withAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Public API paths, plus everything outside /api/ (the embedded web // panel and its static assets), bypass authentication. if publicPaths[r.URL.Path] || !strings.HasPrefix(r.URL.Path, "/api/") { next.ServeHTTP(w, r) return } who, ok := s.authenticate(w, r) if !ok { return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who))) }) } // authenticate resolves and validates the caller, writing the error response // itself and returning ok=false when the request should not proceed. func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*callerIdentity, bool) { token := bearerToken(r) if token == "" { writeError(w, http.StatusUnauthorized, "missing bearer token") return nil, false } who, status, err := s.identify(r.Context(), token) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return nil, false } if status != http.StatusOK || who == nil { writeError(w, http.StatusUnauthorized, "invalid or expired token") return nil, false } return who, true } // requireRole is the shared gate for privileged handlers: it needs the service // account (every privileged flow runs through it) and a caller satisfying ok. // withAuth has already established the identity. func (s *Server) requireRole(next http.HandlerFunc, ok func(*callerIdentity) bool, denied string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !s.pb.Configured() { writeError(w, http.StatusServiceUnavailable, "user management not configured on the server") return } if !ok(caller(r)) { writeError(w, http.StatusForbidden, denied) return } next(w, r) } } // requireManager wraps a handler so only managers (admin or superadmin) proceed. func (s *Server) requireManager(next http.HandlerFunc) http.HandlerFunc { return s.requireRole(next, func(c *callerIdentity) bool { return c.isManager() }, "admin role required") } // requireSuperadmin wraps a handler so only superadmins proceed. func (s *Server) requireSuperadmin(next http.HandlerFunc) http.HandlerFunc { return s.requireRole(next, func(c *callerIdentity) bool { return c.isSuperadmin() }, "superadmin role required") } // requireSuperadminAuth gates a handler on a superadmin caller WITHOUT requiring // the service account to already be configured. Used by the PocketBase settings // endpoints, whose whole purpose is to configure that service account. func (s *Server) requireSuperadminAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !caller(r).isSuperadmin() { writeError(w, http.StatusForbidden, "superadmin role required") return } next(w, r) } } // POST /api/auth/login // Body: {"email"|"identity":"...","password":"..."} // Proxies to the PocketBase users auth-with-password and relays its response — // the client gets PocketBase's own token and user record. PocketBase's address // lives only in this server and is never exposed to clients. func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { var body struct { Email string `json:"email"` Identity string `json:"identity"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } identity := body.Identity if identity == "" { identity = body.Email } if identity == "" || body.Password == "" { writeError(w, http.StatusBadRequest, "email and password are required") return } raw, status, err := s.pb.LoginWithPassword(r.Context(), s.usersCollection(), identity, body.Password) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } relay(w, status, raw) } // GET /api/auth/validate (Authorization: ) // Proxies to PocketBase auth-refresh to confirm a token is still valid. func (s *Server) handleAuthValidate(w http.ResponseWriter, r *http.Request) { token := bearerToken(r) if token == "" { writeJSON(w, http.StatusUnauthorized, map[string]any{"valid": false}) return } raw, status, err := s.pb.AuthRefresh(r.Context(), s.usersCollection(), token) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"valid": false, "detail": err.Error()}) return } relay(w, status, raw) } // GET /api/auth/me — the caller's identity, from their token. func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { who := caller(r) if who == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } writeJSON(w, http.StatusOK, userInfo{ID: who.ID, Email: who.Email, Name: who.Name, Role: who.Role}) } // GET /api/identity — like /api/auth/me, plus the caller's organization. The // panel uses this to decide which management cards to show. func (s *Server) handleIdentity(w http.ResponseWriter, r *http.Request) { who := caller(r) if who == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } orgName := "" if who.OrgID != "" { orgName = s.orgName(r.Context(), who.OrgID) } writeJSON(w, http.StatusOK, map[string]any{ "id": who.ID, "email": who.Email, "name": who.Name, "role": who.Role, "organization": who.OrgID, "organizationName": orgName, }) } // userInfo is the compact identity shape returned by /api/auth/me. type userInfo struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Role string `json:"role,omitempty"` } // relay copies an upstream PocketBase status + JSON body to the client. func relay(w http.ResponseWriter, status int, body []byte) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(body) }