package httpserver import ( "encoding/json" "html/template" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/tim/cairnquire/apps/server/internal/auth" ) type passwordRegisterRequest struct { Email string `json:"email"` DisplayName string `json:"displayName"` Password string `json:"password"` Role string `json:"role"` } type passwordLoginRequest struct { Email string `json:"email"` Password string `json:"password"` } type initialSetupRequest struct { Email string `json:"email"` DisplayName string `json:"displayName"` Password string `json:"password"` SignupsEnabled bool `json:"signupsEnabled"` } type passkeyBeginRequest struct { Email string `json:"email"` DisplayName string `json:"displayName"` Role string `json:"role"` } type passkeyFinishRequest struct { ChallengeID string `json:"challengeId"` } type createTokenRequest struct { Name string `json:"name"` Scopes []auth.Scope `json:"scopes"` ExpiresAt *time.Time `json:"expiresAt"` } type deviceStartRequest struct { Name string `json:"name"` Scopes []auth.Scope `json:"scopes"` } type changePasswordRequest struct { CurrentPassword string `json:"currentPassword"` NewPassword string `json:"newPassword"` } type passwordResetRequest struct { Token string `json:"token"` NewPassword string `json:"newPassword"` } type deleteAccountRequest struct { CurrentPassword string `json:"currentPassword"` } type updateUserRoleRequest struct { Role string `json:"role"` } func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { if _, ok := requirePrincipal(r); ok { http.Redirect(w, r, "/account", http.StatusSeeOther) return } nextPath := r.URL.Query().Get("next") if !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") { nextPath = "/account" } settings, err := s.auth.GetInstanceSettings(r.Context()) if err != nil { s.renderError(w, r, http.StatusInternalServerError, "load registration settings") return } s.renderTemplate(w, r, http.StatusOK, "login.gohtml", layoutData{ Title: "Sign in", BodyClass: "page-auth", BodyTemplate: "login_content", Data: struct { Next string SignupsEnabled bool DevMode bool }{Next: nextPath, SignupsEnabled: settings.SignupsEnabled, DevMode: s.config.DevMode}, }) } func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) { settings, err := s.auth.GetInstanceSettings(r.Context()) if err != nil { s.renderError(w, r, http.StatusInternalServerError, "load initial setup state") return } if settings.SetupComplete { http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.renderTemplate(w, r, http.StatusOK, "setup.gohtml", layoutData{ Title: "Initial setup", BodyClass: "page-auth", BodyTemplate: "setup_content", }) } func (s *Server) handlePasswordResetPage(w http.ResponseWriter, r *http.Request) { s.renderTemplate(w, r, http.StatusOK, "password_reset.gohtml", layoutData{ Title: "Reset password", BodyClass: "page-auth", BodyTemplate: "password_reset_content", Data: struct { Token string }{Token: strings.TrimSpace(r.URL.Query().Get("token"))}, }) } func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return } var req initialSetupRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if _, err := s.auth.CompleteInitialSetup(r.Context(), req.Email, req.DisplayName, req.Password, req.SignupsEnabled); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent()) if err != nil { writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "initial admin created; sign in to continue"}) return } setSessionCookie(w, r, token, principal.ExpiresAt) writeJSONWithStatus(w, http.StatusCreated, map[string]any{"principal": principal}) } func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) { principal, ok := requirePrincipal(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.renderTemplate(w, r, http.StatusOK, "account.gohtml", layoutData{ Title: "Account", BodyClass: "page-account", BodyTemplate: "account_content", Data: principal, }) } func (s *Server) handleTokenCreatePage(w http.ResponseWriter, r *http.Request) { principal, ok := requirePrincipal(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.renderTemplate(w, r, http.StatusOK, "token_create.gohtml", layoutData{ Title: "Create access token", BodyClass: "page-account", BodyTemplate: "token_create_content", Data: principal, }) } func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { principal, ok := requirePrincipal(r) if !ok { writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) return } writeJSON(w, http.StatusOK, map[string]any{"principal": principal}) } func (s *Server) handlePasswordRegister(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return } var req passwordRegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } user, err := s.auth.RegisterPasswordUser(r.Context(), req.Email, req.DisplayName, req.Password, req.Role) if err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSONWithStatus(w, http.StatusCreated, map[string]any{"user": publicUser(user)}) } func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return } var req passwordLoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent()) if err != nil { writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return } setSessionCookie(w, r, token, principal.ExpiresAt) writeJSON(w, http.StatusOK, map[string]any{"principal": principal}) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if principal.SessionID != "" { _ = s.auth.RevokeSession(r.Context(), principal.SessionID) } clearSessionCookie(w) if s.config.DevMode { setDevLogoutCookie(w, r) } writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"}) } func (s *Server) handleDevLogin(w http.ResponseWriter, r *http.Request) { if !s.config.DevMode || s.devUserID == "" { writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "dev login is not available"}) return } principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID) if err != nil { writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } clearDevLogoutCookie(w) writeJSON(w, http.StatusOK, map[string]any{"principal": principal}) } func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if !requireSessionPrincipal(w, principal) { return } var req changePasswordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if err := s.auth.ChangePassword(r.Context(), principal, req.CurrentPassword, req.NewPassword); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"}) } func (s *Server) handlePasswordReset(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return } var req passwordResetRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if err := s.auth.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset"}) } func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if !requireSessionPrincipal(w, principal) { return } var req deleteAccountRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if err := s.auth.DeleteAccount(r.Context(), principal, req.CurrentPassword); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } clearSessionCookie(w) writeJSON(w, http.StatusOK, map[string]string{"status": "account_deleted"}) } func (s *Server) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return } var req passkeyBeginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } options, challengeID, err := s.auth.BeginPasskeyRegistration(r.Context(), req.Email, req.DisplayName, req.Role) if err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options}) } func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return } challengeID := r.URL.Query().Get("challengeId") if challengeID == "" { var req passkeyFinishRequest _ = json.NewDecoder(r.Body).Decode(&req) challengeID = req.ChallengeID } if challengeID == "" { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"}) return } user, err := s.auth.FinishPasskeyRegistration(r.Context(), challengeID, r) if err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)}) } func (s *Server) handleCurrentUserPasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if !requireSessionPrincipal(w, principal) { return } options, challengeID, err := s.auth.BeginCurrentUserPasskeyRegistration(r.Context(), principal) if err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options}) } func (s *Server) handleCurrentUserPasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if !requireSessionPrincipal(w, principal) { return } challengeID := r.URL.Query().Get("challengeId") if challengeID == "" { var req passkeyFinishRequest _ = json.NewDecoder(r.Body).Decode(&req) challengeID = req.ChallengeID } if challengeID == "" { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"}) return } user, err := s.auth.FinishCurrentUserPasskeyRegistration(r.Context(), principal, challengeID, r) if err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)}) } func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return } var req passkeyBeginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } options, challengeID, err := s.auth.BeginPasskeyLogin(r.Context(), req.Email) if err != nil { writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return } writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options}) } func (s *Server) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return } challengeID := r.URL.Query().Get("challengeId") if challengeID == "" { var req passkeyFinishRequest _ = json.NewDecoder(r.Body).Decode(&req) challengeID = req.ChallengeID } if challengeID == "" { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"}) return } principal, token, err := s.auth.FinishPasskeyLogin(r.Context(), challengeID, r) if err != nil { writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) return } setSessionCookie(w, r, token, principal.ExpiresAt) writeJSON(w, http.StatusOK, map[string]any{"principal": principal}) } func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if !requireSessionPrincipal(w, principal) { return } keys, err := s.auth.ListAPIKeys(r.Context(), principal.UserID) if err != nil { writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"tokens": keys}) } func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if !requireSessionPrincipal(w, principal) { return } var req createTokenRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } created, err := s.auth.CreateAPIKey(r.Context(), principal.UserID, req.Name, req.Scopes, req.ExpiresAt) if err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSONWithStatus(w, http.StatusCreated, created) } func (s *Server) handleRevokeAPIToken(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if !requireSessionPrincipal(w, principal) { return } keyID := chi.URLParam(r, "id") if !strings.HasPrefix(keyID, "api:") { keyID = "api:" + keyID } if err := s.auth.RevokeAPIKey(r.Context(), principal.UserID, keyID); err != nil { writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "token not found"}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) } func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return } var req deviceStartRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } start, err := s.auth.StartDeviceFlow(r.Context(), req.Name, req.Scopes) if err != nil { writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, start) } func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if !requireSessionPrincipal(w, principal) { return } var req struct { UserCode string `json:"userCode"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } device, err := s.auth.ApproveDeviceFlow(r.Context(), principal, req.UserCode) if err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired code"}) return } writeJSON(w, http.StatusOK, map[string]any{"status": "approved", "device": map[string]any{"name": device.Name, "scopes": device.Scopes}}) } func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return } var req struct { DeviceCode string `json:"deviceCode"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } created, code, err := s.auth.PollDeviceFlow(r.Context(), req.DeviceCode) if err != nil { status := http.StatusBadRequest if code == "authorization_pending" { status = http.StatusAccepted } writeJSONWithStatus(w, status, map[string]string{"error": code}) return } writeJSON(w, http.StatusOK, created) } func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) { userCode := template.HTMLEscapeString(r.URL.Query().Get("user_code")) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(`

Approve Device

Sign in, then approve this code with POST /api/device/approve.

User code: ` + userCode + `

`)) } func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) { userCode := strings.TrimSpace(r.URL.Query().Get("user_code")) s.renderTemplate(w, r, http.StatusOK, "device_verify.gohtml", layoutData{ Title: "Approve device", BodyClass: "page-auth", BodyTemplate: "device_verify_content", Data: struct { UserCode string }{UserCode: userCode}, }) } func (s *Server) handleAdminUpdateUserRole(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) userID := chi.URLParam(r, "id") var req updateUserRoleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } user, err := s.auth.UpdateUserRole(r.Context(), principal, userID, req.Role) if err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)}) } func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) { users, err := s.auth.ListUsers(r.Context()) if err != nil { writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } public := make([]map[string]any, 0, len(users)) for _, user := range users { public = append(public, publicUser(user)) } writeJSON(w, http.StatusOK, map[string]any{"users": public}) } func (s *Server) handleAdminUpdateAuthUsers(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) var req struct { Users []auth.UserAccessUpdate `json:"users"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } users, err := s.auth.UpdateUserAccess(r.Context(), principal, req.Users) if err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } public := make([]map[string]any, 0, len(users)) for _, user := range users { public = append(public, publicUser(user)) } writeJSON(w, http.StatusOK, map[string]any{"users": public}) } func (s *Server) handleAdminSendPasswordReset(w http.ResponseWriter, r *http.Request) { principal, _ := requirePrincipal(r) if err := s.auth.SendPasswordReset(r.Context(), principal, chi.URLParam(r, "id")); err != nil { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset_sent"}) } func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool { if s.authLimiter.Allow(authRateLimitKey(r)) { return true } writeJSONWithStatus(w, http.StatusTooManyRequests, map[string]string{"error": "rate limited"}) return false } func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) { clearDevLogoutCookie(w) http.SetCookie(w, &http.Cookie{ Name: auth.SessionCookieName, Value: token, Path: "/", Expires: expires, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: isSecureRequest(r), }) } func setDevLogoutCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: devLogoutCookieName, Value: "1", Path: "/", MaxAge: int((24 * time.Hour).Seconds()), HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: isSecureRequest(r), }) } func clearDevLogoutCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: devLogoutCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) } func clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: auth.SessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) } func publicUser(user auth.User) map[string]any { return map[string]any{ "id": user.ID, "email": user.Email, "displayName": user.DisplayName, "role": user.Role, "disabled": user.Disabled, "createdAt": user.CreatedAt, "lastSeenAt": user.LastSeenAt, } } func requireSessionPrincipal(w http.ResponseWriter, principal auth.Principal) bool { if principal.Method == "session" { return true } writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "browser session required"}) return false } func requestIP(r *http.Request) string { if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { return strings.TrimSpace(strings.Split(forwarded, ",")[0]) } return r.RemoteAddr } func isSecureRequest(r *http.Request) bool { return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") }