Jelajahi Sumber

webapi: implement clientLogin auth

Mike 2 hari lalu
induk
melakukan
0de81537ed

+ 19 - 5
server/webapi/handlers/auth.go

@@ -8,6 +8,7 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
+	"mime"
 	"net/http"
 	"net/url"
 	"strconv"
@@ -139,6 +140,17 @@ func (h *AuthHandler) resolveGetTokenSession(r *http.Request) (state.DisplayScre
 	return serverCookie.ScreenName, rawCookie, true
 }
 
+// Web API status codes, which a client reads from the envelope rather than from
+// the HTTP status. A failed sign-in is a demand for better credentials, not an
+// error: statusMoreAuthRequired plus the detail code naming what was wrong is
+// what tells a client to say "incorrect password".
+const (
+	statusMoreAuthRequired = 330
+	statusMissingParameter = 460
+
+	detailBadPassword = 3011
+)
+
 // errInvalidCredentials reports that the auth service rejected the screen name or
 // password, as opposed to failing to answer at all.
 var errInvalidCredentials = errors.New("invalid screen name or password")
@@ -209,10 +221,11 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
 func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 	var username, password, devID string
 
-	// Check Content-Type to determine how to parse the request
-	contentType := r.Header.Get("Content-Type")
+	// The media type alone decides how to read the body: a caller that states a
+	// charset sends "application/json; charset=utf-8", which is still JSON.
+	mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
 
-	if contentType == "application/json" {
+	if mediaType == "application/json" {
 		// Parse JSON body
 		var req ClientLoginRequest
 		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -251,7 +264,7 @@ func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 
 	// Validate required fields
 	if username == "" || password == "" {
-		SendError(w, r, http.StatusBadRequest, "username and password required")
+		SendErrorDetail(w, r, http.StatusBadRequest, statusMissingParameter, 0, "username and password required")
 		return
 	}
 
@@ -259,7 +272,8 @@ func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		h.Logger.DebugContext(r.Context(), "clientLogin failed", "username", username, "error", err)
 		if errors.Is(err, errInvalidCredentials) {
-			SendError(w, r, http.StatusUnauthorized, "username and password required")
+			SendErrorDetail(w, r, http.StatusUnauthorized, statusMoreAuthRequired, detailBadPassword,
+				"invalid screen name or password")
 			return
 		}
 		SendError(w, r, http.StatusInternalServerError, "internal server error")

+ 42 - 1
server/webapi/handlers/auth_test.go

@@ -246,6 +246,23 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 				assert.Contains(t, body, `"sessionSecret"`)
 			},
 		},
+		{
+			// A caller that states a charset is still sending JSON.
+			name:        "Success_JSONBodyWithCharset",
+			method:      "POST",
+			contentType: "application/json; charset=utf-8",
+			body:        `{"username":"testuser","password":"testpass","devId":"dev123"}`,
+			auth: &testAuthService{
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+					return successfulLoginBlock(), nil
+				},
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"loginId":"testuser"`)
+			},
+		},
 		{
 			name:        "Success_FormEncoded",
 			method:      "POST",
@@ -270,6 +287,10 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			auth:               &testAuthService{},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
+				// The code a client reads as "you left something out", which is
+				// not the code that means the credentials were wrong.
+				assert.Contains(t, body, `"statusCode":460`)
+				assert.NotContains(t, body, "statusDetailCode")
 				assert.Contains(t, body, "username and password required")
 			},
 		},
@@ -281,6 +302,7 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			auth:               &testAuthService{},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":460`)
 				assert.Contains(t, body, "username and password required")
 			},
 		},
@@ -296,7 +318,9 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 			},
 			expectedStatusCode: http.StatusUnauthorized,
 			checkResponse: func(t *testing.T, body string) {
-				assert.Contains(t, body, "username and password required")
+				// The codes a client maps to "incorrect password".
+				assert.Contains(t, body, `"statusCode":330`)
+				assert.Contains(t, body, `"statusDetailCode":3011`)
 			},
 		},
 		{
@@ -325,6 +349,23 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
 				assert.Contains(t, body, "invalid JSON format")
 			},
 		},
+		{
+			// A POST carries "f" in its body, the only place clientLogin states it.
+			name:        "Error_AuthFailed_XMLRequestedInBody",
+			method:      "POST",
+			contentType: "application/x-www-form-urlencoded",
+			body:        "s=testuser&pwd=wrongpass&f=xml",
+			auth: &testAuthService{
+				flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
+					return failedLoginBlock(), nil
+				},
+			},
+			expectedStatusCode: http.StatusUnauthorized,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "<statusCode>330</statusCode>")
+				assert.Contains(t, body, "<statusDetailCode>3011</statusDetailCode>")
+			},
+		},
 		{
 			name:        "Error_LoginResponseHasNoCookie",
 			method:      "POST",

+ 69 - 45
server/webapi/handlers/common.go

@@ -39,8 +39,12 @@ type ResponseBody struct {
 // ErrorResponse represents an error response with proper XML/JSON support.
 type ErrorResponse struct {
 	Response struct {
-		StatusCode int    `json:"statusCode" xml:"statusCode"`
-		StatusText string `json:"statusText" xml:"statusText"`
+		StatusCode int `json:"statusCode" xml:"statusCode"`
+		// StatusDetailCode names which failure of a status code this is, e.g. 3011
+		// (bad password) under 330. Omitted when unset, which a client would
+		// otherwise read as a detail code of its own.
+		StatusDetailCode int    `json:"statusDetailCode,omitempty" xml:"statusDetailCode,omitempty"`
+		StatusText       string `json:"statusText" xml:"statusText"`
 		// Data carries an empty object for the same reason the JSONP error path
 		// sends one: a client callback that reaches response.data on a failure
 		// throws a TypeError when it is absent.
@@ -55,13 +59,30 @@ func (e ErrorResponse) MarshalXML(enc *xml.Encoder, _ xml.StartElement) error {
 
 // newErrorResponse builds the error envelope every format shares.
 func newErrorResponse(statusCode int, message string) ErrorResponse {
+	return newErrorResponseDetail(statusCode, 0, message)
+}
+
+// newErrorResponseDetail builds the error envelope with a statusDetailCode.
+func newErrorResponseDetail(statusCode, detailCode int, message string) ErrorResponse {
 	resp := ErrorResponse{}
 	resp.Response.StatusCode = statusCode
+	resp.Response.StatusDetailCode = detailCode
 	resp.Response.StatusText = message
 	resp.Response.Data = struct{}{}
 	return resp
 }
 
+// requestFormat returns the format the client asked for. A POST sends "f" in
+// its body, as clientLogin does.
+func requestFormat(r *http.Request) string {
+	format := strings.ToLower(r.URL.Query().Get("f"))
+	if format == "" && r.Method == http.MethodPost {
+		_ = r.ParseForm()
+		format = strings.ToLower(r.FormValue("f"))
+	}
+	return format
+}
+
 // requestIDFromRequest returns the Web AIM client request correlation id from the
 // "r" query parameter. JSONP callbacks require this echoed in response.requestId.
 func requestIDFromRequest(r *http.Request) string {
@@ -94,20 +115,9 @@ func normalizeEnvelope(r *http.Request, data interface{}) interface{} {
 func SendResponse(w http.ResponseWriter, r *http.Request, data interface{}, logger *slog.Logger) {
 	data = normalizeEnvelope(r, data)
 
-	// Check for format parameter (f for format or callback for JSONP)
-	// First check URL query parameters
-	format := strings.ToLower(r.URL.Query().Get("f"))
+	format := requestFormat(r)
 	callback := jsonpCallback(r)
 
-	// If format not in URL query, check form values (for POST requests)
-	if format == "" && r.Method == "POST" {
-		_ = r.ParseForm()
-		format = strings.ToLower(r.FormValue("f"))
-		if callback == "" {
-			callback = jsonpCallback(r)
-		}
-	}
-
 	// Check for AMF format first
 	if format == "amf" || format == "amf3" {
 		sendAMF(w, r, data, logger)
@@ -145,20 +155,37 @@ func SendResponse(w http.ResponseWriter, r *http.Request, data interface{}, logg
 // Web AIM client reports as the generic "Failed to load script tag, probably
 // malformed JS at that url" instead of the real statusText.
 func SendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
+	sendErrorEnvelope(w, r, statusCode, newErrorResponse(statusCode, message))
+}
+
+// SendErrorDetail sends an error carrying a statusDetailCode, which is how a
+// client tells one failure of a status code from another. The HTTP status is
+// separate because the API codes are not HTTP codes: a bad clientLogin password
+// is 330/3011 on an HTTP 401.
+func SendErrorDetail(w http.ResponseWriter, r *http.Request, httpStatus, statusCode, detailCode int, message string) {
+	sendErrorEnvelope(w, r, httpStatus, newErrorResponseDetail(statusCode, detailCode, message))
+}
+
+// sendErrorEnvelope writes an error envelope in the format the client asked for.
+func sendErrorEnvelope(w http.ResponseWriter, r *http.Request, httpStatus int, resp ErrorResponse) {
 	if callback := jsonpCallback(r); callback != "" && isValidCallback(callback) {
-		sendJSONPError(w, r, callback, statusCode, message)
+		sendJSONPError(w, r, callback, resp)
 		return
 	}
 
-	// Try to detect format from Content-Type header if already set
+	// A client that gets a format it cannot parse reports the failure as an
+	// unreadable response rather than as this statusText. The Content-Type is the
+	// fallback signal, naming the format a handler already began writing.
+	format := requestFormat(r)
 	contentType := w.Header().Get("Content-Type")
 
-	if strings.Contains(contentType, "amf") {
-		sendAMFError(w, r, statusCode, message, nil)
-	} else if strings.Contains(contentType, "xml") {
-		sendXMLError(w, statusCode, message)
-	} else {
-		sendJSONError(w, statusCode, message)
+	switch {
+	case format == "xml" || strings.Contains(contentType, "xml"):
+		sendXMLError(w, httpStatus, resp)
+	case format == "amf" || format == "amf3" || strings.Contains(contentType, "amf"):
+		sendAMFError(w, r, httpStatus, resp, nil)
+	default:
+		sendJSONError(w, httpStatus, resp)
 	}
 }
 
@@ -168,16 +195,19 @@ func SendError(w http.ResponseWriter, r *http.Request, statusCode int, message s
 // of a <script> tag that came back with a 4xx or 5xx, so a status-carrying JSONP
 // error never reaches the callback at all. The real status travels in the
 // envelope, which is where the Web AIM client reads it from regardless.
-func sendJSONPError(w http.ResponseWriter, r *http.Request, callback string, statusCode int, message string) {
+func sendJSONPError(w http.ResponseWriter, r *http.Request, callback string, resp ErrorResponse) {
 	envelope := map[string]any{
-		"statusCode": statusCode,
-		"statusText": message,
+		"statusCode": resp.Response.StatusCode,
+		"statusText": resp.Response.StatusText,
 		// Callbacks that reach response.data on a failure throw a TypeError when
 		// it is absent, which aborts whatever the client was doing mid-startup.
 		// Its XHR path fabricates an empty data for transport failures; JSONP
 		// delivers the envelope verbatim, so the empty data has to come from here.
 		"data": map[string]any{},
 	}
+	if resp.Response.StatusDetailCode != 0 {
+		envelope["statusDetailCode"] = resp.Response.StatusDetailCode
+	}
 	// The client indexes JSONP replies by response.requestId and discards any
 	// reply that lacks one ("Request id is missing from the server response"),
 	// leaving the request pending until it times out.
@@ -187,7 +217,7 @@ func sendJSONPError(w http.ResponseWriter, r *http.Request, callback string, sta
 
 	body, err := json.Marshal(map[string]any{"response": envelope})
 	if err != nil {
-		sendJSONError(w, http.StatusInternalServerError, "internal server error")
+		sendJSONError(w, http.StatusInternalServerError, newErrorResponse(http.StatusInternalServerError, "internal server error"))
 		return
 	}
 
@@ -199,26 +229,22 @@ func sendJSONPError(w http.ResponseWriter, r *http.Request, callback string, sta
 }
 
 // sendJSONError sends a JSON error response.
-func sendJSONError(w http.ResponseWriter, statusCode int, message string) {
-	resp := newErrorResponse(statusCode, message)
-
+func sendJSONError(w http.ResponseWriter, httpStatus int, resp ErrorResponse) {
 	w.Header().Set("Content-Type", "application/json")
-	w.WriteHeader(statusCode)
+	w.WriteHeader(httpStatus)
 	_ = json.NewEncoder(w).Encode(resp)
 }
 
 // sendXMLError sends an XML error response.
-func sendXMLError(w http.ResponseWriter, statusCode int, message string) {
-	resp := newErrorResponse(statusCode, message)
-
+func sendXMLError(w http.ResponseWriter, httpStatus int, resp ErrorResponse) {
 	w.Header().Set("Content-Type", "text/xml; charset=utf-8")
-	w.WriteHeader(statusCode)
+	w.WriteHeader(httpStatus)
 
 	// Write XML declaration and marshal the response
 	xmlData, err := xml.Marshal(resp)
 	if err != nil {
 		// Fall back to simple text response
-		http.Error(w, message, statusCode)
+		http.Error(w, resp.Response.StatusText, httpStatus)
 		return
 	}
 
@@ -255,7 +281,7 @@ func sendXML(w http.ResponseWriter, data interface{}, logger *slog.Logger) {
 		if logger != nil {
 			logger.Error("failed to marshal XML response", "err", err.Error())
 		}
-		sendXMLError(w, http.StatusInternalServerError, "internal server error")
+		sendXMLError(w, http.StatusInternalServerError, newErrorResponse(http.StatusInternalServerError, "internal server error"))
 		return
 	}
 
@@ -284,7 +310,7 @@ func sendJSONP(w http.ResponseWriter, r *http.Request, callback string, data int
 	// Validate callback to prevent XSS. This is the one error here that cannot be
 	// delivered as JSONP: there is no callback name safe to write.
 	if !isValidCallback(callback) {
-		sendJSONError(w, http.StatusBadRequest, "invalid callback parameter")
+		sendJSONError(w, http.StatusBadRequest, newErrorResponse(http.StatusBadRequest, "invalid callback parameter"))
 		return
 	}
 
@@ -295,7 +321,7 @@ func sendJSONP(w http.ResponseWriter, r *http.Request, callback string, data int
 		}
 		// The client is on the <script> transport, so the error has to be
 		// executable JS for it to see anything but a load failure.
-		sendJSONPError(w, r, callback, http.StatusInternalServerError, "internal server error")
+		sendJSONPError(w, r, callback, newErrorResponse(http.StatusInternalServerError, "internal server error"))
 		return
 	}
 
@@ -339,7 +365,7 @@ func sendAMF(w http.ResponseWriter, r *http.Request, data interface{}, logger *s
 				"dataType", fmt.Sprintf("%T", data))
 		}
 		// Fall back to JSON error
-		sendJSONError(w, http.StatusInternalServerError, "AMF encoding failed")
+		sendJSONError(w, http.StatusInternalServerError, newErrorResponse(http.StatusInternalServerError, "AMF encoding failed"))
 		return
 	}
 
@@ -373,21 +399,19 @@ func sendAMF(w http.ResponseWriter, r *http.Request, data interface{}, logger *s
 }
 
 // sendAMFError sends an AMF error response
-func sendAMFError(w http.ResponseWriter, r *http.Request, statusCode int, message string, logger *slog.Logger) {
-	errorResp := newErrorResponse(statusCode, message)
-
+func sendAMFError(w http.ResponseWriter, r *http.Request, httpStatus int, resp ErrorResponse, logger *slog.Logger) {
 	encoder := NewAMFEncoder(logger)
 	version := DetectAMFVersion(r)
 
-	amfData, err := encoder.EncodeAMF(errorResp, version)
+	amfData, err := encoder.EncodeAMF(resp, version)
 	if err != nil {
 		// If AMF encoding fails, fall back to JSON error
-		sendJSONError(w, statusCode, message)
+		sendJSONError(w, httpStatus, resp)
 		return
 	}
 
 	w.Header().Set("Content-Type", "application/x-amf")
 	w.Header().Set("Content-Length", strconv.Itoa(len(amfData)))
-	w.WriteHeader(statusCode)
+	w.WriteHeader(httpStatus)
 	_, _ = w.Write(amfData)
 }

+ 41 - 0
server/webapi/handlers/common_test.go

@@ -141,6 +141,47 @@ func TestSendErrorJSONFallback(t *testing.T) {
 	assert.Contains(t, w.Body.String(), `"statusCode":404`)
 }
 
+// An error takes the format the client asked for in "f", the same signal
+// SendResponse honors. A client that gets a format it cannot parse reports an
+// unreadable response instead of the statusText.
+func TestSendErrorHonorsRequestedFormat(t *testing.T) {
+	t.Run("xml", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/im/sendIM?f=xml", nil)
+		w := httptest.NewRecorder()
+
+		SendError(w, req, http.StatusNotFound, "not found")
+
+		assert.Contains(t, w.Header().Get("Content-Type"), "xml")
+		assert.Contains(t, w.Body.String(), "<statusCode>404</statusCode>")
+	})
+
+	// AMF is binary, so the envelope is unreadable as text; the Content-Type is
+	// what says the encoder ran rather than the JSON fallback.
+	for _, format := range []string{"amf", "amf3"} {
+		t.Run(format, func(t *testing.T) {
+			req := httptest.NewRequest("GET", "/im/sendIM?f="+format, nil)
+			w := httptest.NewRecorder()
+
+			SendError(w, req, http.StatusNotFound, "not found")
+
+			assert.Contains(t, w.Header().Get("Content-Type"), "amf")
+			assert.NotEmpty(t, w.Body.Bytes())
+		})
+	}
+
+	// A callback outranks the format: the client is on the <script> transport
+	// and needs executable JS whatever "f" says.
+	t.Run("a callback outranks f", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/im/sendIM?f=xml&c=cb", nil)
+		w := httptest.NewRecorder()
+
+		SendError(w, req, http.StatusNotFound, "not found")
+
+		assert.Contains(t, w.Header().Get("Content-Type"), "javascript")
+		assert.Contains(t, w.Body.String(), "cb(")
+	})
+}
+
 // The Web API nests the envelope under a "response" key in JSON but renders it
 // as a flat <response> root in XML. MarshalXML reconciles the two so one struct
 // can describe a response in both formats.

+ 56 - 200
server/webapi/handlers/oscar_bridge.go

@@ -1,47 +1,28 @@
 package handlers
 
 import (
-	"bytes"
-	"context"
-	"encoding/hex"
+	"encoding/base64"
 	"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.
+// OSCARBridgeHandler handles the handoff from the Web API's HTTP login to the
+// native OSCAR protocol, telling a client where to connect and what credential
+// to present.
 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.
+// OSCARAuthService verifies the credential a client presents to the bridge.
 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)
+	CrackCookie(authCookie []byte) (state.ServerCookie, error)
 }
 
 // OSCARConfig provides configuration for OSCAR services.
@@ -52,15 +33,6 @@ type OSCARConfig interface {
 	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.
@@ -69,12 +41,12 @@ type StartOSCARSessionResponse 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"`
+			Host   string `json:"host" xml:"host"`
+			Port   int    `json:"port" xml:"port"`
+			Cookie string `json:"cookie" xml:"cookie"`
+			// TLSCertName is the certificate name the client verifies BOS against.
+			// Omitted rather than sent empty: its absence means connect in the clear.
+			TLSCertName string `json:"tlsCertName,omitempty" xml:"tlsCertName,omitempty"`
 		} `json:"data" xml:"data"`
 	} `json:"response"`
 }
@@ -84,31 +56,17 @@ func (s StartOSCARSessionResponse) MarshalXML(e *xml.Encoder, _ xml.StartElement
 	return e.EncodeElement(s.Response, xml.StartElement{Name: xml.Name{Local: "response"}})
 }
 
-// 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.
+// StartOSCARSession handles GET /aim/startOSCARSession requests, which hand a
+// client that authenticated over HTTP the address of a BOS server and the
+// cookie to sign on with. The token in "a" is the auth cookie clientLogin
+// minted, already what BOS expects, so it is handed straight back.
 //
-// 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
+// The sig_sha256 the client computes over the query string is not checked: that
+// signature is keyed by HMAC(password, sessionSecret), and clientLogin keeps
+// neither past the response.
 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,
@@ -118,7 +76,7 @@ func (h *OSCARBridgeHandler) StartOSCARSession(w http.ResponseWriter, r *http.Re
 	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")
+		SendError(w, r, http.StatusInternalServerError, "internal server error")
 		return
 	}
 
@@ -126,125 +84,71 @@ func (h *OSCARBridgeHandler) StartOSCARSession(w http.ResponseWriter, r *http.Re
 	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")
+		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")
+	token := params.Get("a")
+	if token == "" {
+		h.Logger.Warn("missing authentication token")
+		SendError(w, r, http.StatusUnauthorized, "authentication token required")
 		return
 	}
 
-	// Validate WebAPI session
-	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	rawCookie, err := base64.URLEncoding.DecodeString(strings.TrimSpace(token))
 	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")
-		}
+		h.Logger.WarnContext(ctx, "invalid authentication token (base64)", "err", err.Error())
+		SendError(w, r, http.StatusUnauthorized, "invalid or expired token")
 		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)
+	cookie, err := h.OSCARAuthService.CrackCookie(rawCookie)
+	if err != nil {
+		h.Logger.WarnContext(ctx, "invalid authentication token", "err", err.Error())
+		SendError(w, r, http.StatusUnauthorized, "invalid or expired token")
 		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
+	// Encryption the server cannot provide degrades to a plaintext host, which a
+	// client doing opportunistic encryption expects when no certificate is named.
+	// The sign-on cookie then crosses the wire in the clear, so the downgrade is
+	// logged rather than left to be inferred from the absent tlsCertName.
+	useTLS := h.parseBoolParam(params.Get("useTLS"))
+	if useTLS && !h.Config.IsSSLAvailable() {
+		h.Logger.WarnContext(ctx, "TLS requested but no SSL listener is configured, advertising a plaintext BOS host",
+			"screen_name", cookie.ScreenName)
+		useTLS = false
 	}
 
-	// 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 {
+	if useTLS {
 		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)
+	resp := &StartOSCARSessionResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data.Host = host
+	resp.Response.Data.Port = port
+	// Base64, the encoding the client decodes the cookie with.
+	resp.Response.Data.Cookie = base64.StdEncoding.EncodeToString(rawCookie)
+	if useTLS {
+		// The advertised SSL host is the name the certificate is issued to.
+		resp.Response.Data.TLSCertName = host
+	}
 
-	// Send response in requested format
-	h.sendResponse(w, r, resp)
+	SendResponse(w, r, resp, h.Logger)
 
 	h.Logger.InfoContext(ctx, "OSCAR session bridge created",
-		"aimsid", aimsid,
-		"screen_name", session.ScreenName,
+		"screen_name", cookie.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
+		"use_tls", useTLS)
 }
 
 // hasOSCARBridgeCapability checks if the API key has permission to create OSCAR bridges.
@@ -268,51 +172,3 @@ 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
-	}
-
-	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, r, statusCode, message)
-}

+ 215 - 0
server/webapi/handlers/oscar_bridge_test.go

@@ -0,0 +1,215 @@
+package handlers
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/mk6i/open-oscar-server/server/webapi/middleware"
+	"github.com/mk6i/open-oscar-server/state"
+)
+
+// testOSCARConfig implements OSCARConfig with fixed addresses.
+type testOSCARConfig struct {
+	sslAvailable bool
+}
+
+func (c testOSCARConfig) GetBOSAddress() (string, int) { return "bos.example.com", 5190 }
+
+func (c testOSCARConfig) GetSSLBOSAddress() (string, int) { return "ssl.example.com", 5193 }
+
+func (c testOSCARConfig) IsSSLAvailable() bool { return c.sslAvailable }
+
+// bridgeRequest builds a startOSCARSession request carrying the API key the
+// middleware would have put on the context.
+func bridgeRequest(query string, apiKey *state.WebAPIKey) *http.Request {
+	req := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?"+query, nil)
+	if apiKey != nil {
+		req = req.WithContext(context.WithValue(req.Context(), middleware.ContextKeyAPIKey, apiKey))
+	}
+	return req
+}
+
+// bridgeData is the data object of a successful startOSCARSession response.
+type bridgeData struct {
+	Response struct {
+		StatusCode int `json:"statusCode"`
+		Data       struct {
+			Host        string `json:"host"`
+			Port        int    `json:"port"`
+			Cookie      string `json:"cookie"`
+			TLSCertName string `json:"tlsCertName"`
+		} `json:"data"`
+	} `json:"response"`
+}
+
+func TestOSCARBridgeHandler_StartOSCARSession(t *testing.T) {
+	validToken := base64.URLEncoding.EncodeToString(signedCookieFor("testuser"))
+	unrestrictedKey := &state.WebAPIKey{DevID: "dev123"}
+
+	tests := []struct {
+		name         string
+		query        string
+		apiKey       *state.WebAPIKey
+		sslAvailable bool
+		expectedCode int
+		checkBody    func(t *testing.T, body string)
+	}{
+		{
+			// No tlsCertName, which is how the client reads "connect in the clear".
+			name:         "Success_Plaintext",
+			query:        "a=" + validToken,
+			apiKey:       unrestrictedKey,
+			expectedCode: http.StatusOK,
+			checkBody: func(t *testing.T, body string) {
+				got := decodeBridgeData(t, body)
+				assert.Equal(t, 200, got.Response.StatusCode)
+				assert.Equal(t, "bos.example.com", got.Response.Data.Host)
+				assert.Equal(t, 5190, got.Response.Data.Port)
+				assert.Empty(t, got.Response.Data.TLSCertName)
+			},
+		},
+		{
+			name:         "Success_TLS",
+			query:        "a=" + validToken + "&useTLS=1",
+			apiKey:       unrestrictedKey,
+			sslAvailable: true,
+			expectedCode: http.StatusOK,
+			checkBody: func(t *testing.T, body string) {
+				got := decodeBridgeData(t, body)
+				assert.Equal(t, "ssl.example.com", got.Response.Data.Host)
+				assert.Equal(t, 5193, got.Response.Data.Port)
+				// The certificate is issued to the host the client is sent to.
+				assert.Equal(t, "ssl.example.com", got.Response.Data.TLSCertName)
+			},
+		},
+		{
+			// Encryption the server cannot provide degrades to a plaintext host
+			// rather than failing the handoff.
+			name:         "TLSRequestedButUnavailable_DegradesToPlaintext",
+			query:        "a=" + validToken + "&useTLS=true",
+			apiKey:       unrestrictedKey,
+			sslAvailable: false,
+			expectedCode: http.StatusOK,
+			checkBody: func(t *testing.T, body string) {
+				got := decodeBridgeData(t, body)
+				assert.Equal(t, "bos.example.com", got.Response.Data.Host)
+				assert.Empty(t, got.Response.Data.TLSCertName)
+			},
+		},
+		{
+			name:         "Error_MissingToken",
+			query:        "",
+			apiKey:       unrestrictedKey,
+			expectedCode: http.StatusUnauthorized,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "authentication token required")
+			},
+		},
+		{
+			name:         "Error_TokenNotBase64",
+			query:        "a=not!valid!base64",
+			apiKey:       unrestrictedKey,
+			expectedCode: http.StatusUnauthorized,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "invalid or expired token")
+			},
+		},
+		{
+			// A well-formed token the baker refuses to crack: wrong signature or
+			// past its expiry.
+			name:         "Error_TokenFailsSignatureCheck",
+			query:        "a=" + base64.URLEncoding.EncodeToString([]byte("forged")),
+			apiKey:       unrestrictedKey,
+			expectedCode: http.StatusUnauthorized,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "invalid or expired token")
+			},
+		},
+		{
+			name:         "Error_NoAPIKeyOnContext",
+			query:        "a=" + validToken,
+			apiKey:       nil,
+			expectedCode: http.StatusInternalServerError,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "internal server error")
+			},
+		},
+		{
+			name:         "Error_APIKeyLacksBridgeCapability",
+			query:        "a=" + validToken,
+			apiKey:       &state.WebAPIKey{DevID: "dev123", Capabilities: []string{"presence"}},
+			expectedCode: http.StatusForbidden,
+			checkBody: func(t *testing.T, body string) {
+				assert.Contains(t, body, "OSCAR bridge not enabled")
+			},
+		},
+		{
+			name:         "Success_APIKeyGrantsBridgeCapability",
+			query:        "a=" + validToken,
+			apiKey:       &state.WebAPIKey{DevID: "dev123", Capabilities: []string{"presence", "oscar_bridge"}},
+			expectedCode: http.StatusOK,
+			checkBody: func(t *testing.T, body string) {
+				assert.Equal(t, 200, decodeBridgeData(t, body).Response.StatusCode)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			handler := &OSCARBridgeHandler{
+				OSCARAuthService: &testAuthService{crackCookie: crackSignedCookie},
+				Config:           testOSCARConfig{sslAvailable: tt.sslAvailable},
+				Logger:           slog.Default(),
+			}
+
+			rr := httptest.NewRecorder()
+			handler.StartOSCARSession(rr, bridgeRequest(tt.query, tt.apiKey))
+
+			assert.Equal(t, tt.expectedCode, rr.Code)
+			tt.checkBody(t, rr.Body.String())
+		})
+	}
+}
+
+// The token arrives URL-safe, the way clientLogin minted it, and goes back out in
+// standard base64, the alphabet the client decodes the sign-on cookie with. The
+// cookie bytes here encode differently under each.
+func TestOSCARBridgeHandler_StartOSCARSession_ReencodesCookie(t *testing.T) {
+	rawCookie := []byte{0xff, 0xef, 0xbe}
+	urlSafe := base64.URLEncoding.EncodeToString(rawCookie)
+	standard := base64.StdEncoding.EncodeToString(rawCookie)
+	assert.NotEqual(t, urlSafe, standard, "test cookie must distinguish the two alphabets")
+
+	var cracked []byte
+	handler := &OSCARBridgeHandler{
+		OSCARAuthService: &testAuthService{
+			crackCookie: func(authCookie []byte) (state.ServerCookie, error) {
+				cracked = authCookie
+				return state.ServerCookie{ScreenName: "testuser"}, nil
+			},
+		},
+		Config: testOSCARConfig{},
+		Logger: slog.Default(),
+	}
+
+	rr := httptest.NewRecorder()
+	handler.StartOSCARSession(rr, bridgeRequest("a="+urlSafe, &state.WebAPIKey{DevID: "dev123"}))
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Equal(t, rawCookie, cracked, "the baker sees the decoded cookie")
+	assert.Equal(t, standard, decodeBridgeData(t, rr.Body.String()).Response.Data.Cookie)
+}
+
+func decodeBridgeData(t *testing.T, body string) bridgeData {
+	t.Helper()
+	got := bridgeData{}
+	assert.NoError(t, json.Unmarshal([]byte(body), &got))
+	return got
+}

+ 47 - 0
server/webapi/middleware/auth.go

@@ -3,6 +3,7 @@ package middleware
 import (
 	"context"
 	"encoding/json"
+	"encoding/xml"
 	"fmt"
 	"log/slog"
 	"net/http"
@@ -378,6 +379,9 @@ func (m *AuthMiddleware) writeErrorEnvelope(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	// The callback outranks the format: a client on the <script> transport needs
+	// executable JS back whatever "f" says, and gets a script load failure
+	// otherwise.
 	if callback := jsonpCallback(r); callback != "" && isValidJSONPCallback(callback) {
 		w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
 		_, _ = w.Write([]byte(callback))
@@ -387,6 +391,13 @@ func (m *AuthMiddleware) writeErrorEnvelope(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	// An XML client cannot parse a JSON error; it reports an unreadable response
+	// rather than this statusText.
+	if requestFormat(r) == "xml" {
+		m.writeXMLErrorEnvelope(w, statusCode, message, httpStatus)
+		return
+	}
+
 	w.Header().Set("Content-Type", "application/json")
 	if httpStatus {
 		w.WriteHeader(statusCode)
@@ -394,6 +405,42 @@ func (m *AuthMiddleware) writeErrorEnvelope(w http.ResponseWriter, r *http.Reque
 	_, _ = w.Write(body)
 }
 
+// xmlErrorEnvelope is the error envelope XML clients read, rooted at the
+// response itself where JSON nests it under a "response" key.
+type xmlErrorEnvelope struct {
+	XMLName    xml.Name `xml:"response"`
+	StatusCode int      `xml:"statusCode"`
+	StatusText string   `xml:"statusText"`
+	Data       struct{} `xml:"data"`
+}
+
+func (m *AuthMiddleware) writeXMLErrorEnvelope(w http.ResponseWriter, statusCode int, message string, httpStatus bool) {
+	body, err := xml.Marshal(xmlErrorEnvelope{StatusCode: statusCode, StatusText: message})
+	if err != nil {
+		m.Logger.Error("failed to encode XML error response", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/xml; charset=utf-8")
+	if httpStatus {
+		w.WriteHeader(statusCode)
+	}
+	_, _ = w.Write([]byte(xml.Header))
+	_, _ = w.Write(body)
+}
+
+// requestFormat returns the format the client asked for. A POST sends "f" in its
+// body, as clientLogin does, so the query string alone does not answer it.
+func requestFormat(r *http.Request) string {
+	format := strings.ToLower(r.URL.Query().Get("f"))
+	if format == "" && r.Method == http.MethodPost {
+		_ = r.ParseForm()
+		format = strings.ToLower(r.FormValue("f"))
+	}
+	return format
+}
+
 func jsonpCallback(r *http.Request) string {
 	if callback := r.URL.Query().Get("c"); callback != "" {
 		return callback

+ 42 - 0
server/webapi/middleware/cors_test.go

@@ -259,6 +259,48 @@ func TestAuthErrorsHonorJSONP(t *testing.T) {
 	})
 }
 
+// An XML client cannot parse a JSON error, so it reports an unreadable response
+// instead of the reason the auth layer rejected it.
+func TestAuthErrorsHonorXML(t *testing.T) {
+	m := newTestMiddleware(&stubValidator{})
+	h := m.Authenticate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+		t.Fatal("handler should not run")
+	}))
+
+	t.Run("format in the query string", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?f=xml", nil)
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		assert.Contains(t, w.Header().Get("Content-Type"), "xml")
+		assert.Contains(t, w.Body.String(), "<statusCode>400</statusCode>")
+	})
+
+	// A POST states the format in its body, the only place clientLogin sends it.
+	t.Run("format in the POST body", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodPost, "/auth/clientLogin", strings.NewReader("s=testuser&f=xml"))
+		r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		assert.Contains(t, w.Header().Get("Content-Type"), "xml")
+		assert.Contains(t, w.Body.String(), "<statusCode>400</statusCode>")
+	})
+
+	// A client on the <script> transport needs executable JS back whatever "f"
+	// says; XML there is a script load failure with no reason attached.
+	t.Run("a callback outranks the format", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?f=xml&c=cb&r=3", nil)
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		body := w.Body.String()
+		assert.True(t, strings.HasPrefix(body, "cb("), "got %s", body)
+		assert.Contains(t, body, `"statusCode":400`)
+		assert.Contains(t, body, `"requestId":"3"`)
+	})
+}
+
 // stubSessionResolver never resolves a session, so RequireSession always rejects.
 type stubSessionResolver struct{}
 

+ 0 - 5
server/webapi/oscar_config.go

@@ -100,11 +100,6 @@ func (a *OSCARConfigAdapter) IsSSLAvailable() bool {
 	return false
 }
 
-// IsAuthDisabled returns whether authentication is disabled.
-func (a *OSCARConfigAdapter) IsAuthDisabled() bool {
-	return a.cfg.DisableAuth
-}
-
 // splitHostPort splits a host:port string, handling IPv6 addresses correctly.
 // Unlike net.SplitHostPort, this doesn't return an error for missing ports.
 func splitHostPort(hostport string) (host string, port string) {

+ 0 - 2
server/webapi/server.go

@@ -80,9 +80,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 	}
 
 	oscarBridgeHandler := &handlers.OSCARBridgeHandler{
-		SessionManager:   sessionManager,
 		OSCARAuthService: handler.AuthService,
-		CookieBaker:      handler.CookieBaker,
 		Config:           handler.OSCARConfig,
 		Logger:           logger,
 	}

+ 0 - 1
server/webapi/types.go

@@ -101,7 +101,6 @@ type OSCARConfig interface {
 	GetBOSAddress() (host string, port int)
 	GetSSLBOSAddress() (host string, port int)
 	IsSSLAvailable() bool
-	IsAuthDisabled() bool
 }
 
 type ChatSessionManager interface {

+ 0 - 4
state/webapi_session.go

@@ -58,10 +58,6 @@ type WebAPISession struct {
 	AimSID              string                                         // Unique session ID for web client
 	ScreenName          DisplayScreenName                              // User identity
 	OSCARSession        *SessionInstance                               // Bridge to existing OSCAR session
-	OSCARCookie         []byte                                         // OSCAR auth cookie for the startOSCARSession handoff
-	BOSHost             string                                         // BOS host advertised to the web client
-	BOSPort             int                                            // BOS port advertised to the web client
-	UseSSL              bool                                           // Whether the handoff advertised an SSL BOS connection
 	BaseURL             string                                         // Web API base URL advertised to the web client, used to build absolute asset URLs
 	Events              []string                                       // Subscribed event types
 	EventQueue          *types.EventQueue                              // Per-session event queue