Skip to content

Commit 08e3f9a

Browse files
committed
wip: bundled wip to verify against CI
1 parent f4b48c4 commit 08e3f9a

16 files changed

Lines changed: 1444 additions & 272 deletions

.env.example

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,16 @@ E2E_ABLY_ABLY_ACCESS_TOKEN=your_control_api_access_token
1010
# Set this to skip E2E tests even when API key is present (for CI or local dev when you don't want to run E2E tests)
1111
# SKIP_E2E_TESTS=true
1212

13-
# CI bypass secret for rate limit bypass in parallel tests
14-
# This should match the CI_BYPASS_SECRET on the terminal server
15-
CI_BYPASS_SECRET=your_ci_bypass_secret
13+
# Terminal server signing secret
14+
# MUST match the live server's SIGNING_SECRET configuration at wss://web-cli.ably.com
15+
# Used to:
16+
# 1. Sign credentials for HMAC authentication (signedConfig + signature)
17+
# 2. Bypass rate limiting in tests (bypassRateLimit flag in signed config)
18+
# Contact platform team for the actual secret
19+
TERMINAL_SERVER_SIGNING_SECRET=your_signing_secret
20+
21+
# Legacy: CI_BYPASS_SECRET (still supported as fallback)
22+
# CI_BYPASS_SECRET=your_ci_bypass_secret
1623

1724
# Terminal server URL for local testing (defaults to production)
1825
# TERMINAL_SERVER_URL=ws://localhost:8080

examples/web-cli/api/sign.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { VercelRequest, VercelResponse } from "@vercel/node";
2+
import { signCredentials, getSigningSecret } from "../lib/sign-handler.js";
3+
4+
/**
5+
* Vercel Serverless Function: Sign credentials for terminal authentication
6+
*
7+
* This endpoint signs API keys with HMAC-SHA256 to create signed configs
8+
* that can be validated by the terminal server.
9+
*
10+
* Environment Variables Required:
11+
* - SIGNING_SECRET or TERMINAL_SERVER_SIGNING_SECRET
12+
*
13+
* Request Body:
14+
* - apiKey: string (required) - Ably API key in format "appId.keyId:secret"
15+
* - bypassRateLimit: boolean (optional) - Set to true for CI/testing
16+
*
17+
* Response:
18+
* - signedConfig: string - JSON-encoded config that was signed
19+
* - signature: string - HMAC-SHA256 hex signature
20+
*/
21+
export default async function handler(
22+
req: VercelRequest,
23+
res: VercelResponse,
24+
) {
25+
// Only accept POST requests
26+
if (req.method !== "POST") {
27+
return res.status(405).json({ error: "Method not allowed" });
28+
}
29+
30+
// Get signing secret from environment
31+
const secret = getSigningSecret();
32+
33+
if (!secret) {
34+
console.error("[/api/sign] Signing secret not configured");
35+
return res.status(500).json({ error: "Signing secret not configured" });
36+
}
37+
38+
const { apiKey, bypassRateLimit } = req.body;
39+
40+
if (!apiKey) {
41+
return res.status(400).json({ error: "apiKey is required" });
42+
}
43+
44+
// Use shared signing logic
45+
const result = signCredentials({ apiKey, bypassRateLimit }, secret);
46+
47+
res.status(200).json(result);
48+
}

examples/web-cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@tailwindcss/vite": "^4.1.5",
2424
"@types/react": "^18.3.20",
2525
"@types/react-dom": "^18.3.5",
26+
"@vercel/node": "^5.5.17",
2627
"@vitejs/plugin-react": "^4.3.4",
2728
"autoprefixer": "^10.4.21",
2829
"eslint": "^9.21.0",

examples/web-cli/src/App.tsx

Lines changed: 120 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -32,80 +32,86 @@ const getCIAuthToken = (): string | undefined => {
3232
return (window as CliWindow).__ABLY_CLI_CI_AUTH_TOKEN__;
3333
};
3434

35-
// Get credentials from various sources
35+
// Get signed credentials from various sources
3636
const getInitialCredentials = () => {
3737
const urlParams = new URLSearchParams(window.location.search);
38-
38+
3939
// Get the domain from the WebSocket URL for scoping
4040
const wsUrl = getWebSocketUrl();
4141
const wsDomain = new URL(wsUrl).host;
42-
42+
4343
// Check if we should clear credentials (for testing)
4444
if (urlParams.get('clearCredentials') === 'true') {
45+
// Clear new signed format
46+
localStorage.removeItem(`ably.web-cli.signedConfig.${wsDomain}`);
47+
localStorage.removeItem(`ably.web-cli.signature.${wsDomain}`);
48+
localStorage.removeItem(`ably.web-cli.rememberCredentials.${wsDomain}`);
49+
sessionStorage.removeItem(`ably.web-cli.signedConfig.${wsDomain}`);
50+
sessionStorage.removeItem(`ably.web-cli.signature.${wsDomain}`);
51+
// Also clear old format (migration)
4552
localStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`);
4653
localStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`);
47-
localStorage.removeItem(`ably.web-cli.rememberCredentials.${wsDomain}`);
48-
// Also clear from sessionStorage
4954
sessionStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`);
5055
sessionStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`);
5156
// Remove the clearCredentials param from URL
5257
const cleanUrl = new URL(window.location.href);
5358
cleanUrl.searchParams.delete('clearCredentials');
5459
window.history.replaceState(null, '', cleanUrl.toString());
5560
}
56-
57-
// Check localStorage for persisted credentials (if user chose to remember)
61+
62+
// Check query parameters FIRST (for test environment signed configs)
63+
const qsSignedConfig = urlParams.get('signedConfig');
64+
const qsSignature = urlParams.get('signature');
65+
66+
if (qsSignedConfig && qsSignature) {
67+
console.log('[App] Using signed config from query parameters');
68+
return {
69+
signedConfig: qsSignedConfig,
70+
signature: qsSignature,
71+
source: 'query' as const
72+
};
73+
}
74+
75+
// Check localStorage for persisted signed credentials (if user chose to remember)
5876
const rememberCredentials = localStorage.getItem(`ably.web-cli.rememberCredentials.${wsDomain}`) === 'true';
5977
if (rememberCredentials) {
60-
const storedApiKey = localStorage.getItem(`ably.web-cli.apiKey.${wsDomain}`);
61-
const storedAccessToken = localStorage.getItem(`ably.web-cli.accessToken.${wsDomain}`);
62-
if (storedApiKey) {
63-
return {
64-
apiKey: storedApiKey,
65-
accessToken: storedAccessToken || undefined,
78+
const storedSignedConfig = localStorage.getItem(`ably.web-cli.signedConfig.${wsDomain}`);
79+
const storedSignature = localStorage.getItem(`ably.web-cli.signature.${wsDomain}`);
80+
if (storedSignedConfig && storedSignature) {
81+
console.log('[App] Using signed config from localStorage');
82+
return {
83+
signedConfig: storedSignedConfig,
84+
signature: storedSignature,
6685
source: 'localStorage' as const
6786
};
6887
}
6988
}
70-
71-
// Check sessionStorage for session-only credentials
72-
const sessionApiKey = sessionStorage.getItem(`ably.web-cli.apiKey.${wsDomain}`);
73-
const sessionAccessToken = sessionStorage.getItem(`ably.web-cli.accessToken.${wsDomain}`);
74-
if (sessionApiKey) {
75-
return {
76-
apiKey: sessionApiKey,
77-
accessToken: sessionAccessToken || undefined,
89+
90+
// Check sessionStorage for session-only signed credentials
91+
const sessionSignedConfig = sessionStorage.getItem(`ably.web-cli.signedConfig.${wsDomain}`);
92+
const sessionSignature = sessionStorage.getItem(`ably.web-cli.signature.${wsDomain}`);
93+
if (sessionSignedConfig && sessionSignature) {
94+
console.log('[App] Using signed config from sessionStorage');
95+
return {
96+
signedConfig: sessionSignedConfig,
97+
signature: sessionSignature,
7898
source: 'session' as const
7999
};
80100
}
81101

82-
// Then check query parameters (only in non-production environments)
83-
const qsApiKey = urlParams.get('apikey') || urlParams.get('apiKey');
84-
const qsAccessToken = urlParams.get('accessToken') || urlParams.get('accesstoken');
85-
86-
// Security check: only allow query param auth in development/test environments
87-
const isProduction = import.meta.env.PROD && !window.location.hostname.includes('localhost') && !window.location.hostname.includes('127.0.0.1');
88-
89-
if (qsApiKey) {
90-
if (isProduction) {
91-
console.error('Security Warning: API keys in query parameters are not allowed in production environments.');
92-
// Clear the sensitive query parameters from the URL
93-
const cleanUrl = new URL(window.location.href);
94-
cleanUrl.searchParams.delete('apikey');
95-
cleanUrl.searchParams.delete('apiKey');
96-
cleanUrl.searchParams.delete('accessToken');
97-
cleanUrl.searchParams.delete('accesstoken');
98-
window.history.replaceState(null, '', cleanUrl.toString());
99-
} else {
100-
return {
101-
apiKey: qsApiKey,
102-
accessToken: qsAccessToken || undefined,
103-
source: 'query' as const
104-
};
105-
}
102+
// Check for old format credentials (migration)
103+
const oldApiKey = localStorage.getItem(`ably.web-cli.apiKey.${wsDomain}`) ||
104+
sessionStorage.getItem(`ably.web-cli.apiKey.${wsDomain}`);
105+
if (oldApiKey) {
106+
console.warn('[App] Found old credential format. Please re-authenticate with signed credentials.');
107+
// Clear old format
108+
localStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`);
109+
localStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`);
110+
sessionStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`);
111+
sessionStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`);
106112
}
107113

108-
return { apiKey: undefined, accessToken: undefined, source: 'none' as const };
114+
return { signedConfig: undefined, signature: undefined, source: 'none' as const };
109115
};
110116

111117
function App() {
@@ -117,11 +123,11 @@ function App() {
117123
const [displayMode, setDisplayMode] = useState<"fullscreen" | "drawer">(initialMode);
118124
const [showAuthSettings, setShowAuthSettings] = useState(false);
119125

120-
// Initialize credentials
126+
// Initialize signed credentials
121127
const initialCreds = getInitialCredentials();
122-
const [apiKey, setApiKey] = useState<string | undefined>(initialCreds.apiKey);
123-
const [accessToken, setAccessToken] = useState<string | undefined>(initialCreds.accessToken);
124-
const [isAuthenticated, setIsAuthenticated] = useState(Boolean(initialCreds.apiKey && initialCreds.apiKey.trim()));
128+
const [signedConfig, setSignedConfig] = useState<string | undefined>(initialCreds.signedConfig);
129+
const [signature, setSignature] = useState<string | undefined>(initialCreds.signature);
130+
const [isAuthenticated, setIsAuthenticated] = useState(Boolean(initialCreds.signedConfig && initialCreds.signature));
125131
const [authSource, setAuthSource] = useState(initialCreds.source);
126132
// Get the URL and domain early for use in state initialization
127133
const currentWebsocketUrl = getWebSocketUrl();
@@ -144,64 +150,79 @@ function App() {
144150
}, []);
145151

146152
// Handle authentication
147-
const handleAuthenticate = useCallback((newApiKey: string, newAccessToken: string, remember?: boolean) => {
148-
// Clear any existing session data when credentials change (domain-scoped)
149-
sessionStorage.removeItem(`ably.cli.sessionId.${wsDomain}`);
150-
sessionStorage.removeItem(`ably.cli.secondarySessionId.${wsDomain}`);
151-
sessionStorage.removeItem(`ably.cli.isSplit.${wsDomain}`);
152-
153-
setApiKey(newApiKey);
154-
setAccessToken(newAccessToken);
155-
setIsAuthenticated(true);
156-
setShowAuthSettings(false);
157-
158-
// Determine if we should remember based on parameter or current state
159-
const shouldRemember = remember !== undefined ? remember : rememberCredentials;
160-
161-
if (shouldRemember) {
162-
// Store in localStorage for persistence (domain-scoped)
163-
localStorage.setItem(`ably.web-cli.apiKey.${wsDomain}`, newApiKey);
164-
localStorage.setItem(`ably.web-cli.rememberCredentials.${wsDomain}`, 'true');
165-
if (newAccessToken) {
166-
localStorage.setItem(`ably.web-cli.accessToken.${wsDomain}`, newAccessToken);
167-
} else {
168-
localStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`);
153+
const handleAuthenticate = useCallback(async (newApiKey: string, remember?: boolean) => {
154+
try {
155+
// Call /api/sign endpoint to get signed config
156+
const response = await fetch('/api/sign', {
157+
method: 'POST',
158+
headers: { 'Content-Type': 'application/json' },
159+
body: JSON.stringify({
160+
apiKey: newApiKey,
161+
bypassRateLimit: false
162+
})
163+
});
164+
165+
if (!response.ok) {
166+
const error = await response.json();
167+
console.error('[App] Failed to sign credentials:', error);
168+
throw new Error(error.error || 'Failed to sign credentials');
169169
}
170-
setAuthSource('localStorage');
171-
} else {
172-
// Store only in sessionStorage (domain-scoped)
173-
sessionStorage.setItem(`ably.web-cli.apiKey.${wsDomain}`, newApiKey);
174-
if (newAccessToken) {
175-
sessionStorage.setItem(`ably.web-cli.accessToken.${wsDomain}`, newAccessToken);
170+
171+
const { signedConfig: newSignedConfig, signature: newSignature } = await response.json();
172+
173+
// Clear any existing session data when credentials change (domain-scoped)
174+
sessionStorage.removeItem(`ably.cli.sessionId.${wsDomain}`);
175+
sessionStorage.removeItem(`ably.cli.secondarySessionId.${wsDomain}`);
176+
sessionStorage.removeItem(`ably.cli.isSplit.${wsDomain}`);
177+
178+
setSignedConfig(newSignedConfig);
179+
setSignature(newSignature);
180+
setIsAuthenticated(true);
181+
setShowAuthSettings(false);
182+
183+
// Determine if we should remember based on parameter or current state
184+
const shouldRemember = remember !== undefined ? remember : rememberCredentials;
185+
186+
if (shouldRemember) {
187+
// Store in localStorage for persistence (domain-scoped)
188+
localStorage.setItem(`ably.web-cli.signedConfig.${wsDomain}`, newSignedConfig);
189+
localStorage.setItem(`ably.web-cli.signature.${wsDomain}`, newSignature);
190+
localStorage.setItem(`ably.web-cli.rememberCredentials.${wsDomain}`, 'true');
191+
setAuthSource('localStorage');
176192
} else {
177-
sessionStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`);
193+
// Store only in sessionStorage (domain-scoped)
194+
sessionStorage.setItem(`ably.web-cli.signedConfig.${wsDomain}`, newSignedConfig);
195+
sessionStorage.setItem(`ably.web-cli.signature.${wsDomain}`, newSignature);
196+
// Clear from localStorage if it was there (domain-scoped)
197+
localStorage.removeItem(`ably.web-cli.signedConfig.${wsDomain}`);
198+
localStorage.removeItem(`ably.web-cli.signature.${wsDomain}`);
199+
localStorage.removeItem(`ably.web-cli.rememberCredentials.${wsDomain}`);
200+
setAuthSource('session');
178201
}
179-
// Clear from localStorage if it was there (domain-scoped)
180-
localStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`);
181-
localStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`);
182-
localStorage.removeItem(`ably.web-cli.rememberCredentials.${wsDomain}`);
183-
setAuthSource('session');
202+
203+
setRememberCredentials(shouldRemember);
204+
} catch (error) {
205+
console.error('[App] Authentication error:', error);
206+
throw error;
184207
}
185-
186-
setRememberCredentials(shouldRemember);
187208
}, [rememberCredentials, wsDomain]);
188209

189210
// Handle auth settings save
190-
const handleAuthSettingsSave = useCallback((newApiKey: string, newAccessToken: string, remember: boolean) => {
211+
const handleAuthSettingsSave = useCallback(async (newApiKey: string, remember: boolean) => {
191212
if (newApiKey) {
192-
handleAuthenticate(newApiKey, newAccessToken, remember);
213+
await handleAuthenticate(newApiKey, remember);
193214
} else {
194215
// Clear all credentials - go back to auth screen (domain-scoped)
195216
sessionStorage.removeItem(`ably.cli.sessionId.${wsDomain}`);
196217
sessionStorage.removeItem(`ably.cli.secondarySessionId.${wsDomain}`);
197218
sessionStorage.removeItem(`ably.cli.isSplit.${wsDomain}`);
198-
sessionStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`);
199-
sessionStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`);
200-
localStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`);
201-
localStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`);
219+
sessionStorage.removeItem(`ably.web-cli.signedConfig.${wsDomain}`);
220+
sessionStorage.removeItem(`ably.web-cli.signature.${wsDomain}`);
221+
localStorage.removeItem(`ably.web-cli.signedConfig.${wsDomain}`);
222+
localStorage.removeItem(`ably.web-cli.signature.${wsDomain}`);
202223
localStorage.removeItem(`ably.web-cli.rememberCredentials.${wsDomain}`);
203-
setApiKey(undefined);
204-
setAccessToken(undefined);
224+
setSignedConfig(undefined);
225+
setSignature(undefined);
205226
setIsAuthenticated(false);
206227
setShowAuthSettings(false);
207228
setRememberCredentials(false);
@@ -220,11 +241,11 @@ function App() {
220241
// Prepare the terminal component instance to pass it down
221242
const termRef = useRef<AblyCliTerminalHandle>(null);
222243
const TerminalInstance = useCallback(() => (
223-
isAuthenticated && apiKey && apiKey.trim() ? (
244+
isAuthenticated && signedConfig && signature ? (
224245
<AblyCliTerminal
225246
ref={termRef}
226-
ablyAccessToken={accessToken}
227-
ablyApiKey={apiKey}
247+
signedConfig={signedConfig}
248+
signature={signature}
228249
onConnectionStatusChange={handleConnectionChange}
229250
onSessionEnd={handleSessionEnd}
230251
onSessionId={handleSessionId}
@@ -233,10 +254,9 @@ function App() {
233254
enableSplitScreen={true}
234255
showSplitControl={true}
235256
maxReconnectAttempts={5} /* In the example, limit reconnection attempts for testing, default is 15 */
236-
ciAuthToken={getCIAuthToken()}
237257
/>
238258
) : null
239-
), [isAuthenticated, apiKey, accessToken, handleConnectionChange, handleSessionEnd, handleSessionId, currentWebsocketUrl]);
259+
), [isAuthenticated, signedConfig, signature, handleConnectionChange, handleSessionEnd, handleSessionId, currentWebsocketUrl]);
240260

241261
// Show auth screen if not authenticated
242262
if (!isAuthenticated) {
@@ -323,8 +343,7 @@ function App() {
323343
isOpen={showAuthSettings}
324344
onClose={() => setShowAuthSettings(false)}
325345
onSave={handleAuthSettingsSave}
326-
currentApiKey={apiKey}
327-
currentAccessToken={accessToken}
346+
currentSignedConfig={signedConfig}
328347
rememberCredentials={rememberCredentials}
329348
/>
330349
</div>

0 commit comments

Comments
 (0)