Skip to content

Commit 6157b56

Browse files
committed
fix: add auth token modal for browser mode authentication
When the server requires authentication (--auth-token), the browser client now shows a modal prompting the user to enter the auth token. The token is: - Stored in localStorage for subsequent visits - Can also be passed via URL query parameter (?token=...) - Cleared and re-prompted if authentication fails This replaces the previous server-side injection approach with a cleaner user-driven authentication flow. Change-Id: I5599266df30340bcc5ca016a14a67a5d74c52669 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 8fec7cd commit 6157b56

File tree

2 files changed

+300
-56
lines changed

2 files changed

+300
-56
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { useState, useCallback } from "react";
2+
import { Modal } from "./Modal";
3+
4+
interface AuthTokenModalProps {
5+
isOpen: boolean;
6+
onSubmit: (token: string) => void;
7+
error?: string | null;
8+
}
9+
10+
const AUTH_TOKEN_STORAGE_KEY = "mux:auth-token";
11+
12+
export function getStoredAuthToken(): string | null {
13+
try {
14+
return localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
15+
} catch {
16+
return null;
17+
}
18+
}
19+
20+
export function setStoredAuthToken(token: string): void {
21+
try {
22+
localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token);
23+
} catch {
24+
// Ignore storage errors
25+
}
26+
}
27+
28+
export function clearStoredAuthToken(): void {
29+
try {
30+
localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
31+
} catch {
32+
// Ignore storage errors
33+
}
34+
}
35+
36+
export function AuthTokenModal(props: AuthTokenModalProps) {
37+
const [token, setToken] = useState("");
38+
39+
const { onSubmit } = props;
40+
const handleSubmit = useCallback(
41+
(e: React.FormEvent) => {
42+
e.preventDefault();
43+
if (token.trim()) {
44+
setStoredAuthToken(token.trim());
45+
onSubmit(token.trim());
46+
}
47+
},
48+
[token, onSubmit]
49+
);
50+
51+
return (
52+
<Modal isOpen={props.isOpen} onClose={() => undefined} title="Authentication Required">
53+
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
54+
<p style={{ margin: 0, color: "var(--color-text-secondary)" }}>
55+
This server requires an authentication token. Enter the token provided when the server was
56+
started.
57+
</p>
58+
59+
{props.error && (
60+
<div
61+
style={{
62+
padding: "8px 12px",
63+
borderRadius: 4,
64+
backgroundColor: "var(--color-error-background, rgba(255, 0, 0, 0.1))",
65+
color: "var(--color-error, #ff6b6b)",
66+
fontSize: 13,
67+
}}
68+
>
69+
{props.error}
70+
</div>
71+
)}
72+
73+
<input
74+
type="password"
75+
value={token}
76+
onChange={(e) => setToken(e.target.value)}
77+
placeholder="Enter auth token"
78+
autoFocus
79+
style={{
80+
padding: "10px 12px",
81+
borderRadius: 4,
82+
border: "1px solid var(--color-border)",
83+
backgroundColor: "var(--color-input-background)",
84+
color: "var(--color-text)",
85+
fontSize: 14,
86+
outline: "none",
87+
}}
88+
/>
89+
90+
<button
91+
type="submit"
92+
disabled={!token.trim()}
93+
style={{
94+
padding: "10px 16px",
95+
borderRadius: 4,
96+
border: "none",
97+
backgroundColor: token.trim()
98+
? "var(--color-primary)"
99+
: "var(--color-button-disabled-background)",
100+
color: token.trim() ? "white" : "var(--color-text-disabled)",
101+
fontSize: 14,
102+
fontWeight: 500,
103+
cursor: token.trim() ? "pointer" : "not-allowed",
104+
}}
105+
>
106+
Connect
107+
</button>
108+
</form>
109+
</Modal>
110+
);
111+
}

src/browser/orpc/react.tsx

Lines changed: 189 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { createContext, useContext, useEffect, useState } from "react";
1+
import { createContext, useContext, useEffect, useState, useCallback } from "react";
22
import { createClient } from "@/common/orpc/client";
33
import { RPCLink as WebSocketLink } from "@orpc/client/websocket";
44
import { RPCLink as MessagePortLink } from "@orpc/client/message-port";
55
import type { AppRouter } from "@/node/orpc/router";
66
import type { RouterClient } from "@orpc/server";
7+
import {
8+
AuthTokenModal,
9+
getStoredAuthToken,
10+
clearStoredAuthToken,
11+
} from "@/browser/components/AuthTokenModal";
712

813
type ORPCClient = ReturnType<typeof createClient>;
914

@@ -17,73 +22,201 @@ interface ORPCProviderProps {
1722
client?: ORPCClient;
1823
}
1924

20-
export const ORPCProvider = (props: ORPCProviderProps) => {
21-
const [client, setClient] = useState<ORPCClient | null>(props.client ?? null);
25+
type ConnectionState =
26+
| { status: "connecting" }
27+
| { status: "connected"; client: ORPCClient; cleanup: () => void }
28+
| { status: "auth_required"; error?: string }
29+
| { status: "error"; error: string };
2230

23-
useEffect(() => {
24-
// If client provided externally, use it directly
25-
if (props.client) {
26-
setClient(() => props.client!);
27-
window.__ORPC_CLIENT__ = props.client;
28-
return;
29-
}
30-
31-
let cleanup: () => void;
32-
let newClient: ORPCClient;
33-
34-
// Detect Electron mode by checking if window.api exists (exposed by preload script)
35-
// window.api.platform contains the actual OS platform (darwin/win32/linux), not "electron"
36-
if (window.api) {
37-
// Electron Mode: Use MessageChannel
38-
const { port1: clientPort, port2: serverPort } = new MessageChannel();
39-
40-
// Send port to preload/main
41-
window.postMessage("start-orpc-client", "*", [serverPort]);
42-
43-
const link = new MessagePortLink({
44-
port: clientPort,
45-
});
46-
clientPort.start();
47-
48-
newClient = createClient(link);
49-
cleanup = () => {
50-
clientPort.close();
51-
};
52-
} else {
53-
// Browser Mode: Use HTTP/WebSocket
54-
// Assume server is at same origin or configured via VITE_BACKEND_URL
55-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
56-
// @ts-ignore - import.meta is available in Vite
57-
const API_BASE = import.meta.env.VITE_BACKEND_URL ?? window.location.origin;
58-
const WS_BASE = API_BASE.replace("http://", "ws://").replace("https://", "wss://");
59-
60-
const ws = new WebSocket(`${WS_BASE}/orpc/ws`);
61-
const link = new WebSocketLink({
62-
websocket: ws,
31+
function getApiBase(): string {
32+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
33+
// @ts-ignore - import.meta is available in Vite
34+
return import.meta.env.VITE_BACKEND_URL ?? window.location.origin;
35+
}
36+
37+
function createElectronClient(): { client: ORPCClient; cleanup: () => void } {
38+
const { port1: clientPort, port2: serverPort } = new MessageChannel();
39+
window.postMessage("start-orpc-client", "*", [serverPort]);
40+
41+
const link = new MessagePortLink({ port: clientPort });
42+
clientPort.start();
43+
44+
return {
45+
client: createClient(link),
46+
cleanup: () => clientPort.close(),
47+
};
48+
}
49+
50+
function createBrowserClient(authToken: string | null): {
51+
client: ORPCClient;
52+
cleanup: () => void;
53+
ws: WebSocket;
54+
} {
55+
const API_BASE = getApiBase();
56+
const WS_BASE = API_BASE.replace("http://", "ws://").replace("https://", "wss://");
57+
58+
const wsUrl = authToken
59+
? `${WS_BASE}/orpc/ws?token=${encodeURIComponent(authToken)}`
60+
: `${WS_BASE}/orpc/ws`;
61+
62+
const ws = new WebSocket(wsUrl);
63+
const link = new WebSocketLink({ websocket: ws });
64+
65+
return {
66+
client: createClient(link),
67+
cleanup: () => ws.close(),
68+
ws,
69+
};
70+
}
71+
72+
export const ORPCProvider = (props: ORPCProviderProps) => {
73+
const [state, setState] = useState<ConnectionState>({ status: "connecting" });
74+
const [authToken, setAuthToken] = useState<string | null>(() => {
75+
// Check URL param first, then localStorage
76+
const urlParams = new URLSearchParams(window.location.search);
77+
return urlParams.get("token") ?? getStoredAuthToken();
78+
});
79+
80+
const connect = useCallback(
81+
(token: string | null) => {
82+
// If client provided externally, use it directly
83+
if (props.client) {
84+
window.__ORPC_CLIENT__ = props.client;
85+
setState({ status: "connected", client: props.client, cleanup: () => undefined });
86+
return;
87+
}
88+
89+
// Electron mode - no auth needed
90+
if (window.api) {
91+
const { client, cleanup } = createElectronClient();
92+
window.__ORPC_CLIENT__ = client;
93+
setState({ status: "connected", client, cleanup });
94+
return;
95+
}
96+
97+
// Browser mode - connect with optional auth token
98+
setState({ status: "connecting" });
99+
const { client, cleanup, ws } = createBrowserClient(token);
100+
101+
ws.addEventListener("open", () => {
102+
// Connection successful - test with a ping to verify auth
103+
client.general
104+
.ping("auth-check")
105+
.then(() => {
106+
window.__ORPC_CLIENT__ = client;
107+
setState({ status: "connected", client, cleanup });
108+
})
109+
.catch((err: unknown) => {
110+
cleanup();
111+
const errMsg = err instanceof Error ? err.message : String(err);
112+
const errMsgLower = errMsg.toLowerCase();
113+
// Check for auth-related errors (case-insensitive)
114+
const isAuthError =
115+
errMsgLower.includes("unauthorized") ||
116+
errMsgLower.includes("401") ||
117+
errMsgLower.includes("auth token") ||
118+
errMsgLower.includes("authentication");
119+
if (isAuthError) {
120+
clearStoredAuthToken();
121+
setState({ status: "auth_required", error: token ? "Invalid token" : undefined });
122+
} else {
123+
setState({ status: "error", error: errMsg });
124+
}
125+
});
63126
});
64127

65-
newClient = createClient(link);
66-
cleanup = () => {
67-
ws.close();
68-
};
69-
}
128+
ws.addEventListener("error", () => {
129+
// WebSocket connection failed - might be auth issue or network
130+
cleanup();
131+
// If we had a token and failed, likely auth issue
132+
if (token) {
133+
clearStoredAuthToken();
134+
setState({ status: "auth_required", error: "Connection failed - invalid token?" });
135+
} else {
136+
// Try without token first, server might not require auth
137+
// If server requires auth, the ping will fail with UNAUTHORIZED
138+
setState({ status: "auth_required" });
139+
}
140+
});
70141

71-
// Pass a function to setClient to prevent React from treating the client (which is a callable Proxy)
72-
// as a functional state update. Without this, React calls client(prevState), triggering a request to root /.
73-
setClient(() => newClient);
142+
ws.addEventListener("close", (event) => {
143+
// 1008 = Policy Violation (often used for auth failures)
144+
// 4401 = Custom unauthorized code
145+
if (event.code === 1008 || event.code === 4401) {
146+
cleanup();
147+
clearStoredAuthToken();
148+
setState({ status: "auth_required", error: "Authentication required" });
149+
}
150+
});
151+
},
152+
[props.client]
153+
);
74154

75-
window.__ORPC_CLIENT__ = newClient;
155+
// Initial connection attempt
156+
useEffect(() => {
157+
connect(authToken);
76158

77159
return () => {
78-
cleanup();
160+
if (state.status === "connected") {
161+
state.cleanup();
162+
}
79163
};
80-
}, [props.client]);
164+
// Only run on mount and when authToken changes via handleAuthSubmit
165+
// eslint-disable-next-line react-hooks/exhaustive-deps
166+
}, []);
167+
168+
const handleAuthSubmit = useCallback(
169+
(token: string) => {
170+
setAuthToken(token);
171+
connect(token);
172+
},
173+
[connect]
174+
);
175+
176+
// Show auth modal if auth is required
177+
if (state.status === "auth_required") {
178+
return <AuthTokenModal isOpen={true} onSubmit={handleAuthSubmit} error={state.error ?? null} />;
179+
}
180+
181+
// Show error state
182+
if (state.status === "error") {
183+
return (
184+
<div
185+
style={{
186+
display: "flex",
187+
alignItems: "center",
188+
justifyContent: "center",
189+
height: "100vh",
190+
color: "var(--color-error, #ff6b6b)",
191+
flexDirection: "column",
192+
gap: 16,
193+
}}
194+
>
195+
<div>Failed to connect to server</div>
196+
<div style={{ fontSize: 13, color: "var(--color-text-secondary)" }}>{state.error}</div>
197+
<button
198+
onClick={() => connect(authToken)}
199+
style={{
200+
padding: "8px 16px",
201+
borderRadius: 4,
202+
border: "1px solid var(--color-border)",
203+
background: "var(--color-button-background)",
204+
color: "var(--color-text)",
205+
cursor: "pointer",
206+
}}
207+
>
208+
Retry
209+
</button>
210+
</div>
211+
);
212+
}
81213

82-
if (!client) {
214+
// Show loading while connecting
215+
if (state.status === "connecting") {
83216
return null; // Or a loading spinner
84217
}
85218

86-
return <ORPCContext.Provider value={client}>{props.children}</ORPCContext.Provider>;
219+
return <ORPCContext.Provider value={state.client}>{props.children}</ORPCContext.Provider>;
87220
};
88221

89222
export const useORPC = (): RouterClient<AppRouter> => {

0 commit comments

Comments
 (0)