Fix ACP auth and Gemini session compatibility
This commit is contained in:
parent
6ebcdd6f5b
commit
98a076cd65
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
build/
|
||||
.env
|
||||
|
||||
173
docs/acp-public-validation-2026-04-09.md
Normal file
173
docs/acp-public-validation-2026-04-09.md
Normal file
@ -0,0 +1,173 @@
|
||||
# ACP Public Validation - 2026-04-09
|
||||
|
||||
This document records the post-deployment public validation for `xworkmate-bridge.svc.plus` and the unified ACP ingress at `acp-server.svc.plus`.
|
||||
|
||||
It is intended as an app-integration reference so future clients use the verified public endpoints and expected JSON-RPC methods.
|
||||
|
||||
## Verified Public Endpoints
|
||||
|
||||
### Bridge root
|
||||
|
||||
- URL: `https://xworkmate-bridge.svc.plus/`
|
||||
- Result: `200 OK`
|
||||
- Body: `xworkmate-bridge is running`
|
||||
|
||||
### ACP public ingress
|
||||
|
||||
The public ACP JSON-RPC endpoint is the `.../acp/rpc` path.
|
||||
|
||||
Do not send JSON-RPC requests to `.../acp` for HTTP clients.
|
||||
|
||||
Verified public HTTP JSON-RPC endpoints:
|
||||
|
||||
- Codex: `https://acp-server.svc.plus/codex/acp/rpc`
|
||||
- OpenCode: `https://acp-server.svc.plus/opencode/acp/rpc`
|
||||
- Gemini: `https://acp-server.svc.plus/gemini/acp/rpc`
|
||||
|
||||
The `.../acp` path remains reserved for WebSocket ACP.
|
||||
|
||||
## Auth Contract
|
||||
|
||||
All verified public ACP HTTP requests used:
|
||||
|
||||
- header: `Authorization: Bearer <INTERNAL_SERVICE_TOKEN>`
|
||||
- header: `Content-Type: application/json`
|
||||
|
||||
Missing bearer auth returns a JSON-RPC error envelope with code `-32001`.
|
||||
|
||||
## Public Validation Results
|
||||
|
||||
### Codex
|
||||
|
||||
Verified `acp.capabilities` over the public ingress:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "acp.capabilities",
|
||||
"result": {
|
||||
"providers": ["codex", "gemini", "opencode"],
|
||||
"singleAgent": true,
|
||||
"multiAgent": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verified `session.start` reached the Codex execution layer, but the task failed upstream.
|
||||
|
||||
Observed upstream failure summary:
|
||||
|
||||
- repeated `wss://api.openai.com/v1/responses` `500 Internal Server Error`
|
||||
- final `https://api.openai.com/v1/responses` `401 Unauthorized`
|
||||
- message: `Missing bearer or basic authentication in header`
|
||||
|
||||
### OpenCode
|
||||
|
||||
Verified `acp.capabilities` over the public ingress:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "acp.capabilities",
|
||||
"result": {
|
||||
"providers": ["opencode"],
|
||||
"singleAgent": true,
|
||||
"multiAgent": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verified `session.start` end to end with prompt `Reply with exactly pong`.
|
||||
|
||||
Observed result:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"provider": "opencode",
|
||||
"output": "pong"
|
||||
}
|
||||
```
|
||||
|
||||
### Gemini
|
||||
|
||||
Verified `acp.capabilities` over the public ingress:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "acp.capabilities",
|
||||
"result": {
|
||||
"providers": ["gemini"],
|
||||
"singleAgent": true,
|
||||
"multiAgent": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Before the compatibility layer landed, the upstream Gemini ACP returned:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "\"Method not found\": session.start"
|
||||
}
|
||||
```
|
||||
|
||||
The adapter has now been updated so `session.start` and `session.message` default to adapter-local prompt compatibility instead of forwarding unsupported upstream methods.
|
||||
|
||||
## App Integration Notes
|
||||
|
||||
### Recommended request shape
|
||||
|
||||
Use JSON-RPC `POST` requests against `.../acp/rpc`.
|
||||
|
||||
For capability discovery:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "cap-1",
|
||||
"method": "acp.capabilities"
|
||||
}
|
||||
```
|
||||
|
||||
For single-agent task execution:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "task-1",
|
||||
"method": "session.start",
|
||||
"params": {
|
||||
"sessionId": "session-1",
|
||||
"threadId": "thread-1",
|
||||
"taskPrompt": "Reply with exactly pong",
|
||||
"workingDirectory": "/tmp",
|
||||
"routing": {
|
||||
"routingMode": "explicit",
|
||||
"explicitExecutionTarget": "singleAgent",
|
||||
"explicitProviderId": "opencode"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Provider-specific notes
|
||||
|
||||
- `opencode` is the currently verified public task path.
|
||||
- `gemini` now depends on the adapter compatibility layer, not an upstream Gemini ACP conversation method.
|
||||
- `codex` public routing is healthy, but task execution requires upstream OpenAI auth to be present in the runtime environment.
|
||||
|
||||
## Codex Runtime Root Cause
|
||||
|
||||
Remote inspection on `jp-xhttp-contabo.svc.plus` showed:
|
||||
|
||||
- `codex-app-server` only had `HOME=/root TERM=xterm-256color NODE_NO_WARNINGS=1`
|
||||
- `/root/.codex` existed, but no auth/config JSON files were present
|
||||
- `codex --version` was `0.117.0`
|
||||
|
||||
That means the deployed Codex runtime was able to start, but it did not have a usable OpenAI auth source.
|
||||
|
||||
For future deployments, the systemd units should provide:
|
||||
|
||||
- `CODEX_HOME`
|
||||
- `OPENAI_API_KEY` when API-key auth is used
|
||||
- optional custom base URL variables when running against a non-default upstream
|
||||
@ -269,7 +269,7 @@ XWORKMATE_GEMINI_ARGS=--experimental-acp
|
||||
XWORKMATE_GEMINI_INIT_PROTOCOL_VERSION=1
|
||||
```
|
||||
|
||||
The implemented first version exposes:
|
||||
The implemented adapter exposes:
|
||||
|
||||
- `POST /acp/rpc`
|
||||
- `GET /acp` WebSocket ACP
|
||||
@ -284,14 +284,19 @@ Supported adapter methods:
|
||||
- `gemini.initialize`
|
||||
- `gemini.raw`
|
||||
|
||||
`session.start` and `session.message` currently behave as a shim skeleton:
|
||||
`session.start` and `session.message` now use a compatibility layer by default:
|
||||
|
||||
- the adapter always initializes Gemini ACP first
|
||||
- then it forwards to the configured upstream method
|
||||
- by default, the upstream method name is the same as the incoming bridge method
|
||||
- if Gemini returns an upstream error, the adapter converts that into a bridge-compatible `success: false` result payload instead of failing the HTTP transport
|
||||
- the adapter still initializes Gemini ACP first so `acp.capabilities` remains grounded in the real upstream ACP surface
|
||||
- if `GEMINI_ADAPTER_UPSTREAM_METHOD` is unset, session traffic runs through adapter-local prompt mode
|
||||
- the adapter keeps session-local history keyed by `sessionId`
|
||||
- `session.start` resets adapter-local history for that session
|
||||
- `session.message` replays prior user turns plus the new turn as one prompt to the Gemini CLI
|
||||
- the adapter returns a bridge-compatible single-agent payload with `output`, `provider`, `mode`, and `upstreamMethod: "prompt"`
|
||||
- `session.close` drops adapter-local state
|
||||
|
||||
You can override the forwarded method name with:
|
||||
This default exists because the verified Gemini ACP upstream did not expose bridge-compatible `session.start` / `session.message` methods during testing.
|
||||
|
||||
If Gemini ACP later gains a compatible conversation method, you can override the forwarded method name with:
|
||||
|
||||
```bash
|
||||
export GEMINI_ADAPTER_UPSTREAM_METHOD=your-discovered-gemini-method
|
||||
|
||||
@ -101,7 +101,7 @@ func NewServer() *Server {
|
||||
queues: make(map[string]chan task),
|
||||
gateway: gatewayruntime.NewManager(),
|
||||
providerCatalog: make(map[string]syncedProvider),
|
||||
authService: service.NewStaticTokenAuthService(""),
|
||||
authService: service.NewStaticTokenAuthService(strings.TrimSpace(shared.EnvOrDefault("ACP_AUTH_TOKEN", ""))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package geminiadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
@ -24,12 +25,15 @@ const (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
client rpcClient
|
||||
authService *service.StaticTokenAuthService
|
||||
providerID string
|
||||
providerLabel string
|
||||
allowedOrigins []string
|
||||
upstreamMethod string
|
||||
client rpcClient
|
||||
authService *service.StaticTokenAuthService
|
||||
providerID string
|
||||
providerLabel string
|
||||
allowedOrigins []string
|
||||
upstreamMethod string
|
||||
sessionRunner func(context.Context, string, string, string) (string, error)
|
||||
sessionsMu sync.Mutex
|
||||
sessions map[string]*adapterSession
|
||||
}
|
||||
|
||||
var adapterWSUpgrader = websocket.Upgrader{
|
||||
@ -40,6 +44,14 @@ var adapterWSUpgrader = websocket.Upgrader{
|
||||
},
|
||||
}
|
||||
|
||||
type adapterSession struct {
|
||||
history []string
|
||||
model string
|
||||
workingDirectory string
|
||||
lastOutput string
|
||||
lastUpstreamMethod string
|
||||
}
|
||||
|
||||
func Serve(args []string) error {
|
||||
flags := flag.NewFlagSet("gemini-acp-adapter", flag.ExitOnError)
|
||||
listen := flags.String(
|
||||
@ -98,6 +110,16 @@ func NewServer(client rpcClient) *Server {
|
||||
providerLabel: strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_PROVIDER_LABEL", defaultLabel)),
|
||||
allowedOrigins: parseAllowedOrigins(strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*,http://127.0.0.1:*"))),
|
||||
upstreamMethod: strings.TrimSpace(shared.EnvOrDefault("GEMINI_ADAPTER_UPSTREAM_METHOD", "")),
|
||||
sessionRunner: func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
|
||||
return shared.RunProviderCommand(
|
||||
ctx,
|
||||
defaultProviderID,
|
||||
model,
|
||||
prompt,
|
||||
workingDirectory,
|
||||
)
|
||||
},
|
||||
sessions: make(map[string]*adapterSession),
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,7 +210,8 @@ func (s *Server) handleRequest(request shared.RPCRequest) map[string]any {
|
||||
case "session.cancel":
|
||||
return map[string]any{"accepted": true, "cancelled": false}
|
||||
case "session.close":
|
||||
return map[string]any{"accepted": true, "closed": true}
|
||||
sessionID := strings.TrimSpace(shared.StringArg(request.Params, "sessionId", ""))
|
||||
return map[string]any{"accepted": true, "closed": s.closeSession(sessionID)}
|
||||
case "gemini.initialize":
|
||||
return s.handleInitialize()
|
||||
case "gemini.raw":
|
||||
@ -275,9 +298,13 @@ func (s *Server) handleSessionRequest(method string, params map[string]any) map[
|
||||
}
|
||||
}
|
||||
upstreamMethod := s.upstreamMethod
|
||||
if upstreamMethod == "" {
|
||||
upstreamMethod = strings.TrimSpace(method)
|
||||
if upstreamMethod != "" {
|
||||
return s.handleConfiguredUpstreamSessionRequest(upstreamMethod, params)
|
||||
}
|
||||
return s.handleCompatSessionRequest(method, params)
|
||||
}
|
||||
|
||||
func (s *Server) handleConfiguredUpstreamSessionRequest(upstreamMethod string, params map[string]any) map[string]any {
|
||||
response, err := s.client.Call(upstreamMethod, params)
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
@ -317,6 +344,130 @@ func (s *Server) handleSessionRequest(method string, params map[string]any) map[
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCompatSessionRequest(method string, params map[string]any) map[string]any {
|
||||
if s.sessionRunner == nil {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": "gemini session runner is not configured",
|
||||
}
|
||||
}
|
||||
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
|
||||
if sessionID == "" {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": "sessionId is required",
|
||||
}
|
||||
}
|
||||
state := s.getOrCreateSession(sessionID)
|
||||
if method == "session.start" {
|
||||
state = s.resetSession(sessionID)
|
||||
}
|
||||
taskPrompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
|
||||
taskPrompt = shared.AugmentPromptWithAttachments(taskPrompt, params)
|
||||
if taskPrompt == "" {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": "taskPrompt is required",
|
||||
}
|
||||
}
|
||||
|
||||
model := strings.TrimSpace(shared.StringArg(params, "model", ""))
|
||||
if model == "" {
|
||||
model = state.model
|
||||
}
|
||||
workingDirectory := strings.TrimSpace(shared.StringArg(params, "workingDirectory", ""))
|
||||
if workingDirectory == "" {
|
||||
workingDirectory = state.workingDirectory
|
||||
}
|
||||
|
||||
sessionsHistory := append([]string(nil), state.history...)
|
||||
sessionsHistory = append(sessionsHistory, taskPrompt)
|
||||
composedPrompt := shared.ComposeHistoryPrompt(sessionsHistory)
|
||||
output, err := s.sessionRunner(context.Background(), model, composedPrompt, workingDirectory)
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"error": err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
s.sessionsMu.Lock()
|
||||
state = s.sessions[sessionID]
|
||||
if state == nil {
|
||||
state = &adapterSession{}
|
||||
s.sessions[sessionID] = state
|
||||
}
|
||||
state.history = sessionsHistory
|
||||
state.model = model
|
||||
state.workingDirectory = workingDirectory
|
||||
state.lastOutput = output
|
||||
state.lastUpstreamMethod = "prompt"
|
||||
s.sessionsMu.Unlock()
|
||||
|
||||
result := map[string]any{
|
||||
"success": true,
|
||||
"provider": s.providerID,
|
||||
"mode": "single-agent",
|
||||
"output": output,
|
||||
"sessionId": sessionID,
|
||||
"upstreamMethod": "prompt",
|
||||
}
|
||||
if workingDirectory != "" {
|
||||
result["effectiveWorkingDirectory"] = workingDirectory
|
||||
}
|
||||
if model != "" {
|
||||
result["resolvedModel"] = model
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) getOrCreateSession(sessionID string) *adapterSession {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
state := s.sessions[sessionID]
|
||||
if state == nil {
|
||||
state = &adapterSession{}
|
||||
s.sessions[sessionID] = state
|
||||
}
|
||||
return &adapterSession{
|
||||
history: append([]string(nil), state.history...),
|
||||
model: state.model,
|
||||
workingDirectory: state.workingDirectory,
|
||||
lastOutput: state.lastOutput,
|
||||
lastUpstreamMethod: state.lastUpstreamMethod,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) resetSession(sessionID string) *adapterSession {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
state := &adapterSession{}
|
||||
s.sessions[sessionID] = state
|
||||
return state
|
||||
}
|
||||
|
||||
func (s *Server) closeSession(sessionID string) bool {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return false
|
||||
}
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
if _, ok := s.sessions[sessionID]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.sessions, sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
func parseAllowedOrigins(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
|
||||
@ -2,6 +2,7 @@ package geminiadapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -72,12 +73,14 @@ func TestHandleRPCSessionStartReturnsUpstreamResult(t *testing.T) {
|
||||
},
|
||||
}
|
||||
server := NewServer(stub)
|
||||
server.upstreamMethod = "session.start"
|
||||
|
||||
body, _ := json.Marshal(shared.RPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: 1,
|
||||
Method: "session.start",
|
||||
Params: map[string]any{
|
||||
"sessionId": "s1",
|
||||
"taskPrompt": "hello",
|
||||
},
|
||||
})
|
||||
@ -102,6 +105,114 @@ func TestHandleRPCSessionStartReturnsUpstreamResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSessionStartFallsBackToPromptRunner(t *testing.T) {
|
||||
stub := &stubClient{
|
||||
initResult: initializeResult{ProtocolVersion: 1},
|
||||
}
|
||||
server := NewServer(stub)
|
||||
server.sessionRunner = func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
|
||||
if model != "gemini-2.5-pro" {
|
||||
t.Fatalf("expected model gemini-2.5-pro, got %q", model)
|
||||
}
|
||||
if workingDirectory != "/tmp/demo" {
|
||||
t.Fatalf("expected workingDirectory /tmp/demo, got %q", workingDirectory)
|
||||
}
|
||||
expectedPrompt := "## User Turn 1\nReply with exactly pong"
|
||||
if prompt != expectedPrompt {
|
||||
t.Fatalf("unexpected prompt %q", prompt)
|
||||
}
|
||||
return "pong", nil
|
||||
}
|
||||
|
||||
result := server.handleRequest(shared.RPCRequest{
|
||||
Method: "session.start",
|
||||
Params: map[string]any{
|
||||
"sessionId": "s1",
|
||||
"taskPrompt": "Reply with exactly pong",
|
||||
"model": "gemini-2.5-pro",
|
||||
"workingDirectory": "/tmp/demo",
|
||||
},
|
||||
})
|
||||
if got := result["output"]; got != "pong" {
|
||||
t.Fatalf("expected output pong, got %#v", result)
|
||||
}
|
||||
if got := result["upstreamMethod"]; got != "prompt" {
|
||||
t.Fatalf("expected prompt upstream method, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSessionMessageReusesAdapterLocalHistory(t *testing.T) {
|
||||
stub := &stubClient{
|
||||
initResult: initializeResult{ProtocolVersion: 1},
|
||||
}
|
||||
server := NewServer(stub)
|
||||
callCount := 0
|
||||
server.sessionRunner = func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
expected := "## User Turn 1\nFirst turn"
|
||||
if prompt != expected {
|
||||
t.Fatalf("unexpected first prompt %q", prompt)
|
||||
}
|
||||
return "first-reply", nil
|
||||
}
|
||||
expected := "## User Turn 1\nFirst turn\n\n## User Turn 2\nSecond turn"
|
||||
if prompt != expected {
|
||||
t.Fatalf("unexpected second prompt %q", prompt)
|
||||
}
|
||||
if workingDirectory != "/tmp/demo" {
|
||||
t.Fatalf("expected inherited workingDirectory, got %q", workingDirectory)
|
||||
}
|
||||
if model != "gemini-2.5-flash" {
|
||||
t.Fatalf("expected inherited model, got %q", model)
|
||||
}
|
||||
return "second-reply", nil
|
||||
}
|
||||
|
||||
server.handleRequest(shared.RPCRequest{
|
||||
Method: "session.start",
|
||||
Params: map[string]any{
|
||||
"sessionId": "s1",
|
||||
"taskPrompt": "First turn",
|
||||
"model": "gemini-2.5-flash",
|
||||
"workingDirectory": "/tmp/demo",
|
||||
},
|
||||
})
|
||||
result := server.handleRequest(shared.RPCRequest{
|
||||
Method: "session.message",
|
||||
Params: map[string]any{
|
||||
"sessionId": "s1",
|
||||
"taskPrompt": "Second turn",
|
||||
},
|
||||
})
|
||||
if got := result["output"]; got != "second-reply" {
|
||||
t.Fatalf("expected second reply, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCloseDropsAdapterLocalState(t *testing.T) {
|
||||
server := NewServer(&stubClient{initResult: initializeResult{ProtocolVersion: 1}})
|
||||
server.sessionRunner = func(ctx context.Context, model, prompt, workingDirectory string) (string, error) {
|
||||
return "ok", nil
|
||||
}
|
||||
server.handleRequest(shared.RPCRequest{
|
||||
Method: "session.start",
|
||||
Params: map[string]any{
|
||||
"sessionId": "s1",
|
||||
"taskPrompt": "hello",
|
||||
},
|
||||
})
|
||||
result := server.handleRequest(shared.RPCRequest{
|
||||
Method: "session.close",
|
||||
Params: map[string]any{
|
||||
"sessionId": "s1",
|
||||
},
|
||||
})
|
||||
if got := result["closed"]; got != true {
|
||||
t.Fatalf("expected closed true, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebSocketCapabilities(t *testing.T) {
|
||||
server := NewServer(&stubClient{
|
||||
initResult: initializeResult{ProtocolVersion: 1},
|
||||
|
||||
@ -27,5 +27,11 @@ func (s *StaticTokenAuthService) ValidateAuthorizationHeader(header string) bool
|
||||
}
|
||||
return strings.TrimSpace(header[len("Bearer "):]) != ""
|
||||
}
|
||||
return header == s.expectedToken
|
||||
if header == s.expectedToken {
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(header[len("Bearer "):]) == s.expectedToken
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user