Skip to content

Commit f58bec2

Browse files
authored
feat: implement gateway token authentication (#17)
1 parent 5c46a11 commit f58bec2

File tree

5 files changed

+272
-43
lines changed

5 files changed

+272
-43
lines changed

__tests__/gateway-context.test.tsx

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import React from 'react'
2+
import { act, render, waitFor } from '@testing-library/react'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { GatewayProvider, useGateway } from '@/context/gateway-context'
5+
6+
const {
7+
mockGetOrCreateDeviceIdentity,
8+
mockBuildDeviceAuthPayload,
9+
mockSignPayload,
10+
mockLoadDeviceToken,
11+
mockStoreDeviceToken,
12+
} = vi.hoisted(() => ({
13+
mockGetOrCreateDeviceIdentity: vi.fn(),
14+
mockBuildDeviceAuthPayload: vi.fn(),
15+
mockSignPayload: vi.fn(),
16+
mockLoadDeviceToken: vi.fn(),
17+
mockStoreDeviceToken: vi.fn(),
18+
}))
19+
20+
vi.mock('@/lib/device-auth', () => ({
21+
getOrCreateDeviceIdentity: mockGetOrCreateDeviceIdentity,
22+
buildDeviceAuthPayload: mockBuildDeviceAuthPayload,
23+
signPayload: mockSignPayload,
24+
loadDeviceToken: mockLoadDeviceToken,
25+
storeDeviceToken: mockStoreDeviceToken,
26+
}))
27+
28+
vi.mock('@/lib/tauri', () => ({
29+
isTauri: () => false,
30+
tauriInvoke: vi.fn(),
31+
}))
32+
33+
vi.mock('@/lib/skill-first-policy', () => ({
34+
buildSkillFirstBlockMessage: vi.fn(() => 'blocked'),
35+
evaluateSkillFirstPolicy: vi.fn(() => ({ blocked: false })),
36+
updateSkillProbeFromMessage: vi.fn(),
37+
}))
38+
39+
class MockWebSocket {
40+
static OPEN = 1
41+
static CONNECTING = 0
42+
static instances: MockWebSocket[] = []
43+
static sentFrames: Array<Record<string, unknown>> = []
44+
45+
url: string
46+
readyState = MockWebSocket.OPEN
47+
onopen: ((ev: Event) => void) | null = null
48+
onclose: ((ev: CloseEvent) => void) | null = null
49+
onerror: (() => void) | null = null
50+
onmessage: ((ev: MessageEvent) => void) | null = null
51+
52+
constructor(url: string) {
53+
this.url = url
54+
MockWebSocket.instances.push(this)
55+
}
56+
57+
send(data: string) {
58+
MockWebSocket.sentFrames.push(JSON.parse(data) as Record<string, unknown>)
59+
}
60+
61+
close() {
62+
this.readyState = 3
63+
}
64+
}
65+
66+
function Harness({ onReady }: { onReady: (api: ReturnType<typeof useGateway>) => void }) {
67+
const api = useGateway()
68+
React.useEffect(() => {
69+
onReady(api)
70+
}, [api, onReady])
71+
return null
72+
}
73+
74+
describe('GatewayProvider connect.challenge auth paths', () => {
75+
beforeEach(() => {
76+
MockWebSocket.instances = []
77+
MockWebSocket.sentFrames = []
78+
localStorage.clear()
79+
80+
vi.clearAllMocks()
81+
vi.stubGlobal('WebSocket', MockWebSocket)
82+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
83+
84+
mockGetOrCreateDeviceIdentity.mockResolvedValue({
85+
deviceId: 'dev-1',
86+
publicKeyBase64Url: 'pub-key',
87+
privateKey: 'priv-key',
88+
})
89+
mockBuildDeviceAuthPayload.mockReturnValue('payload-to-sign')
90+
mockSignPayload.mockResolvedValue('sig-123')
91+
})
92+
93+
it('without stored token sends connect request without device block', async () => {
94+
mockLoadDeviceToken.mockReturnValue(null)
95+
96+
let api: ReturnType<typeof useGateway> | null = null
97+
render(
98+
<GatewayProvider>
99+
<Harness onReady={(ctx) => (api = ctx)} />
100+
</GatewayProvider>,
101+
)
102+
103+
await waitFor(() => expect(api).not.toBeNull())
104+
105+
act(() => {
106+
api!.connect('ws://localhost:18789', 'gateway-secret')
107+
})
108+
109+
const ws = MockWebSocket.instances.at(-1)
110+
expect(ws).toBeTruthy()
111+
112+
act(() => {
113+
ws!.onmessage?.({
114+
data: JSON.stringify({ type: 'event', event: 'connect.challenge', payload: { nonce: 'n1' } }),
115+
} as MessageEvent)
116+
})
117+
118+
await waitFor(() => {
119+
expect(MockWebSocket.sentFrames.length).toBeGreaterThan(0)
120+
})
121+
122+
const req = MockWebSocket.sentFrames[0]
123+
expect(req.method).toBe('connect')
124+
const params = req.params as Record<string, unknown>
125+
const auth = params.auth as Record<string, unknown>
126+
127+
expect(auth.password).toBe('gateway-secret')
128+
expect(auth.token).toBe('gateway-secret')
129+
expect(params.device).toBeUndefined()
130+
131+
expect(mockBuildDeviceAuthPayload).not.toHaveBeenCalled()
132+
expect(mockSignPayload).not.toHaveBeenCalled()
133+
})
134+
135+
it('with stored token sends signed device block and token-preferred auth', async () => {
136+
mockLoadDeviceToken.mockReturnValue('stored-device-token')
137+
138+
let api: ReturnType<typeof useGateway> | null = null
139+
render(
140+
<GatewayProvider>
141+
<Harness onReady={(ctx) => (api = ctx)} />
142+
</GatewayProvider>,
143+
)
144+
145+
await waitFor(() => expect(api).not.toBeNull())
146+
147+
act(() => {
148+
api!.connect('ws://localhost:18789', 'gateway-secret')
149+
})
150+
151+
const ws = MockWebSocket.instances.at(-1)
152+
expect(ws).toBeTruthy()
153+
154+
act(() => {
155+
ws!.onmessage?.({
156+
data: JSON.stringify({ type: 'event', event: 'connect.challenge', payload: { nonce: 'n2' } }),
157+
} as MessageEvent)
158+
})
159+
160+
await waitFor(() => {
161+
expect(MockWebSocket.sentFrames.length).toBeGreaterThan(0)
162+
})
163+
164+
const req = MockWebSocket.sentFrames[0]
165+
expect(req.method).toBe('connect')
166+
const params = req.params as Record<string, unknown>
167+
const auth = params.auth as Record<string, unknown>
168+
const device = params.device as Record<string, unknown>
169+
170+
expect(auth.password).toBe('gateway-secret')
171+
expect(auth.token).toBe('stored-device-token')
172+
173+
expect(device.id).toBe('dev-1')
174+
expect(device.publicKey).toBe('pub-key')
175+
expect(device.signature).toBe('sig-123')
176+
expect(device.nonce).toBe('n2')
177+
expect(typeof device.signedAt).toBe('number')
178+
179+
expect(mockBuildDeviceAuthPayload).toHaveBeenCalledWith(
180+
expect.objectContaining({
181+
deviceId: 'dev-1',
182+
token: 'stored-device-token',
183+
nonce: 'n2',
184+
}),
185+
)
186+
expect(mockSignPayload).toHaveBeenCalledWith('priv-key', 'payload-to-sign')
187+
})
188+
})

__tests__/gateway-protocol.test.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,19 @@ describe('makeRequest', () => {
4747
})
4848

4949
describe('makeConnectRequest', () => {
50-
it('creates a connect request with password', () => {
50+
it('creates a connect request with password and token', () => {
5151
const req = makeConnectRequest('secret123')
5252
expect(req.method).toBe('connect')
53-
expect((req.params as Record<string, unknown>).auth).toEqual({ password: 'secret123' })
53+
const auth = (req.params as Record<string, unknown>).auth as Record<string, unknown>
54+
expect(auth.password).toBe('secret123')
55+
// Token is also sent so gateway auth.mode "token" works
56+
expect(auth.token).toBe('secret123')
5457
})
5558

56-
it('includes stored token when provided', () => {
59+
it('prefers stored device token over password for token field', () => {
5760
const req = makeConnectRequest('pass', undefined, 'saved-token')
5861
const auth = (req.params as Record<string, unknown>).auth as Record<string, unknown>
62+
expect(auth.password).toBe('pass')
5963
expect(auth.token).toBe('saved-token')
6064
})
6165
})
@@ -86,9 +90,7 @@ describe('computeUsageStats', () => {
8690

8791
it('handles snake_case field names', () => {
8892
const stats = computeUsageStats({
89-
records: [
90-
{ usage: { input_tokens: 500, output_tokens: 250 } },
91-
],
93+
records: [{ usage: { input_tokens: 500, output_tokens: 250 } }],
9294
})
9395
expect(stats.totalInputTokens).toBe(500)
9496
expect(stats.totalOutputTokens).toBe(250)
@@ -106,9 +108,7 @@ describe('computeUsageStats', () => {
106108
const stats = computeUsageStats({
107109
records: [],
108110
aggregates: {
109-
daily: [
110-
{ date: '2025-01-01', tokens: 1000, cost: 0.05 },
111-
],
111+
daily: [{ date: '2025-01-01', tokens: 1000, cost: 0.05 }],
112112
},
113113
})
114114
expect(stats.daily).toHaveLength(1)
@@ -190,6 +190,8 @@ describe('formatSchedule', () => {
190190

191191
it('formats cron schedule', () => {
192192
expect(formatSchedule({ kind: 'cron', expr: '0 * * * *' })).toBe('0 * * * *')
193-
expect(formatSchedule({ kind: 'cron', expr: '0 9 * * 1', tz: 'US/Eastern' })).toBe('0 9 * * 1 (US/Eastern)')
193+
expect(formatSchedule({ kind: 'cron', expr: '0 9 * * 1', tz: 'US/Eastern' })).toBe(
194+
'0 9 * * 1 (US/Eastern)',
195+
)
194196
})
195197
})

context/gateway-context.tsx

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -162,29 +162,43 @@ export function GatewayProvider({ children }: { children: React.ReactNode }) {
162162
const signedAt = Date.now()
163163
const existingToken = loadDeviceToken(identity.deviceId, role)
164164

165-
const authPayload = buildDeviceAuthPayload({
166-
deviceId: identity.deviceId,
167-
clientId: 'gateway-client',
168-
clientMode: 'ui',
169-
role,
170-
scopes,
171-
signedAtMs: signedAt,
172-
token: existingToken,
173-
nonce,
174-
})
175-
const signature = await signPayload(identity.privateKey, authPayload)
176-
177-
const connectReq = makeConnectRequest(
178-
password,
179-
{
180-
id: identity.deviceId,
181-
publicKey: identity.publicKeyBase64Url,
182-
signature,
183-
signedAt,
184-
...(nonce ? { nonce } : {}),
185-
},
186-
existingToken ?? undefined,
187-
)
165+
// When we have a stored device token from a prior successful pairing,
166+
// send full device identity with cryptographic signature.
167+
// On first connect (no stored token), skip device auth entirely and
168+
// rely on token/password-only auth. This avoids the signature mismatch
169+
// where the client signs with an empty token field but the server
170+
// reconstructs the payload using auth.token (the gateway token).
171+
// After a successful token-only connect, the gateway issues a
172+
// deviceToken that gets stored for subsequent connections.
173+
let connectReq: ReturnType<typeof makeConnectRequest>
174+
if (existingToken) {
175+
const authPayload = buildDeviceAuthPayload({
176+
deviceId: identity.deviceId,
177+
clientId: 'gateway-client',
178+
clientMode: 'ui',
179+
role,
180+
scopes,
181+
signedAtMs: signedAt,
182+
token: existingToken,
183+
nonce,
184+
})
185+
const signature = await signPayload(identity.privateKey, authPayload)
186+
187+
connectReq = makeConnectRequest(
188+
password,
189+
{
190+
id: identity.deviceId,
191+
publicKey: identity.publicKeyBase64Url,
192+
signature,
193+
signedAt,
194+
...(nonce ? { nonce } : {}),
195+
},
196+
existingToken,
197+
)
198+
} else {
199+
// First connection: token-only, no device block
200+
connectReq = makeConnectRequest(password)
201+
}
188202

189203
ws.send(JSON.stringify(connectReq))
190204

lib/gateway-protocol.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,14 @@ export function makeConnectRequest(
526526
role: "operator",
527527
scopes: ["operator.read", "operator.write", "operator.admin"],
528528
caps: [],
529-
auth: { password, ...(storedToken ? { token: storedToken } : {}) },
529+
auth: {
530+
password,
531+
// Gateway auth.mode can be token or password. The gateway checks
532+
// auth.token when mode is token and auth.password when mode is
533+
// password, so send the credential as both to cover either mode.
534+
// A stored device token (from prior pairing) takes precedence.
535+
token: storedToken || password || undefined,
536+
},
530537
...(device ? { device } : {}),
531538
},
532539
};

src-tauri/src/engine.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,31 +122,49 @@ pub struct GatewayConfig {
122122

123123
#[tauri::command]
124124
pub fn engine_gateway_config() -> Result<GatewayConfig, String> {
125-
// Read ~/.openclaw/openclaw.json for gateway port and password
125+
// Read ~/.openclaw/openclaw.json for gateway port and auth token.
126+
// OpenClaw nests these under a "gateway" key:
127+
// { "gateway": { "port": 18789, "auth": { "mode": "token", "token": "..." } } }
128+
// Fall back to top-level keys for flat configs or legacy layouts.
126129
let home = std::env::var("HOME").unwrap_or_default();
127130
let config_path = std::path::PathBuf::from(&home).join(".openclaw/openclaw.json");
128131

129132
if !config_path.exists() {
130-
return Err("Config not found".to_string());
133+
return Err("Config not found: ~/.openclaw/openclaw.json".to_string());
131134
}
132135

133136
let content = std::fs::read_to_string(&config_path)
134137
.map_err(|e| format!("Failed to read config: {}", e))?;
135138

136-
let config: serde_json::Value =
139+
let root: serde_json::Value =
137140
serde_json::from_str(&content).map_err(|e| format!("Failed to parse config: {}", e))?;
138141

139-
// Extract port (default 18789) and password
140-
let port = config.get("port").and_then(|v| v.as_u64()).unwrap_or(18789);
142+
// Prefer gateway.* sub-object; fall back to top-level for flat configs
143+
let gateway = root.get("gateway").unwrap_or(&root);
141144

142-
let password = config
145+
let port = gateway
146+
.get("port")
147+
.and_then(|v| v.as_u64())
148+
.unwrap_or(18789);
149+
150+
// OpenClaw uses "token" (with auth.mode: "token") as the primary auth method.
151+
// Also check "password" for legacy/alternative auth modes.
152+
let token_from_config = gateway
143153
.get("auth")
144-
.and_then(|a| a.get("password"))
145-
.and_then(|p| p.as_str())
154+
.and_then(|a| a.get("token").or_else(|| a.get("password")))
155+
.and_then(|v| v.as_str())
146156
.unwrap_or("");
147157

158+
// Also check OPENCLAW_GATEWAY_TOKEN env var as final fallback,
159+
// matching how the gateway itself resolves the token at runtime.
160+
let password = if token_from_config.is_empty() {
161+
std::env::var("OPENCLAW_GATEWAY_TOKEN").unwrap_or_default()
162+
} else {
163+
token_from_config.to_string()
164+
};
165+
148166
Ok(GatewayConfig {
149167
url: format!("ws://127.0.0.1:{}", port),
150-
password: password.to_string(),
168+
password,
151169
})
152170
}

0 commit comments

Comments
 (0)