| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- package handlers
- import (
- "bytes"
- "context"
- "encoding/hex"
- "encoding/xml"
- "fmt"
- "log/slog"
- "net/http"
- "strings"
- "github.com/mk6i/open-oscar-server/server/webapi/middleware"
- "github.com/mk6i/open-oscar-server/state"
- "github.com/mk6i/open-oscar-server/wire"
- )
- // OSCARBridgeHandler handles Web API to OSCAR protocol bridging endpoints.
- // This handler is responsible for creating a bridge between web-based clients
- // and the native OSCAR protocol, allowing web clients to connect to OSCAR services.
- type OSCARBridgeHandler struct {
- SessionManager *state.WebAPISessionManager
- OSCARAuthService OSCARAuthService
- CookieBaker CookieBaker
- Config OSCARConfig
- Logger *slog.Logger
- }
- // OSCARAuthService defines methods needed for OSCAR authentication and session management.
- type OSCARAuthService interface {
- // RegisterBOSSession creates a new BOS (Basic OSCAR Service) session
- RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error)
- // RetrieveBOSSession retrieves an existing BOS session
- RetrieveBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
- // Signout ends an OSCAR session
- Signout(ctx context.Context, session *state.Session)
- }
- // CookieBaker issues and validates authentication cookies for OSCAR services.
- type CookieBaker interface {
- // Issue creates a new authentication cookie from the given payload
- Issue(data []byte) ([]byte, error)
- // Crack verifies and decodes an authentication cookie
- Crack(data []byte) ([]byte, error)
- }
- // OSCARConfig provides configuration for OSCAR services.
- type OSCARConfig interface {
- // GetBOSAddress returns the BOS server address for client connections
- GetBOSAddress() (host string, port int)
- // GetSSLBOSAddress returns the SSL-enabled BOS server address
- GetSSLBOSAddress() (host string, port int)
- // IsSSLAvailable checks if SSL is configured for BOS connections
- IsSSLAvailable() bool
- // IsAuthDisabled returns whether authentication is disabled
- IsAuthDisabled() bool
- }
- // StartOSCARSessionRequest represents the request parameters for startOSCARSession.
- type StartOSCARSessionRequest struct {
- AimSID string // WebAPI session ID
- UseSSL bool // Whether to use SSL for the OSCAR connection
- Compress bool // Whether to use compression (not implemented)
- }
- // StartOSCARSessionResponse represents the response for startOSCARSession endpoint.
- type StartOSCARSessionResponse struct {
- XMLName xml.Name `xml:"response" json:"-"`
- Response struct {
- StatusCode int `json:"statusCode" xml:"statusCode"`
- StatusText string `json:"statusText" xml:"statusText"`
- Data struct {
- Host string `json:"host" xml:"host"`
- Port int `json:"port" xml:"port"`
- Cookie string `json:"cookie" xml:"cookie"`
- UseSSL bool `json:"useSSL" xml:"useSSL"`
- Encryption string `json:"encryption,omitempty" xml:"encryption,omitempty"`
- Compression string `json:"compression,omitempty" xml:"compression,omitempty"`
- } `json:"data" xml:"data"`
- } `json:"response" xml:"-"`
- // For XML responses, flatten the structure
- StatusCode int `json:"-" xml:"statusCode"`
- StatusText string `json:"-" xml:"statusText"`
- Data struct {
- Host string `json:"-" xml:"host"`
- Port int `json:"-" xml:"port"`
- Cookie string `json:"-" xml:"cookie"`
- UseSSL bool `json:"-" xml:"useSSL"`
- Encryption string `json:"-" xml:"encryption,omitempty"`
- Compression string `json:"-" xml:"compression,omitempty"`
- } `json:"-" xml:"data"`
- }
- // StartOSCARSession handles GET /aim/startOSCARSession requests.
- // This endpoint creates a bridge between a WebAPI session and the native OSCAR protocol,
- // returning connection details that allow a web client to establish a direct OSCAR connection.
- //
- // The endpoint performs the following operations:
- // 1. Validates the WebAPI session
- // 2. Creates an OSCAR authentication cookie
- // 3. Optionally pre-registers a BOS session
- // 4. Returns connection details (host, port, cookie)
- //
- // Parameters:
- // - aimsid: The WebAPI session ID (required)
- // - useSSL: Whether to use SSL connection (optional, default: false)
- // - compress: Whether to use compression (optional, not implemented)
- // - f: Response format - "json" or "xml" (optional, default: "json")
- //
- // Returns:
- // - 200 OK: Successfully created OSCAR session bridge
- // - 400 Bad Request: Missing or invalid parameters
- // - 401 Unauthorized: Invalid or expired WebAPI session
- // - 500 Internal Server Error: Failed to create OSCAR session
- func (h *OSCARBridgeHandler) StartOSCARSession(w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- // Log the request
- h.Logger.InfoContext(ctx, "startOSCARSession requested",
- "method", r.Method,
- "remote_addr", r.RemoteAddr,
- "user_agent", r.UserAgent())
- // Get API key info from context (set by auth middleware)
- apiKey, ok := ctx.Value(middleware.ContextKeyAPIKey).(*state.WebAPIKey)
- if !ok {
- h.Logger.Error("API key not found in context")
- h.sendError(w, r, http.StatusInternalServerError, "internal server error")
- return
- }
- // Verify that this API key has permission to create OSCAR sessions
- if !h.hasOSCARBridgeCapability(apiKey) {
- h.Logger.Warn("API key lacks OSCAR bridge capability",
- "dev_id", apiKey.DevID)
- h.sendError(w, r, http.StatusForbidden, "OSCAR bridge not enabled for this application")
- return
- }
- // Parse request parameters
- params := r.URL.Query()
- aimsid := params.Get("aimsid")
- if aimsid == "" {
- h.Logger.Warn("missing aimsid parameter")
- h.sendError(w, r, http.StatusBadRequest, "missing aimsid parameter")
- return
- }
- // Validate WebAPI session
- session, err := h.SessionManager.GetSession(r.Context(), aimsid)
- if err != nil {
- switch err {
- case state.ErrNoWebAPISession:
- h.Logger.Warn("session not found", "aimsid", aimsid)
- h.sendError(w, r, http.StatusNotFound, "session not found")
- case state.ErrWebAPISessionExpired:
- h.Logger.Warn("session expired", "aimsid", aimsid)
- h.sendError(w, r, http.StatusGone, "session expired")
- default:
- h.Logger.Error("failed to get session", "error", err)
- h.sendError(w, r, http.StatusInternalServerError, "internal server error")
- }
- return
- }
- // Touch the session to update last access time
- _ = h.SessionManager.TouchSession(r.Context(), aimsid)
- // Check if session already has an OSCAR bridge
- if session.OSCARSession != nil {
- h.Logger.Info("session already has OSCAR bridge",
- "aimsid", aimsid,
- "screen_name", session.ScreenName)
- // Return existing connection details
- h.returnExistingBridge(w, r, session)
- return
- }
- // Parse optional parameters
- useSSL := h.parseBoolParam(params.Get("useSSL"))
- compress := h.parseBoolParam(params.Get("compress"))
- // Check SSL availability if requested
- if useSSL && !h.Config.IsSSLAvailable() {
- h.Logger.Warn("SSL requested but not available")
- h.sendError(w, r, http.StatusBadRequest, "SSL not available")
- return
- }
- // Create OSCAR authentication cookie
- cookie, err := h.createOSCARCookie(session)
- if err != nil {
- h.Logger.Error("failed to create OSCAR cookie",
- "error", err,
- "screen_name", session.ScreenName)
- h.sendError(w, r, http.StatusInternalServerError, "failed to create authentication cookie")
- return
- }
- // Get BOS server address
- var host string
- var port int
- if useSSL {
- host, port = h.Config.GetSSLBOSAddress()
- } else {
- host, port = h.Config.GetBOSAddress()
- }
- // Record the bridge details on the session so a repeat startOSCARSession
- // can return the same connection details via returnExistingBridge.
- session.OSCARCookie = cookie
- session.BOSHost = host
- session.BOSPort = port
- session.UseSSL = useSSL
- // Prepare response
- resp := h.buildResponse(host, port, cookie, useSSL, compress)
- // Send response in requested format
- h.sendResponse(w, r, resp)
- h.Logger.InfoContext(ctx, "OSCAR session bridge created",
- "aimsid", aimsid,
- "screen_name", session.ScreenName,
- "bos_host", host,
- "bos_port", port,
- "use_ssl", useSSL,
- "compress", compress)
- }
- // createOSCARCookie generates an OSCAR authentication cookie for the session.
- func (h *OSCARBridgeHandler) createOSCARCookie(session *state.WebAPISession) ([]byte, error) {
- // Create server cookie with session details
- serverCookie := state.ServerCookie{
- Service: wire.BOS, // Basic OSCAR Service
- ScreenName: session.ScreenName,
- ClientID: fmt.Sprintf("WebAPI-%s", session.ClientName),
- MultiConnFlag: 0, // Single connection
- }
- // Marshal the cookie to bytes
- buf := &bytes.Buffer{}
- if err := wire.MarshalBE(serverCookie, buf); err != nil {
- return nil, fmt.Errorf("failed to marshal server cookie: %w", err)
- }
- // Issue the cookie with HMAC signature
- cookie, err := h.CookieBaker.Issue(buf.Bytes())
- if err != nil {
- return nil, fmt.Errorf("failed to issue cookie: %w", err)
- }
- return cookie, nil
- }
- // hasOSCARBridgeCapability checks if the API key has permission to create OSCAR bridges.
- func (h *OSCARBridgeHandler) hasOSCARBridgeCapability(apiKey *state.WebAPIKey) bool {
- if len(apiKey.Capabilities) == 0 {
- return true // No restrictions if capabilities not specified
- }
- // Check if OSCAR bridge is explicitly enabled
- for _, cap := range apiKey.Capabilities {
- if cap == "oscar_bridge" || cap == "*" {
- return true
- }
- }
- return false
- }
- // parseBoolParam parses a boolean parameter from query string.
- func (h *OSCARBridgeHandler) parseBoolParam(value string) bool {
- value = strings.ToLower(value)
- return value == "true" || value == "1" || value == "yes"
- }
- // returnExistingBridge returns details for an existing OSCAR bridge.
- func (h *OSCARBridgeHandler) returnExistingBridge(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
- // Reuse the bridge details recorded on the session by StartOSCARSession.
- if len(session.OSCARCookie) > 0 {
- resp := h.buildResponse(session.BOSHost, session.BOSPort, session.OSCARCookie, session.UseSSL, false)
- h.sendResponse(w, r, resp)
- return
- }
- // If we can't retrieve the bridge, return an error
- h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve existing bridge")
- }
- // buildResponse constructs the response object.
- func (h *OSCARBridgeHandler) buildResponse(host string, port int, cookie []byte, useSSL, compress bool) *StartOSCARSessionResponse {
- resp := &StartOSCARSessionResponse{}
- resp.Response.StatusCode = 200
- resp.Response.StatusText = "OK"
- resp.Response.Data.Host = host
- resp.Response.Data.Port = port
- resp.Response.Data.Cookie = hex.EncodeToString(cookie) // Hex encode the cookie
- resp.Response.Data.UseSSL = useSSL
- // Add encryption info if SSL is used
- if useSSL {
- resp.Response.Data.Encryption = "TLS"
- }
- // Add compression info if requested (not implemented)
- if compress {
- resp.Response.Data.Compression = "none" // Compression not implemented
- }
- // Duplicate data for XML format
- resp.StatusCode = resp.Response.StatusCode
- resp.StatusText = resp.Response.StatusText
- resp.Data.Host = resp.Response.Data.Host
- resp.Data.Port = resp.Response.Data.Port
- resp.Data.Cookie = resp.Response.Data.Cookie
- resp.Data.UseSSL = resp.Response.Data.UseSSL
- resp.Data.Encryption = resp.Response.Data.Encryption
- resp.Data.Compression = resp.Response.Data.Compression
- return resp
- }
- // sendResponse sends the response in the requested format.
- func (h *OSCARBridgeHandler) sendResponse(w http.ResponseWriter, r *http.Request, resp *StartOSCARSessionResponse) {
- // Use the centralized SendResponse function which handles all formats
- SendResponse(w, r, resp, h.Logger)
- }
- // sendError sends an error response in the appropriate format.
- func (h *OSCARBridgeHandler) sendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
- // SendError already detects format from Content-Type header
- SendError(w, statusCode, message)
- }
|