History

Refactor WorkOS integration: streamline redirect URI resolution and update callback handlers

Rhys Sullivan committed 8 months ago

Commit 3778d801 parent 7c8b00110 files · +354 −30Browse files

Unified · Split

10 files
  1. executor/apps/web/src/lib/http/request-origin.ts +19 −2
  2. executor/apps/web/src/lib/workos-callback.ts +123 −0
  3. executor/apps/web/src/lib/workos-debug.ts +39 −0
  4. executor/apps/web/src/lib/workos-redirect.test.ts +82 −0
  5. executor/apps/web/src/lib/workos-redirect.ts +48 −0
  6. executor/apps/web/src/routes/api/auth/callback.tsx +2 −4
  7. executor/apps/web/src/routes/callback.tsx +2 −4
  8. executor/apps/web/src/routes/sign-in.tsx +16 −2
  9. executor/apps/web/src/routes/sign-up.tsx +16 −2
  10. executor/apps/web/src/start.ts +7 −16
executor/apps/web/src/lib/http/request-origin.ts+19−2
@@ -6,14 +6,31 @@ function firstHeaderValue(value: string | null): string { · 5 unmodified lines
66 return value.split(",")[0]?.trim() ?? "";
77 }
88
9+function normalizeOrigin(candidateHost: string, candidateProto: string): string | undefined {
10+ const parsed = candidateHost && candidateProto
11+ ? { value: `${candidateProto}://${candidateHost}` }
12+ : null;
13+
14+ if (!parsed) {
15+ return undefined;
16+ }
17+
18+ try {
19+ return new URL(parsed.value).origin;
20+ } catch {
21+ return undefined;
22+ }
23+}
24+
925 export function externalOriginFromRequest(request: Request): string {
1026 const requestUrl = new URL(request.url);
1127 const host = firstHeaderValue(request.headers.get("x-forwarded-host") ?? request.headers.get("host"));
1228 const proto = firstHeaderValue(request.headers.get("x-forwarded-proto"))
1329 || requestUrl.protocol.replace(":", "");
1430
15− if (host && proto) {
16− return `${proto}://${host}`;
31+ const normalized = normalizeOrigin(host, proto);
32+ if (normalized) {
33+ return normalized;
1734 }
1835
1936 return requestUrl.origin;
executor/apps/web/src/lib/workos-callback.ts+123−0
@@ -0,0 +1,123 @@
1+import { handleCallbackRoute } from "@workos/authkit-tanstack-react-start";
2+
3+import { redirectResponse } from "@/lib/http/response";
4+import { isWorkosDebugEnabled, logWorkosAuth, redactAuthCode } from "@/lib/workos-debug";
5+
6+const callbackHandler = handleCallbackRoute({
7+ returnPathname: "/",
8+});
9+
10+const WORKOS_CALLBACK_REPLAY_MS = 10 * 60 * 1000;
11+const CALLBACK_REPLAY_PRUNE_INTERVAL_MS = 30 * 1000;
12+const callbackCodeReplay = new Map<string, number>();
13+let nextReplayPruneAt = 0;
14+
15+type WorkOSCallbackInput = Parameters<typeof callbackHandler>[0];
16+
17+function pruneCallbackReplays(now = Date.now()): void {
18+ if (now < nextReplayPruneAt) {
19+ return;
20+ }
21+
22+ callbackCodeReplay.forEach((expiresAt, code) => {
23+ if (expiresAt <= now) {
24+ callbackCodeReplay.delete(code);
25+ }
26+ });
27+
28+ nextReplayPruneAt = now + CALLBACK_REPLAY_PRUNE_INTERVAL_MS;
29+}
30+
31+function isReplayedCode(code: string, now = Date.now()): boolean {
32+ pruneCallbackReplays(now);
33+ const expiresAt = callbackCodeReplay.get(code);
34+
35+ if (!expiresAt || expiresAt <= now) {
36+ if (expiresAt) {
37+ callbackCodeReplay.delete(code);
38+ }
39+
40+ return false;
41+ }
42+
43+ return true;
44+}
45+
46+function markCodeAsReplayed(code: string, now = Date.now()): void {
47+ callbackCodeReplay.set(code, now + WORKOS_CALLBACK_REPLAY_MS);
48+}
49+
50+export async function handleWorkOSCallback(context: WorkOSCallbackInput): Promise<Response> {
51+ const request = (context as { request: Request }).request;
52+ const requestUrl = new URL(request.url);
53+ const code = requestUrl.searchParams.get("code")?.trim() ?? "";
54+ const requestId = request.headers.get("x-request-id");
55+
56+ if (isWorkosDebugEnabled()) {
57+ logWorkosAuth("callback.request", {
58+ requestId,
59+ code: redactAuthCode(code),
60+ path: requestUrl.pathname,
61+ host: requestUrl.host,
62+ forwardedHost: request.headers.get("x-forwarded-host"),
63+ forwardedProto: request.headers.get("x-forwarded-proto"),
64+ state: requestUrl.searchParams.get("state") ? "present" : "missing",
65+ authorizationSessionId: requestUrl.searchParams.get("authorization_session_id")
66+ ? "present"
67+ : "missing",
68+ error: requestUrl.searchParams.get("error")
69+ ? requestUrl.searchParams.get("error")
70+ : undefined,
71+ errorDescription: requestUrl.searchParams.get("error_description")
72+ ? "present"
73+ : "missing",
74+ });
75+ }
76+
77+ if (code && isReplayedCode(code)) {
78+ if (isWorkosDebugEnabled()) {
79+ logWorkosAuth("callback.replay-blocked", {
80+ requestId,
81+ code: redactAuthCode(code),
82+ });
83+ }
84+
85+ return redirectResponse("/", 302);
86+ }
87+
88+ const response = await callbackHandler(context);
89+
90+ if (isWorkosDebugEnabled()) {
91+ logWorkosAuth("callback.result", {
92+ requestId,
93+ code: redactAuthCode(code),
94+ status: response.status,
95+ statusText: response.statusText,
96+ });
97+ }
98+
99+ if (code && response.status >= 300 && response.status < 400) {
100+ markCodeAsReplayed(code);
101+
102+ if (isWorkosDebugEnabled()) {
103+ logWorkosAuth("callback.code-marked-used", {
104+ requestId,
105+ code: redactAuthCode(code),
106+ status: response.status,
107+ });
108+ }
109+
110+ return response;
111+ }
112+
113+ if (isWorkosDebugEnabled()) {
114+ logWorkosAuth("callback.complete", {
115+ requestId,
116+ code: redactAuthCode(code),
117+ status: response.status,
118+ statusText: response.statusText,
119+ });
120+ }
121+
122+ return response;
123+}
executor/apps/web/src/lib/workos-debug.ts+39−0
@@ -0,0 +1,39 @@
1+function trim(value: string | null | undefined): string | undefined {
2+ const candidate = value?.trim();
3+ return candidate && candidate.length > 0 ? candidate : undefined;
4+}
5+
6+const WORKOS_DEBUG_VALUES = new Set(["1", "true", "yes", "on"]);
7+
8+function isWorkosDebugEnabledInternal(): boolean {
9+ if (process.env.NODE_ENV !== "production") {
10+ return true;
11+ }
12+
13+ const value = trim(process.env.WORKOS_DEBUG_AUTH);
14+ return value ? WORKOS_DEBUG_VALUES.has(value.toLowerCase()) : false;
15+}
16+
17+export function isWorkosDebugEnabled(): boolean {
18+ return isWorkosDebugEnabledInternal();
19+}
20+
21+export function redactAuthCode(value: string | null | undefined): string {
22+ const code = trim(value);
23+ if (!code) {
24+ return "missing";
25+ }
26+ if (code.length <= 10) {
27+ return "*".repeat(code.length);
28+ }
29+
30+ return `${code.slice(0, 4)}...${code.slice(-4)}`;
31+}
32+
33+export function logWorkosAuth(message: string, details: Record<string, unknown>): void {
34+ if (!isWorkosDebugEnabledInternal()) {
35+ return;
36+ }
37+
38+ console.info(`[workos] ${message}: ${JSON.stringify(details)}`);
39+}
executor/apps/web/src/lib/workos-redirect.test.ts+82−0
@@ -0,0 +1,82 @@
1+import { expect, test } from "bun:test";
2+
3+import { resolveWorkosRedirectUri } from "./workos-redirect";
4+
5+const WORKOS_ENV_KEYS = [
6+ "WORKOS_REDIRECT_URI",
7+ "NEXT_PUBLIC_WORKOS_REDIRECT_URI",
8+ "EXECUTOR_PUBLIC_ORIGIN",
9+ "VERCEL_PROJECT_PRODUCTION_URL",
10+ "VERCEL_URL",
11+ "NEXT_PUBLIC_VERCEL_URL",
12+ "NODE_ENV",
13+] as const;
14+
15+const originalWorkosEnv: Record<(typeof WORKOS_ENV_KEYS)[number], string | undefined> =
16+ WORKOS_ENV_KEYS.reduce((acc, key) => {
17+ acc[key] = process.env[key];
18+ return acc;
19+ }, {} as Record<(typeof WORKOS_ENV_KEYS)[number], string | undefined>);
20+
21+function resetWorkosEnv() {
22+ for (const key of WORKOS_ENV_KEYS) {
23+ if (originalWorkosEnv[key] === undefined) {
24+ delete process.env[key];
25+ } else {
26+ process.env[key] = originalWorkosEnv[key];
27+ }
28+ }
29+}
30+
31+test("uses explicit WORKOS_REDIRECT_URI when set", () => {
32+ resetWorkosEnv();
33+
34+ process.env.WORKOS_REDIRECT_URI = "https://app.example.com/callback";
35+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI = "https://public.example.com/callback";
36+
37+ const request = new Request("https://request.example.com/sign-in");
38+ expect(resolveWorkosRedirectUri(request)).toBe("https://app.example.com/callback");
39+});
40+
41+test("falls back to NEXT_PUBLIC_WORKOS_REDIRECT_URI when server var is absent", () => {
42+ resetWorkosEnv();
43+
44+ delete process.env.WORKOS_REDIRECT_URI;
45+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI = "https://public.example.com/callback";
46+
47+ const request = new Request("https://request.example.com/sign-in");
48+ expect(resolveWorkosRedirectUri(request)).toBe("https://public.example.com/callback");
49+});
50+
51+test("derives from forwarded request origin when not explicitly configured", () => {
52+ resetWorkosEnv();
53+
54+ delete process.env.WORKOS_REDIRECT_URI;
55+ delete process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI;
56+
57+ const request = new Request("https://request.example.com/sign-in", {
58+ headers: {
59+ "x-forwarded-host": "executor.sh",
60+ "x-forwarded-proto": "https",
61+ },
62+ });
63+
64+ expect(resolveWorkosRedirectUri(request)).toBe("https://executor.sh/callback");
65+});
66+
67+test("falls back to localhost when host cannot be derived locally", () => {
68+ resetWorkosEnv();
69+
70+ delete process.env.WORKOS_REDIRECT_URI;
71+ delete process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI;
72+ delete process.env.EXECUTOR_PUBLIC_ORIGIN;
73+ delete process.env.VERCEL_PROJECT_PRODUCTION_URL;
74+ delete process.env.VERCEL_URL;
75+ delete process.env.NEXT_PUBLIC_VERCEL_URL;
76+ process.env.NODE_ENV = "development";
77+
78+ const request = new Request("http://localhost:1234/sign-in");
79+ expect(resolveWorkosRedirectUri(request)).toBe("http://localhost:1234/callback");
80+});
81+
82+resetWorkosEnv();
executor/apps/web/src/lib/workos-redirect.ts+48−0
@@ -0,0 +1,48 @@
1+import { externalOriginFromRequest } from "./http/request-origin";
2+
3+const WORKOS_REDIRECT_PATH = "/callback";
4+
5+function trim(value: string | undefined): string | undefined {
6+ const candidate = value?.trim();
7+ return candidate && candidate.length > 0 ? candidate : undefined;
8+}
9+
10+function fallbackWorkosOriginFromServer(): string | undefined {
11+ if (trim(process.env.EXECUTOR_PUBLIC_ORIGIN)) {
12+ return trim(process.env.EXECUTOR_PUBLIC_ORIGIN);
13+ }
14+
15+ const vercelHost = trim(process.env.VERCEL_PROJECT_PRODUCTION_URL)
16+ ?? trim(process.env.VERCEL_URL)
17+ ?? trim(process.env.NEXT_PUBLIC_VERCEL_URL);
18+
19+ if (vercelHost) {
20+ return vercelHost.startsWith("http://") || vercelHost.startsWith("https://")
21+ ? vercelHost
22+ : `https://${vercelHost}`;
23+ }
24+
25+ if (trim(process.env.NODE_ENV) !== "production") {
26+ return `http://localhost:${trim(process.env.PORT) ?? "4312"}`;
27+ }
28+
29+ return undefined;
30+}
31+
32+export function resolveWorkosRedirectUri(request?: Request): string | undefined {
33+ const explicitRedirect = trim(process.env.WORKOS_REDIRECT_URI);
34+ if (explicitRedirect) {
35+ return explicitRedirect;
36+ }
37+
38+ const publicRedirect = trim(process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI);
39+ if (publicRedirect) {
40+ return publicRedirect;
41+ }
42+
43+ const origin = request
44+ ? externalOriginFromRequest(request)
45+ : fallbackWorkosOriginFromServer();
46+
47+ return origin ? `${origin}${WORKOS_REDIRECT_PATH}` : undefined;
48+}
executor/apps/web/src/routes/api/auth/callback.tsx+2−4
@@ -1,12 +1,10 @@
11 import { createFileRoute } from "@tanstack/react-router";
2−import { handleCallbackRoute } from "@workos/authkit-tanstack-react-start";
2+import { handleWorkOSCallback } from "@/lib/workos-callback";
33
44 export const Route = createFileRoute("/api/auth/callback")({
55 server: {
66 handlers: {
7− GET: handleCallbackRoute({
8− returnPathname: "/",
9− }),
7+ GET: handleWorkOSCallback,
108 },
119 },
1210 });
executor/apps/web/src/routes/callback.tsx+2−4
@@ -1,12 +1,10 @@
11 import { createFileRoute } from "@tanstack/react-router";
2−import { handleCallbackRoute } from "@workos/authkit-tanstack-react-start";
2+import { handleWorkOSCallback } from "@/lib/workos-callback";
33
44 export const Route = createFileRoute("/callback")({
55 server: {
66 handlers: {
7− GET: handleCallbackRoute({
8− returnPathname: "/",
9− }),
7+ GET: handleWorkOSCallback,
108 },
119 },
1210 });
executor/apps/web/src/routes/sign-in.tsx+16−2
@@ -3,9 +3,10 @@ import { ConvexHttpClient } from "convex/browser"; · 2 unmodified lines
33 import { getAuthkit } from "@workos/authkit-tanstack-react-start";
44 import { api } from "@executor/database/convex/_generated/api";
55 import type { Id } from "@executor/database/convex/_generated/dataModel";
6−import { externalOriginFromRequest } from "@/lib/http/request-origin";
76 import { readOptionalQueryParam, readOptionalReferrerQueryParam } from "@/lib/http/query-params";
87 import { redirectResponse } from "@/lib/http/response";
8+import { isWorkosDebugEnabled, logWorkosAuth } from "@/lib/workos-debug";
9+import { resolveWorkosRedirectUri } from "@/lib/workos-redirect";
910
1011 const AUTHKIT_PASSTHROUGH_QUERY_KEYS = [
1112 "authorization_session_id",
@@ -81,7 +82,20 @@ async function handleSignIn(request: Request): Promise<Response> { · 69 unmodified lines
8182 const oauthClientId =
8283 readOptionalQueryParam(requestUrl, ["client_id"])
8384 ?? readOptionalReferrerQueryParam(request, ["client_id"]);
84− const redirectUri = oauthRedirectUri ?? `${externalOriginFromRequest(request)}/callback`;
85+ const redirectUri = oauthRedirectUri ?? resolveWorkosRedirectUri(request);
86+ if (!redirectUri) {
87+ return redirectResponse("/");
88+ }
89+
90+ if (isWorkosDebugEnabled()) {
91+ logWorkosAuth("sign-in.redirect", {
92+ redirectUri,
93+ requestHost: requestUrl.host,
94+ forwardedHost: request.headers.get("x-forwarded-host"),
95+ forwardedProto: request.headers.get("x-forwarded-proto") ?? requestUrl.protocol,
96+ hasOrganizationHint: Boolean(readOptionalQueryParam(requestUrl, ["organization_id"])),
97+ });
98+ }
8599 const organizationId = await resolveOrganizationHint(requestUrl);
86100 const loginHint = readOptionalQueryParam(requestUrl, ["login_hint"]);
87101
executor/apps/web/src/routes/sign-up.tsx+16−2
@@ -3,9 +3,10 @@ import { ConvexHttpClient } from "convex/browser"; · 2 unmodified lines
33 import { getAuthkit } from "@workos/authkit-tanstack-react-start";
44 import { api } from "@executor/database/convex/_generated/api";
55 import type { Id } from "@executor/database/convex/_generated/dataModel";
6−import { externalOriginFromRequest } from "@/lib/http/request-origin";
76 import { readOptionalQueryParam, readOptionalReferrerQueryParam } from "@/lib/http/query-params";
87 import { redirectResponse } from "@/lib/http/response";
8+import { isWorkosDebugEnabled, logWorkosAuth } from "@/lib/workos-debug";
9+import { resolveWorkosRedirectUri } from "@/lib/workos-redirect";
910
1011 const AUTHKIT_PASSTHROUGH_QUERY_KEYS = [
1112 "authorization_session_id",
@@ -81,7 +82,20 @@ async function handleSignUp(request: Request): Promise<Response> { · 69 unmodified lines
8182 const oauthClientId =
8283 readOptionalQueryParam(requestUrl, ["client_id"])
8384 ?? readOptionalReferrerQueryParam(request, ["client_id"]);
84− const redirectUri = oauthRedirectUri ?? `${externalOriginFromRequest(request)}/callback`;
85+ const redirectUri = oauthRedirectUri ?? resolveWorkosRedirectUri(request);
86+ if (!redirectUri) {
87+ return redirectResponse("/");
88+ }
89+
90+ if (isWorkosDebugEnabled()) {
91+ logWorkosAuth("sign-up.redirect", {
92+ redirectUri,
93+ requestHost: requestUrl.host,
94+ forwardedHost: request.headers.get("x-forwarded-host"),
95+ forwardedProto: request.headers.get("x-forwarded-proto") ?? requestUrl.protocol,
96+ hasOrganizationHint: Boolean(readOptionalQueryParam(requestUrl, ["organization_id"])),
97+ });
98+ }
8599 const organizationId = await resolveOrganizationHint(requestUrl);
86100 const loginHint = readOptionalQueryParam(requestUrl, ["login_hint"]);
87101
executor/apps/web/src/start.ts+7−16
@@ -1,20 +1,6 @@
11 import { createStart } from "@tanstack/react-start";
22 import { authkitMiddleware } from "@workos/authkit-tanstack-react-start";
3−
4−function trim(value: string | undefined): string | undefined {
5− const candidate = value?.trim();
6− return candidate && candidate.length > 0 ? candidate : undefined;
7−}
8−
9−function workosRedirectUri(): string | undefined {
10− return trim(process.env.WORKOS_REDIRECT_URI)
11− ?? (trim(process.env.EXECUTOR_PUBLIC_ORIGIN)
12− ? `${trim(process.env.EXECUTOR_PUBLIC_ORIGIN)}/callback`
13− : undefined)
14− ?? (trim(process.env.NODE_ENV) !== "production"
15− ? `http://localhost:${trim(process.env.PORT) ?? "4312"}/callback`
16− : undefined);
17−}
3+import { resolveWorkosRedirectUri } from "@/lib/workos-redirect";
184
195 function workosConfigured(): boolean {
206 return Boolean(
@@ -24,7 +10,12 @@ function workosConfigured(): boolean { · 3 unmodified lines
2410 );
2511 }
2612
27−const resolvedWorkosRedirectUri = workosRedirectUri();
13+function trim(value: string | undefined): string | undefined {
14+ const candidate = value?.trim();
15+ return candidate && candidate.length > 0 ? candidate : undefined;
16+}
17+
18+const resolvedWorkosRedirectUri = resolveWorkosRedirectUri();
2819
2920 if (!trim(process.env.WORKOS_REDIRECT_URI) && resolvedWorkosRedirectUri) {
3021 process.env.WORKOS_REDIRECT_URI = resolvedWorkosRedirectUri;