Analytics: time-series message graph with 5 time spans (1h/4h/24h/7d/30d), top-5 agents and channels leaderboards, summary cards. 4 new REST endpoints. PWA: web app manifest, service worker with cache-first static/network-only API strategy, push notifications via Web Push API with VAPID keys, push subscription management endpoints, SQLite migration for subscriptions. UX fixes: auto-resize compose textarea (3-12 lines), inline editable agent display name, editable human display name in settings, smart mention/channel highlighting (existing→link, deleted→inactive badge, unknown→plain text), font size -/+ preference (12-24px persisted in localStorage), version footer with GitHub link. MCP: 4 prompts — daily-digest, agent-health-check, channel-overview, debug-agent. Registered with prompt capabilities enabled. Code review fixes: scoped push unsubscribe to user, capped analytics limit at 100, hardened HTML strip regex, bounded SW cache, backend push unsub on disable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1074 lines
32 KiB
Go
1074 lines
32 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ory/fosite"
|
|
)
|
|
|
|
// AgentLister provides a way to list agents by owner for the authorize page.
|
|
// This avoids a direct dependency on the agents package.
|
|
type AgentLister interface {
|
|
ListAgentsByOwner(ctx context.Context, ownerID int64) ([]AgentInfo, error)
|
|
}
|
|
|
|
// AgentInfo is a minimal agent representation used by the authorize page.
|
|
type AgentInfo struct {
|
|
Name string
|
|
DisplayName string
|
|
Type string
|
|
}
|
|
|
|
// Handlers holds the HTTP handlers for the auth subsystem.
|
|
type Handlers struct {
|
|
userStore UserStore
|
|
sessionStore SessionStore
|
|
clientStore ClientStore
|
|
provider fosite.OAuth2Provider
|
|
config Config
|
|
agentLister AgentLister
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewHandlers creates a new set of auth HTTP handlers.
|
|
func NewHandlers(
|
|
userStore UserStore,
|
|
sessionStore SessionStore,
|
|
clientStore ClientStore,
|
|
provider fosite.OAuth2Provider,
|
|
config Config,
|
|
) *Handlers {
|
|
return &Handlers{
|
|
userStore: userStore,
|
|
sessionStore: sessionStore,
|
|
clientStore: clientStore,
|
|
provider: provider,
|
|
config: config,
|
|
logger: slog.Default().With("component", "auth"),
|
|
}
|
|
}
|
|
|
|
// SetAgentLister sets the agent lister for the OAuth authorize page.
|
|
func (h *Handlers) SetAgentLister(lister AgentLister) {
|
|
h.agentLister = lister
|
|
}
|
|
|
|
// HandleRegister handles POST /auth/register.
|
|
func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body")
|
|
return
|
|
}
|
|
|
|
user, err := h.userStore.CreateUser(r.Context(), req.Username, req.Password, req.DisplayName)
|
|
if err != nil {
|
|
switch err {
|
|
case ErrDuplicateUsername:
|
|
writeError(w, http.StatusConflict, "duplicate_username", "Username already exists")
|
|
case ErrInvalidUsername:
|
|
writeError(w, http.StatusBadRequest, "invalid_username", err.Error())
|
|
case ErrPasswordTooShort:
|
|
writeError(w, http.StatusBadRequest, "invalid_password", err.Error())
|
|
case ErrPasswordTooLong:
|
|
writeError(w, http.StatusBadRequest, "invalid_password", err.Error())
|
|
default:
|
|
h.logger.Error("create user failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "server_error", "Failed to create user")
|
|
}
|
|
return
|
|
}
|
|
|
|
LogAuthEvent(r.Context(), h.logger, AuthEvent{
|
|
Type: EventUserCreated,
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
RemoteIP: remoteIP(r),
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(user)
|
|
}
|
|
|
|
// HandleLogin handles POST /auth/login.
|
|
func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body")
|
|
return
|
|
}
|
|
|
|
user, err := h.userStore.VerifyPassword(r.Context(), req.Username, req.Password)
|
|
if err != nil {
|
|
LogAuthEvent(r.Context(), h.logger, AuthEvent{
|
|
Type: EventLoginFailure,
|
|
Username: req.Username,
|
|
RemoteIP: remoteIP(r),
|
|
})
|
|
writeError(w, http.StatusUnauthorized, "invalid_credentials", "Invalid username or password")
|
|
return
|
|
}
|
|
|
|
session, err := h.sessionStore.CreateSession(r.Context(), user.ID, h.config.SessionLifetime)
|
|
if err != nil {
|
|
h.logger.Error("create session failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "server_error", "Failed to create session")
|
|
return
|
|
}
|
|
|
|
// Set session cookie
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: SessionCookieName,
|
|
Value: session.SessionID,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: !h.config.DevMode,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: int(h.config.SessionLifetime.Seconds()),
|
|
})
|
|
|
|
LogAuthEvent(r.Context(), h.logger, AuthEvent{
|
|
Type: EventLoginSuccess,
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
RemoteIP: remoteIP(r),
|
|
})
|
|
|
|
LogAuthEvent(r.Context(), h.logger, AuthEvent{
|
|
Type: EventSessionCreated,
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
RemoteIP: remoteIP(r),
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(user)
|
|
}
|
|
|
|
// HandleLogout handles POST /auth/logout.
|
|
func (h *Handlers) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST required")
|
|
return
|
|
}
|
|
|
|
cookie, err := r.Cookie(SessionCookieName)
|
|
if err == nil && cookie.Value != "" {
|
|
h.sessionStore.DeleteSession(r.Context(), cookie.Value)
|
|
|
|
user, ok := UserFromContext(r.Context())
|
|
if ok {
|
|
LogAuthEvent(r.Context(), h.logger, AuthEvent{
|
|
Type: EventSessionDestroyed,
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
RemoteIP: remoteIP(r),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Clear cookie
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: SessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: !h.config.DevMode,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"logged_out"}`))
|
|
}
|
|
|
|
// HandleMe handles GET /auth/me.
|
|
func (h *Handlers) HandleMe(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET required")
|
|
return
|
|
}
|
|
|
|
user, ok := UserFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(user)
|
|
}
|
|
|
|
// HandleUpdateProfile handles PUT /api/auth/profile.
|
|
func (h *Handlers) HandleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "PUT required")
|
|
return
|
|
}
|
|
|
|
user, ok := UserFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body")
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(req.DisplayName) == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "Display name cannot be empty")
|
|
return
|
|
}
|
|
|
|
if err := h.userStore.UpdateDisplayName(r.Context(), user.ID, req.DisplayName); err != nil {
|
|
h.logger.Error("update display name failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "server_error", "Failed to update display name")
|
|
return
|
|
}
|
|
|
|
// Fetch updated user
|
|
updated, err := h.userStore.GetUserByID(r.Context(), user.ID)
|
|
if err != nil {
|
|
h.logger.Error("fetch updated user failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "server_error", "Profile updated but failed to fetch result")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"message": "Profile updated",
|
|
"user": map[string]any{
|
|
"id": updated.ID,
|
|
"username": updated.Username,
|
|
"display_name": updated.DisplayName,
|
|
"role": updated.Role,
|
|
},
|
|
})
|
|
}
|
|
|
|
// HandleChangePassword handles PUT /auth/password.
|
|
func (h *Handlers) HandleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "PUT required")
|
|
return
|
|
}
|
|
|
|
user, ok := UserFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
CurrentPassword string `json:"current_password"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body")
|
|
return
|
|
}
|
|
|
|
// Verify current password
|
|
if _, err := h.userStore.VerifyPassword(r.Context(), user.Username, req.CurrentPassword); err != nil {
|
|
writeError(w, http.StatusForbidden, "invalid_password", "Current password is incorrect")
|
|
return
|
|
}
|
|
|
|
// Update password
|
|
if err := h.userStore.UpdatePassword(r.Context(), user.ID, req.NewPassword); err != nil {
|
|
switch err {
|
|
case ErrPasswordTooShort:
|
|
writeError(w, http.StatusBadRequest, "invalid_password", err.Error())
|
|
case ErrPasswordTooLong:
|
|
writeError(w, http.StatusBadRequest, "invalid_password", err.Error())
|
|
default:
|
|
h.logger.Error("update password failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "server_error", "Failed to update password")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Invalidate all sessions except current
|
|
if sessionID, ok := SessionIDFromContext(r.Context()); ok {
|
|
h.sessionStore.DeleteSessionsByUserExcept(r.Context(), user.ID, sessionID)
|
|
}
|
|
|
|
LogAuthEvent(r.Context(), h.logger, AuthEvent{
|
|
Type: EventPasswordChanged,
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
RemoteIP: remoteIP(r),
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"password_changed"}`))
|
|
}
|
|
|
|
// HandleOAuthMetadata handles GET /.well-known/oauth-authorization-server.
|
|
// Returns OAuth 2.1 server metadata as per RFC 8414.
|
|
func (h *Handlers) HandleOAuthMetadata(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET required")
|
|
return
|
|
}
|
|
|
|
baseURL := h.config.IssuerURL
|
|
if baseURL == "" || baseURL == "auto" {
|
|
// Derive from request headers (supports reverse proxies and tunnels)
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
// Cloudflare, nginx, and other proxies set these headers
|
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
|
scheme = proto
|
|
}
|
|
host := r.Host
|
|
if fwdHost := r.Header.Get("X-Forwarded-Host"); fwdHost != "" {
|
|
host = fwdHost
|
|
}
|
|
baseURL = fmt.Sprintf("%s://%s", scheme, host)
|
|
}
|
|
|
|
metadata := map[string]any{
|
|
"issuer": baseURL,
|
|
"authorization_endpoint": baseURL + "/oauth/authorize",
|
|
"token_endpoint": baseURL + "/oauth/token",
|
|
"registration_endpoint": baseURL + "/oauth/register",
|
|
"token_endpoint_auth_methods_supported": []string{"none"},
|
|
"response_types_supported": []string{"code"},
|
|
"grant_types_supported": []string{"authorization_code", "refresh_token"},
|
|
"code_challenge_methods_supported": []string{"S256"},
|
|
"scopes_supported": []string{"mcp"},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(metadata)
|
|
}
|
|
|
|
// HandleDynamicRegistration handles POST /oauth/register (RFC 7591).
|
|
// MCP clients use this to register themselves as public OAuth clients.
|
|
func (h *Handlers) HandleDynamicRegistration(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
ClientName string `json:"client_name"`
|
|
RedirectURIs []string `json:"redirect_uris"`
|
|
GrantTypes []string `json:"grant_types"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_client_metadata", "Invalid JSON body")
|
|
return
|
|
}
|
|
|
|
if req.ClientName == "" {
|
|
req.ClientName = "dynamic-client"
|
|
}
|
|
|
|
// Default redirect URIs for MCP clients
|
|
if len(req.RedirectURIs) == 0 {
|
|
req.RedirectURIs = []string{"http://127.0.0.1"}
|
|
}
|
|
|
|
// Normalize localhost to 127.0.0.1 for fosite's loopback matching (RFC 8252 Section 7.3).
|
|
// Fosite allows dynamic ports for loopback IPs but not for "localhost" hostname.
|
|
normalizedURIs := make([]string, 0, len(req.RedirectURIs))
|
|
for _, uri := range req.RedirectURIs {
|
|
parsed, err := url.Parse(uri)
|
|
if err == nil && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1") {
|
|
// Store without port — fosite ignores port for loopback addresses
|
|
normalized := fmt.Sprintf("http://127.0.0.1%s", parsed.Path)
|
|
normalizedURIs = append(normalizedURIs, normalized)
|
|
} else {
|
|
normalizedURIs = append(normalizedURIs, uri)
|
|
}
|
|
}
|
|
req.RedirectURIs = normalizedURIs
|
|
|
|
// Only allow authorization_code + refresh_token for public clients
|
|
grantTypes := []string{"authorization_code", "refresh_token"}
|
|
scopes := []string{"mcp"}
|
|
|
|
client, err := h.clientStore.CreatePublicClient(r.Context(), req.ClientName, req.RedirectURIs, grantTypes, scopes)
|
|
if err != nil {
|
|
h.logger.Error("dynamic client registration failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "server_error", "Failed to register client")
|
|
return
|
|
}
|
|
|
|
h.logger.Info("dynamic client registered",
|
|
"client_id", client.ID,
|
|
"client_name", client.Name,
|
|
"redirect_uris", client.RedirectURIs,
|
|
)
|
|
|
|
// RFC 7591 response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"client_id": client.ID,
|
|
"client_name": client.Name,
|
|
"redirect_uris": client.RedirectURIs,
|
|
"grant_types": client.GrantTypes,
|
|
"scope": "mcp",
|
|
"token_endpoint_auth_method": "none",
|
|
})
|
|
}
|
|
|
|
// HandleAuthorizeGet handles GET /oauth/authorize.
|
|
// Renders the login form or agent selector depending on session state.
|
|
func (h *Handlers) HandleAuthorizeGet(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET required")
|
|
return
|
|
}
|
|
|
|
// Collect OAuth params from query string
|
|
oauthParams := authorizeParams{
|
|
ResponseType: r.URL.Query().Get("response_type"),
|
|
ClientID: r.URL.Query().Get("client_id"),
|
|
RedirectURI: r.URL.Query().Get("redirect_uri"),
|
|
State: r.URL.Query().Get("state"),
|
|
Scope: r.URL.Query().Get("scope"),
|
|
CodeChallenge: r.URL.Query().Get("code_challenge"),
|
|
CodeChallengeMethod: r.URL.Query().Get("code_challenge_method"),
|
|
}
|
|
|
|
// Check if user has a valid session
|
|
user, loggedIn := h.trySessionAuth(r)
|
|
|
|
data := authorizePageData{
|
|
Params: oauthParams,
|
|
LoggedIn: loggedIn,
|
|
}
|
|
|
|
if loggedIn {
|
|
data.Username = user.Username
|
|
// Fetch user's agents
|
|
agentsList, err := h.listUserAgents(r.Context(), user.ID)
|
|
if err != nil {
|
|
h.logger.Error("list user agents failed", "error", err)
|
|
}
|
|
data.Agents = agentsList
|
|
if len(agentsList) == 0 {
|
|
data.NoAgents = true
|
|
}
|
|
}
|
|
|
|
h.renderAuthorizePage(w, data)
|
|
}
|
|
|
|
// HandleAuthorizePost handles POST /oauth/authorize.
|
|
// Processes login or authorization approval.
|
|
func (h *Handlers) HandleAuthorizePost(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST required")
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid form data")
|
|
return
|
|
}
|
|
|
|
// Collect OAuth params from form (passed as hidden fields)
|
|
oauthParams := authorizeParams{
|
|
ResponseType: r.FormValue("response_type"),
|
|
ClientID: r.FormValue("client_id"),
|
|
RedirectURI: r.FormValue("redirect_uri"),
|
|
State: r.FormValue("state"),
|
|
Scope: r.FormValue("scope"),
|
|
CodeChallenge: r.FormValue("code_challenge"),
|
|
CodeChallengeMethod: r.FormValue("code_challenge_method"),
|
|
}
|
|
|
|
action := r.FormValue("action")
|
|
|
|
switch action {
|
|
case "login":
|
|
h.handleAuthorizeLogin(w, r, oauthParams)
|
|
case "authorize":
|
|
h.handleAuthorizeApprove(w, r, oauthParams)
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "Unknown action")
|
|
}
|
|
}
|
|
|
|
// handleAuthorizeLogin processes the login form submission within the authorize flow.
|
|
func (h *Handlers) handleAuthorizeLogin(w http.ResponseWriter, r *http.Request, params authorizeParams) {
|
|
username := r.FormValue("username")
|
|
password := r.FormValue("password")
|
|
|
|
user, err := h.userStore.VerifyPassword(r.Context(), username, password)
|
|
if err != nil {
|
|
data := authorizePageData{
|
|
Params: params,
|
|
LoggedIn: false,
|
|
LoginError: "Invalid username or password",
|
|
}
|
|
h.renderAuthorizePage(w, data)
|
|
return
|
|
}
|
|
|
|
// Create session
|
|
session, err := h.sessionStore.CreateSession(r.Context(), user.ID, h.config.SessionLifetime)
|
|
if err != nil {
|
|
h.logger.Error("create session failed during authorize", "error", err)
|
|
data := authorizePageData{
|
|
Params: params,
|
|
LoggedIn: false,
|
|
LoginError: "Server error, please try again",
|
|
}
|
|
h.renderAuthorizePage(w, data)
|
|
return
|
|
}
|
|
|
|
// Set session cookie
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: SessionCookieName,
|
|
Value: session.SessionID,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: !h.config.DevMode,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: int(h.config.SessionLifetime.Seconds()),
|
|
})
|
|
|
|
LogAuthEvent(r.Context(), h.logger, AuthEvent{
|
|
Type: EventLoginSuccess,
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
RemoteIP: remoteIP(r),
|
|
})
|
|
|
|
// Now show the agent selector
|
|
agentsList, err := h.listUserAgents(r.Context(), user.ID)
|
|
if err != nil {
|
|
h.logger.Error("list user agents failed", "error", err)
|
|
}
|
|
|
|
data := authorizePageData{
|
|
Params: params,
|
|
LoggedIn: true,
|
|
Username: user.Username,
|
|
Agents: agentsList,
|
|
NoAgents: len(agentsList) == 0,
|
|
}
|
|
|
|
h.renderAuthorizePage(w, data)
|
|
}
|
|
|
|
// handleAuthorizeApprove processes the "Authorize" button click.
|
|
func (h *Handlers) handleAuthorizeApprove(w http.ResponseWriter, r *http.Request, params authorizeParams) {
|
|
// Verify session
|
|
user, ok := h.trySessionAuth(r)
|
|
if !ok {
|
|
data := authorizePageData{
|
|
Params: params,
|
|
LoggedIn: false,
|
|
LoginError: "Session expired, please log in again",
|
|
}
|
|
h.renderAuthorizePage(w, data)
|
|
return
|
|
}
|
|
|
|
agentName := r.FormValue("agent_name")
|
|
if agentName == "" {
|
|
agentsList, _ := h.listUserAgents(r.Context(), user.ID)
|
|
data := authorizePageData{
|
|
Params: params,
|
|
LoggedIn: true,
|
|
Username: user.Username,
|
|
Agents: agentsList,
|
|
LoginError: "Please select an agent",
|
|
}
|
|
h.renderAuthorizePage(w, data)
|
|
return
|
|
}
|
|
|
|
// Normalize localhost to 127.0.0.1 for fosite's loopback matching (RFC 8252)
|
|
redirectURI := params.RedirectURI
|
|
if parsed, err := url.Parse(redirectURI); err == nil && parsed.Hostname() == "localhost" {
|
|
parsed.Host = "127.0.0.1:" + parsed.Port()
|
|
if parsed.Port() == "" {
|
|
parsed.Host = "127.0.0.1"
|
|
}
|
|
redirectURI = parsed.String()
|
|
}
|
|
|
|
// Build a synthetic GET request with the OAuth params for fosite
|
|
q := url.Values{
|
|
"response_type": {params.ResponseType},
|
|
"client_id": {params.ClientID},
|
|
"redirect_uri": {redirectURI},
|
|
"state": {params.State},
|
|
"scope": {params.Scope},
|
|
"code_challenge": {params.CodeChallenge},
|
|
"code_challenge_method": {params.CodeChallengeMethod},
|
|
}
|
|
syntheticReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "/oauth/authorize?"+q.Encode(), nil)
|
|
|
|
ar, err := h.provider.NewAuthorizeRequest(r.Context(), syntheticReq)
|
|
if err != nil {
|
|
h.logger.Debug("authorize request failed", "error", err)
|
|
h.provider.WriteAuthorizeError(r.Context(), w, ar, err)
|
|
return
|
|
}
|
|
|
|
// Grant requested scopes
|
|
for _, scope := range ar.GetRequestedScopes() {
|
|
ar.GrantScope(scope)
|
|
}
|
|
|
|
// Create fosite session with agent_name
|
|
sess := NewSessionWithAgent(user, agentName)
|
|
response, err := h.provider.NewAuthorizeResponse(r.Context(), ar, sess)
|
|
if err != nil {
|
|
h.logger.Debug("authorize response failed", "error", err)
|
|
h.provider.WriteAuthorizeError(r.Context(), w, ar, err)
|
|
return
|
|
}
|
|
|
|
h.provider.WriteAuthorizeResponse(r.Context(), w, ar, response)
|
|
}
|
|
|
|
// trySessionAuth checks if the request has a valid session cookie.
|
|
func (h *Handlers) trySessionAuth(r *http.Request) (*User, bool) {
|
|
cookie, err := r.Cookie(SessionCookieName)
|
|
if err != nil || cookie.Value == "" {
|
|
return nil, false
|
|
}
|
|
|
|
session, err := h.sessionStore.GetSession(r.Context(), cookie.Value)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
user, err := h.userStore.GetUserByID(r.Context(), session.UserID)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
return user, true
|
|
}
|
|
|
|
// listUserAgents returns a list of agents owned by the user.
|
|
func (h *Handlers) listUserAgents(ctx context.Context, userID int64) ([]AgentInfo, error) {
|
|
if h.agentLister == nil {
|
|
return nil, nil
|
|
}
|
|
return h.agentLister.ListAgentsByOwner(ctx, userID)
|
|
}
|
|
|
|
// authorizeParams holds the OAuth query/form parameters.
|
|
type authorizeParams struct {
|
|
ResponseType string
|
|
ClientID string
|
|
RedirectURI string
|
|
State string
|
|
Scope string
|
|
CodeChallenge string
|
|
CodeChallengeMethod string
|
|
}
|
|
|
|
// authorizePageData holds template data for the authorize page.
|
|
type authorizePageData struct {
|
|
Params authorizeParams
|
|
LoggedIn bool
|
|
Username string
|
|
Agents []AgentInfo
|
|
NoAgents bool
|
|
LoginError string
|
|
}
|
|
|
|
// renderAuthorizePage renders the authorize HTML template.
|
|
func (h *Handlers) renderAuthorizePage(w http.ResponseWriter, data authorizePageData) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := authorizeTemplate.Execute(w, data); err != nil {
|
|
h.logger.Error("render authorize template failed", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// authorizeTemplate is the HTML template for the OAuth authorize page.
|
|
var authorizeTemplate = template.Must(template.New("authorize").Parse(authorizeTemplateHTML))
|
|
|
|
const authorizeTemplateHTML = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>SynapBus — Authorize</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@500;700&family=DM+Sans:wght@400;500&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
background: #1a1d21;
|
|
color: #e8e8e8;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background-image:
|
|
radial-gradient(ellipse at 20% 50%, rgba(124,58,237,0.08) 0%, transparent 50%),
|
|
radial-gradient(ellipse at 80% 50%, rgba(54,197,240,0.06) 0%, transparent 50%);
|
|
}
|
|
.card {
|
|
background: #222529;
|
|
border: 1px solid #383b40;
|
|
border-radius: 16px;
|
|
padding: 2.5rem 2rem 2rem;
|
|
max-width: 420px;
|
|
width: 100%;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.03) inset;
|
|
}
|
|
.logo {
|
|
text-align: center;
|
|
margin-bottom: 2rem;
|
|
}
|
|
.logo svg {
|
|
width: 48px;
|
|
height: 48px;
|
|
margin-bottom: 0.75rem;
|
|
filter: drop-shadow(0 0 8px rgba(54,197,240,0.3));
|
|
}
|
|
.logo h1 {
|
|
font-family: 'Instrument Sans', sans-serif;
|
|
font-size: 1.6rem;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, #36c5f0, #7c3aed);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
.logo p {
|
|
font-size: 0.82rem;
|
|
color: #9b9da0;
|
|
margin-top: 0.3rem;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.error {
|
|
background: rgba(224,30,90,0.12);
|
|
color: #f87171;
|
|
padding: 0.7rem 0.85rem;
|
|
border-radius: 8px;
|
|
margin-bottom: 1.25rem;
|
|
font-size: 0.85rem;
|
|
border: 1px solid rgba(224,30,90,0.2);
|
|
}
|
|
.info {
|
|
background: rgba(54,197,240,0.08);
|
|
color: #7dd3fc;
|
|
padding: 0.7rem 0.85rem;
|
|
border-radius: 8px;
|
|
margin-bottom: 1.25rem;
|
|
font-size: 0.85rem;
|
|
border: 1px solid rgba(54,197,240,0.15);
|
|
}
|
|
label {
|
|
display: block;
|
|
margin-bottom: 0.35rem;
|
|
font-size: 0.82rem;
|
|
font-weight: 500;
|
|
color: #9b9da0;
|
|
letter-spacing: 0.01em;
|
|
}
|
|
input, select {
|
|
width: 100%;
|
|
padding: 0.65rem 0.85rem;
|
|
background: #1a1d21;
|
|
border: 1px solid #383b40;
|
|
border-radius: 8px;
|
|
color: #e8e8e8;
|
|
font-family: 'DM Sans', sans-serif;
|
|
font-size: 0.92rem;
|
|
margin-bottom: 1.1rem;
|
|
transition: border-color 0.15s;
|
|
}
|
|
input:focus, select:focus {
|
|
outline: none;
|
|
border-color: #36c5f0;
|
|
box-shadow: 0 0 0 2px rgba(54,197,240,0.15);
|
|
}
|
|
select option { background: #1a1d21; }
|
|
button {
|
|
width: 100%;
|
|
padding: 0.72rem;
|
|
background: linear-gradient(135deg, #2eb67d, #1a9e6a);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-family: 'DM Sans', sans-serif;
|
|
font-size: 0.95rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s, transform 0.1s;
|
|
letter-spacing: 0.01em;
|
|
}
|
|
button:hover { opacity: 0.9; }
|
|
button:active { transform: scale(0.99); }
|
|
.meta {
|
|
font-size: 0.72rem;
|
|
color: #545760;
|
|
margin-top: 1.25rem;
|
|
text-align: center;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
word-break: break-all;
|
|
}
|
|
.user-info {
|
|
font-size: 0.88rem;
|
|
color: #9b9da0;
|
|
margin-bottom: 1.25rem;
|
|
padding: 0.6rem 0.85rem;
|
|
background: rgba(124,58,237,0.08);
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(124,58,237,0.15);
|
|
}
|
|
.user-info strong { color: #e8e8e8; }
|
|
.divider {
|
|
height: 1px;
|
|
background: #383b40;
|
|
margin: 1.5rem 0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div class="logo">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
<defs><linearGradient id="bg" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
|
<stop stop-color="#7c3aed"/><stop offset="1" stop-color="#06b6d4"/>
|
|
</linearGradient></defs>
|
|
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
|
|
<g transform="translate(4,4)">
|
|
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd"/>
|
|
<circle cx="6" cy="10" r="1.5" fill="#a78bfa"/>
|
|
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd"/>
|
|
<circle cx="12" cy="13" r="2" fill="#67e8f9"/>
|
|
<circle cx="5" cy="17" r="1.5" fill="#a78bfa"/>
|
|
<circle cx="19" cy="17" r="1.3" fill="#a78bfa"/>
|
|
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd"/>
|
|
<line x1="12" y1="5" x2="6" y2="10" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
|
<line x1="12" y1="5" x2="18" y2="9" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
|
<line x1="6" y1="10" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
|
<line x1="18" y1="9" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
|
<line x1="12" y1="13" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
|
<line x1="12" y1="13" x2="19" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
|
<line x1="5" y1="17" x2="11" y2="20" stroke="white" stroke-width="0.5" opacity="0.3"/>
|
|
<line x1="6" y1="10" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.3"/>
|
|
</g>
|
|
</svg>
|
|
<h1>SynapBus</h1>
|
|
<p>Agent Authorization</p>
|
|
</div>
|
|
|
|
{{if .LoginError}}<div class="error">{{.LoginError}}</div>{{end}}
|
|
|
|
{{if not .LoggedIn}}
|
|
<form method="POST" action="/oauth/authorize">
|
|
<input type="hidden" name="action" value="login">
|
|
<input type="hidden" name="response_type" value="{{.Params.ResponseType}}">
|
|
<input type="hidden" name="client_id" value="{{.Params.ClientID}}">
|
|
<input type="hidden" name="redirect_uri" value="{{.Params.RedirectURI}}">
|
|
<input type="hidden" name="state" value="{{.Params.State}}">
|
|
<input type="hidden" name="scope" value="{{.Params.Scope}}">
|
|
<input type="hidden" name="code_challenge" value="{{.Params.CodeChallenge}}">
|
|
<input type="hidden" name="code_challenge_method" value="{{.Params.CodeChallengeMethod}}">
|
|
|
|
<label for="username">Username</label>
|
|
<input type="text" id="username" name="username" required autofocus placeholder="Enter your username">
|
|
|
|
<label for="password">Password</label>
|
|
<input type="password" id="password" name="password" required placeholder="Enter your password">
|
|
|
|
<button type="submit">Log In</button>
|
|
</form>
|
|
{{else}}
|
|
<div class="user-info">Logged in as <strong>{{.Username}}</strong></div>
|
|
|
|
{{if .NoAgents}}
|
|
<div class="info">No agents registered yet. Create an agent in the SynapBus Web UI first, then return here to authorize.</div>
|
|
{{else}}
|
|
<form method="POST" action="/oauth/authorize">
|
|
<input type="hidden" name="action" value="authorize">
|
|
<input type="hidden" name="response_type" value="{{.Params.ResponseType}}">
|
|
<input type="hidden" name="client_id" value="{{.Params.ClientID}}">
|
|
<input type="hidden" name="redirect_uri" value="{{.Params.RedirectURI}}">
|
|
<input type="hidden" name="state" value="{{.Params.State}}">
|
|
<input type="hidden" name="scope" value="{{.Params.Scope}}">
|
|
<input type="hidden" name="code_challenge" value="{{.Params.CodeChallenge}}">
|
|
<input type="hidden" name="code_challenge_method" value="{{.Params.CodeChallengeMethod}}">
|
|
|
|
<label for="agent_name">Select Agent</label>
|
|
<select id="agent_name" name="agent_name" required>
|
|
{{if eq (len .Agents) 1}}
|
|
{{range .Agents}}<option value="{{.Name}}" selected>{{.DisplayName}} ({{.Name}})</option>{{end}}
|
|
{{else}}
|
|
<option value="">— choose an agent —</option>
|
|
{{range .Agents}}
|
|
<option value="{{.Name}}">{{.DisplayName}} ({{.Name}})</option>
|
|
{{end}}
|
|
{{end}}
|
|
</select>
|
|
|
|
<button type="submit">Authorize</button>
|
|
</form>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
<div class="meta">{{.Params.ClientID}}</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|
|
|
|
// HandleToken handles POST /oauth/token.
|
|
func (h *Handlers) HandleToken(w http.ResponseWriter, r *http.Request) {
|
|
// Normalize localhost redirect_uri to 127.0.0.1 to match what was stored during authorization (RFC 8252)
|
|
if err := r.ParseForm(); err == nil {
|
|
if redirectURI := r.Form.Get("redirect_uri"); redirectURI != "" {
|
|
if parsed, err := url.Parse(redirectURI); err == nil && parsed.Hostname() == "localhost" {
|
|
parsed.Host = "127.0.0.1:" + parsed.Port()
|
|
if parsed.Port() == "" {
|
|
parsed.Host = "127.0.0.1"
|
|
}
|
|
r.Form.Set("redirect_uri", parsed.String())
|
|
r.PostForm.Set("redirect_uri", parsed.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx := r.Context()
|
|
sess := &fositeSession{}
|
|
|
|
ar, err := h.provider.NewAccessRequest(ctx, r, sess)
|
|
if err != nil {
|
|
h.logger.Debug("access request failed", "error", err)
|
|
h.provider.WriteAccessError(ctx, w, ar, err)
|
|
return
|
|
}
|
|
|
|
// Grant requested scopes
|
|
for _, scope := range ar.GetRequestedScopes() {
|
|
ar.GrantScope(scope)
|
|
}
|
|
|
|
response, err := h.provider.NewAccessResponse(ctx, ar)
|
|
if err != nil {
|
|
h.logger.Debug("access response failed", "error", err)
|
|
h.provider.WriteAccessError(ctx, w, ar, err)
|
|
return
|
|
}
|
|
|
|
grantType := r.FormValue("grant_type")
|
|
LogAuthEvent(ctx, h.logger, AuthEvent{
|
|
Type: EventTokenIssued,
|
|
ClientID: ar.GetClient().GetID(),
|
|
RemoteIP: remoteIP(r),
|
|
Details: map[string]any{
|
|
"grant_type": grantType,
|
|
},
|
|
})
|
|
|
|
if grantType == "refresh_token" {
|
|
LogAuthEvent(ctx, h.logger, AuthEvent{
|
|
Type: EventTokenRefreshed,
|
|
ClientID: ar.GetClient().GetID(),
|
|
RemoteIP: remoteIP(r),
|
|
})
|
|
}
|
|
|
|
h.provider.WriteAccessResponse(ctx, w, ar, response)
|
|
}
|
|
|
|
// HandleIntrospect handles POST /oauth/introspect.
|
|
func (h *Handlers) HandleIntrospect(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST required")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
sess := &fositeSession{}
|
|
|
|
ir, err := h.provider.NewIntrospectionRequest(ctx, r, sess)
|
|
if err != nil {
|
|
h.logger.Debug("introspection request failed", "error", err)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(IntrospectionResponse{Active: false})
|
|
return
|
|
}
|
|
|
|
resp := IntrospectionResponse{
|
|
Active: ir.IsActive(),
|
|
ClientID: ir.GetAccessRequester().GetClient().GetID(),
|
|
TokenType: "bearer",
|
|
}
|
|
|
|
if sess, ok := ir.GetAccessRequester().GetSession().(*fositeSession); ok {
|
|
resp.Username = sess.Username
|
|
resp.Subject = sess.Subject
|
|
if exp := sess.GetExpiresAt(fosite.AccessToken); !exp.IsZero() {
|
|
resp.ExpiresAt = exp.Unix()
|
|
}
|
|
}
|
|
|
|
resp.Scope = strings.Join(ir.GetAccessRequester().GetGrantedScopes(), " ")
|
|
resp.IssuedAt = time.Now().Unix() // approximate
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// writeError writes a JSON error response.
|
|
func writeError(w http.ResponseWriter, status int, errCode, description string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(ErrorResponse{
|
|
Error: errCode,
|
|
Description: description,
|
|
})
|
|
}
|