Skip to content

Commit b7beb76

Browse files
committed
Refactor token passing on app urls
1 parent 857587b commit b7beb76

File tree

5 files changed

+112
-56
lines changed

5 files changed

+112
-56
lines changed

site/src/modules/apps/apps.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
import type {
2+
Workspace,
3+
WorkspaceAgent,
4+
WorkspaceApp,
5+
} from "api/typesGenerated";
6+
7+
// This is a magic undocumented string that is replaced
8+
// with a brand-new session token from the backend.
9+
// This only exists for external URLs, and should only
10+
// be used internally, and is highly subject to break.
11+
const SESSION_TOKEN_PLACEHOLDER = "$SESSION_TOKEN";
12+
113
type GetVSCodeHrefParams = {
214
owner: string;
315
workspace: string;
@@ -49,6 +61,73 @@ export const getTerminalHref = ({
4961
}/terminal?${params}`;
5062
};
5163

52-
export const openAppInNewWindow = (name: string, href: string) => {
64+
export const openAppInNewWindow = (href: string) => {
5365
window.open(href, "_blank", "width=900,height=600");
5466
};
67+
68+
type CreateAppHrefParams = {
69+
path: string;
70+
host: string;
71+
workspace: Workspace;
72+
agent: WorkspaceAgent;
73+
token?: string;
74+
};
75+
76+
export const createAppHref = (
77+
app: WorkspaceApp,
78+
{ path, token, workspace, agent, host }: CreateAppHrefParams,
79+
): string => {
80+
if (isExternalApp(app)) {
81+
return needsSessionToken(app)
82+
? app.url.replaceAll(SESSION_TOKEN_PLACEHOLDER, token ?? "")
83+
: app.url;
84+
}
85+
86+
// The backend redirects if the trailing slash isn't included, so we add it
87+
// here to avoid extra roundtrips.
88+
let href = `${path}/@${workspace.owner_name}/${workspace.name}.${
89+
agent.name
90+
}/apps/${encodeURIComponent(app.slug)}/`;
91+
92+
if (app.command) {
93+
// Terminal links are relative. The terminal page knows how
94+
// to select the correct workspace proxy for the websocket
95+
// connection.
96+
href = `/@${workspace.owner_name}/${workspace.name}.${
97+
agent.name
98+
}/terminal?command=${encodeURIComponent(app.command)}`;
99+
}
100+
101+
if (host && app.subdomain && app.subdomain_name) {
102+
const baseUrl = `${window.location.protocol}//${host.replace(/\*/g, app.subdomain_name)}`;
103+
const url = new URL(baseUrl);
104+
url.pathname = "/";
105+
href = url.toString();
106+
}
107+
108+
return href;
109+
};
110+
111+
export const needsSessionToken = (app: WorkspaceApp) => {
112+
if (!isExternalApp(app)) {
113+
return false;
114+
}
115+
116+
// HTTP links should never need the session token, since Cookies
117+
// handle sharing it when you access the Coder Dashboard. We should
118+
// never be forwarding the bare session token to other domains!
119+
const isHttp = app.url.startsWith("http");
120+
const requiresSessionToken = app.url.includes(SESSION_TOKEN_PLACEHOLDER);
121+
return requiresSessionToken && !isHttp;
122+
};
123+
124+
type ExternalWorkspaceApp = WorkspaceApp & {
125+
external: true;
126+
url: string;
127+
};
128+
129+
export const isExternalApp = (
130+
app: WorkspaceApp,
131+
): app is ExternalWorkspaceApp => {
132+
return app.external && app.url !== undefined;
133+
};

site/src/modules/resources/AgentRow.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { PortForwardButton } from "./PortForwardButton";
4040
import { AgentSSHButton } from "./SSHButton/SSHButton";
4141
import { TerminalLink } from "./TerminalLink/TerminalLink";
4242
import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton";
43+
import { apiKey } from "api/queries/users";
4344

4445
export interface AgentRowProps {
4546
agent: WorkspaceAgent;
@@ -163,6 +164,8 @@ export const AgentRow: FC<AgentRowProps> = ({
163164
refetchInterval: 10_000,
164165
});
165166

167+
const { data: apiKeyResponse } = useQuery(apiKey());
168+
166169
return (
167170
<Stack
168171
key={agent.id}
@@ -239,6 +242,7 @@ export const AgentRow: FC<AgentRowProps> = ({
239242
)}
240243
{visibleApps.map((app) => (
241244
<AppLink
245+
token={apiKeyResponse?.key}
242246
key={app.slug}
243247
app={app}
244248
agent={agent}

site/src/modules/resources/AppLink/AppLink.tsx

Lines changed: 26 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import { generateRandomString } from "utils/random";
1717
import { AgentButton } from "../AgentButton";
1818
import { BaseIcon } from "./BaseIcon";
1919
import { ShareIcon } from "./ShareIcon";
20+
import {
21+
createAppHref,
22+
needsSessionToken,
23+
openAppInNewWindow,
24+
} from "modules/apps/apps";
2025

2126
export const DisplayAppNameMap: Record<TypesGen.DisplayApp, string> = {
2227
port_forwarding_helper: "Ports",
@@ -35,57 +40,55 @@ export interface AppLinkProps {
3540
workspace: TypesGen.Workspace;
3641
app: TypesGen.WorkspaceApp;
3742
agent: TypesGen.WorkspaceAgent;
43+
token?: string;
3844
}
3945

40-
export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
46+
export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent, token }) => {
4147
const { proxy } = useProxy();
42-
const preferredPathBase = proxy.preferredPathAppURL;
43-
const appsHost = proxy.preferredWildcardHostname;
44-
const [fetchingSessionToken, setFetchingSessionToken] = useState(false);
48+
const host = proxy.preferredWildcardHostname;
4549
const [iconError, setIconError] = useState(false);
4650
const theme = useTheme();
47-
const username = workspace.owner_name;
48-
const displayName = app.display_name || app.slug;
49-
50-
const href = createAppLinkHref(
51-
window.location.protocol,
52-
preferredPathBase,
53-
appsHost,
54-
app.slug,
55-
username,
56-
workspace,
51+
const displayName = app.display_name ?? app.slug;
52+
const href = createAppHref(app, {
5753
agent,
58-
app,
59-
);
54+
workspace,
55+
token,
56+
path: proxy.preferredPathAppURL,
57+
host: proxy.preferredWildcardHostname,
58+
});
6059

6160
// canClick is ONLY false when it's a subdomain app and the admin hasn't
6261
// enabled wildcard access URL or the session token is being fetched.
6362
//
6463
// To avoid bugs in the healthcheck code locking users out of apps, we no
6564
// longer block access to apps if they are unhealthy/initializing.
6665
let canClick = true;
66+
let primaryTooltip = "";
6767
let icon = !iconError && (
6868
<BaseIcon app={app} onIconPathError={() => setIconError(true)} />
6969
);
7070

71-
let primaryTooltip = "";
7271
if (app.health === "initializing") {
7372
icon = <Spinner loading />;
7473
primaryTooltip = "Initializing...";
7574
}
75+
7676
if (app.health === "unhealthy") {
7777
icon = <ErrorOutlineIcon css={{ color: theme.palette.warning.light }} />;
7878
primaryTooltip = "Unhealthy";
7979
}
80-
if (!appsHost && app.subdomain) {
80+
81+
if (!host && app.subdomain) {
8182
canClick = false;
8283
icon = <ErrorOutlineIcon css={{ color: theme.palette.grey[300] }} />;
8384
primaryTooltip =
8485
"Your admin has not configured subdomain application access";
8586
}
86-
if (fetchingSessionToken) {
87+
88+
if (!token && needsSessionToken(app)) {
8789
canClick = false;
8890
}
91+
8992
if (
9093
agent.lifecycle_state === "starting" &&
9194
agent.startup_script_behavior === "blocking"
@@ -99,32 +102,12 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
99102
<AgentButton asChild>
100103
<a
101104
href={canClick ? href : undefined}
102-
onClick={async (event) => {
105+
onClick={(event) => {
103106
if (!canClick) {
104107
return;
105108
}
106109

107-
event.preventDefault();
108-
109-
// HTTP links should never need the session token, since Cookies
110-
// handle sharing it when you access the Coder Dashboard. We should
111-
// never be forwarding the bare session token to other domains!
112-
const isHttp = app.url?.startsWith("http");
113-
if (app.external && !isHttp) {
114-
// This is a magic undocumented string that is replaced
115-
// with a brand-new session token from the backend.
116-
// This only exists for external URLs, and should only
117-
// be used internally, and is highly subject to break.
118-
const magicTokenString = "$SESSION_TOKEN";
119-
const hasMagicToken = href.indexOf(magicTokenString);
120-
let url = href;
121-
if (hasMagicToken !== -1) {
122-
setFetchingSessionToken(true);
123-
const key = await API.getApiKey();
124-
url = href.replaceAll(magicTokenString, key.key);
125-
setFetchingSessionToken(false);
126-
}
127-
110+
if (app.external) {
128111
// When browser recognizes the protocol and is able to navigate to the app,
129112
// it will blur away, and will stop the timer. Otherwise,
130113
// an error message will be displayed.
@@ -135,22 +118,12 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
135118
window.addEventListener("blur", () => {
136119
clearTimeout(openAppExternallyFailed);
137120
});
138-
139-
window.location.href = url;
140-
return;
141121
}
142122

143123
switch (app.open_in) {
144124
case "slim-window": {
145-
window.open(
146-
href,
147-
Language.appTitle(displayName, generateRandomString(12)),
148-
"width=900,height=600",
149-
);
150-
return;
151-
}
152-
default: {
153-
window.open(href);
125+
event.preventDefault();
126+
openAppInNewWindow(href);
154127
return;
155128
}
156129
}

site/src/modules/resources/TerminalLink/TerminalLink.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
3737
href={href}
3838
onClick={(event: MouseEvent<HTMLElement>) => {
3939
event.preventDefault();
40-
openAppInNewWindow("Terminal", href);
40+
openAppInNewWindow(href);
4141
}}
4242
>
4343
<TerminalIcon />

site/src/pages/WorkspacesPage/WorkspacesTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -696,7 +696,7 @@ const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
696696
href={href}
697697
onClick={(e) => {
698698
e.preventDefault();
699-
openAppInNewWindow("Terminal", href);
699+
openAppInNewWindow(href);
700700
}}
701701
label="Open Terminal"
702702
>

0 commit comments

Comments
 (0)