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
6 return value.split(",")[0]?.trim() ?? "";6 return value.split(",")[0]?.trim() ?? "";
7}7}
8 8
9function 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
9export function externalOriginFromRequest(request: Request): string {25export function externalOriginFromRequest(request: Request): string {
10 const requestUrl = new URL(request.url);26 const requestUrl = new URL(request.url);
11 const host = firstHeaderValue(request.headers.get("x-forwarded-host") ?? request.headers.get("host"));27 const host = firstHeaderValue(request.headers.get("x-forwarded-host") ?? request.headers.get("host"));
12 const proto = firstHeaderValue(request.headers.get("x-forwarded-proto"))28 const proto = firstHeaderValue(request.headers.get("x-forwarded-proto"))
13 || requestUrl.protocol.replace(":", "");29 || requestUrl.protocol.replace(":", "");
14 30
15 if (host && proto) {31 const normalized = normalizeOrigin(host, proto);
16 return `${proto}://${host}`;32 if (normalized) {
33 return normalized;
17 }34 }
18 35
19 return requestUrl.origin;36 return requestUrl.origin;
executor/apps/web/src/lib/workos-callback.ts+123−0
@@ -0,0 +1,123 @@
1import { handleCallbackRoute } from "@workos/authkit-tanstack-react-start";
2
3import { redirectResponse } from "@/lib/http/response";
4import { isWorkosDebugEnabled, logWorkosAuth, redactAuthCode } from "@/lib/workos-debug";
5
6const callbackHandler = handleCallbackRoute({
7 returnPathname: "/",
8});
9
10const WORKOS_CALLBACK_REPLAY_MS = 10 * 60 * 1000;
11const CALLBACK_REPLAY_PRUNE_INTERVAL_MS = 30 * 1000;
12const callbackCodeReplay = new Map<string, number>();
13let nextReplayPruneAt = 0;
14
15type WorkOSCallbackInput = Parameters<typeof callbackHandler>[0];
16
17function 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
31function 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
46function markCodeAsReplayed(code: string, now = Date.now()): void {
47 callbackCodeReplay.set(code, now + WORKOS_CALLBACK_REPLAY_MS);
48}
49
50export 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 @@
1function trim(value: string | null | undefined): string | undefined {
2 const candidate = value?.trim();
3 return candidate && candidate.length > 0 ? candidate : undefined;
4}
5
6const WORKOS_DEBUG_VALUES = new Set(["1", "true", "yes", "on"]);
7
8function 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
17export function isWorkosDebugEnabled(): boolean {
18 return isWorkosDebugEnabledInternal();
19}
20
21export 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
33export 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 @@
1import { expect, test } from "bun:test";
2
3import { resolveWorkosRedirectUri } from "./workos-redirect";
4
5const 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
15const 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
21function 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
31test("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
41test("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
51test("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
67test("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
82resetWorkosEnv();
executor/apps/web/src/lib/workos-redirect.ts+48−0
@@ -0,0 +1,48 @@
1import { externalOriginFromRequest } from "./http/request-origin";
2
3const WORKOS_REDIRECT_PATH = "/callback";
4
5function trim(value: string | undefined): string | undefined {
6 const candidate = value?.trim();
7 return candidate && candidate.length > 0 ? candidate : undefined;
8}
9
10function 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
32export 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 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { handleCallbackRoute } from "@workos/authkit-tanstack-react-start";2import { handleWorkOSCallback } from "@/lib/workos-callback";
3 3
4export const Route = createFileRoute("/api/auth/callback")({4export const Route = createFileRoute("/api/auth/callback")({
5 server: {5 server: {
6 handlers: {6 handlers: {
7 GET: handleCallbackRoute({7 GET: handleWorkOSCallback,
8 returnPathname: "/",
9 }),
10 },8 },
11 },9 },
12});10});
executor/apps/web/src/routes/callback.tsx+2−4
@@ -1,12 +1,10 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { handleCallbackRoute } from "@workos/authkit-tanstack-react-start";2import { handleWorkOSCallback } from "@/lib/workos-callback";
3 3
4export const Route = createFileRoute("/callback")({4export const Route = createFileRoute("/callback")({
5 server: {5 server: {
6 handlers: {6 handlers: {
7 GET: handleCallbackRoute({7 GET: handleWorkOSCallback,
8 returnPathname: "/",
9 }),
10 },8 },
11 },9 },
12});10});
executor/apps/web/src/routes/sign-in.tsx+16−2
@@ -3,9 +3,10 @@ import { ConvexHttpClient } from "convex/browser"; · 2 unmodified lines
3import { getAuthkit } from "@workos/authkit-tanstack-react-start";3import { getAuthkit } from "@workos/authkit-tanstack-react-start";
4import { api } from "@executor/database/convex/_generated/api";4import { api } from "@executor/database/convex/_generated/api";
5import type { Id } from "@executor/database/convex/_generated/dataModel";5import type { Id } from "@executor/database/convex/_generated/dataModel";
6import { externalOriginFromRequest } from "@/lib/http/request-origin";
7import { readOptionalQueryParam, readOptionalReferrerQueryParam } from "@/lib/http/query-params";6import { readOptionalQueryParam, readOptionalReferrerQueryParam } from "@/lib/http/query-params";
8import { redirectResponse } from "@/lib/http/response";7import { redirectResponse } from "@/lib/http/response";
8import { isWorkosDebugEnabled, logWorkosAuth } from "@/lib/workos-debug";
9import { resolveWorkosRedirectUri } from "@/lib/workos-redirect";
9 10
10const AUTHKIT_PASSTHROUGH_QUERY_KEYS = [11const AUTHKIT_PASSTHROUGH_QUERY_KEYS = [
11 "authorization_session_id",12 "authorization_session_id",
@@ -81,7 +82,20 @@ async function handleSignIn(request: Request): Promise<Response> { · 69 unmodified lines
81 const oauthClientId =82 const oauthClientId =
82 readOptionalQueryParam(requestUrl, ["client_id"])83 readOptionalQueryParam(requestUrl, ["client_id"])
83 ?? readOptionalReferrerQueryParam(request, ["client_id"]);84 ?? 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 }
85 const organizationId = await resolveOrganizationHint(requestUrl);99 const organizationId = await resolveOrganizationHint(requestUrl);
86 const loginHint = readOptionalQueryParam(requestUrl, ["login_hint"]);100 const loginHint = readOptionalQueryParam(requestUrl, ["login_hint"]);
87 101
executor/apps/web/src/routes/sign-up.tsx+16−2
@@ -3,9 +3,10 @@ import { ConvexHttpClient } from "convex/browser"; · 2 unmodified lines
3import { getAuthkit } from "@workos/authkit-tanstack-react-start";3import { getAuthkit } from "@workos/authkit-tanstack-react-start";
4import { api } from "@executor/database/convex/_generated/api";4import { api } from "@executor/database/convex/_generated/api";
5import type { Id } from "@executor/database/convex/_generated/dataModel";5import type { Id } from "@executor/database/convex/_generated/dataModel";
6import { externalOriginFromRequest } from "@/lib/http/request-origin";
7import { readOptionalQueryParam, readOptionalReferrerQueryParam } from "@/lib/http/query-params";6import { readOptionalQueryParam, readOptionalReferrerQueryParam } from "@/lib/http/query-params";
8import { redirectResponse } from "@/lib/http/response";7import { redirectResponse } from "@/lib/http/response";
8import { isWorkosDebugEnabled, logWorkosAuth } from "@/lib/workos-debug";
9import { resolveWorkosRedirectUri } from "@/lib/workos-redirect";
9 10
10const AUTHKIT_PASSTHROUGH_QUERY_KEYS = [11const AUTHKIT_PASSTHROUGH_QUERY_KEYS = [
11 "authorization_session_id",12 "authorization_session_id",
@@ -81,7 +82,20 @@ async function handleSignUp(request: Request): Promise<Response> { · 69 unmodified lines
81 const oauthClientId =82 const oauthClientId =
82 readOptionalQueryParam(requestUrl, ["client_id"])83 readOptionalQueryParam(requestUrl, ["client_id"])
83 ?? readOptionalReferrerQueryParam(request, ["client_id"]);84 ?? 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 }
85 const organizationId = await resolveOrganizationHint(requestUrl);99 const organizationId = await resolveOrganizationHint(requestUrl);
86 const loginHint = readOptionalQueryParam(requestUrl, ["login_hint"]);100 const loginHint = readOptionalQueryParam(requestUrl, ["login_hint"]);
87 101
executor/apps/web/src/start.ts+7−16
@@ -1,20 +1,6 @@
1import { createStart } from "@tanstack/react-start";1import { createStart } from "@tanstack/react-start";
2import { authkitMiddleware } from "@workos/authkit-tanstack-react-start";2import { authkitMiddleware } from "@workos/authkit-tanstack-react-start";
3 3import { resolveWorkosRedirectUri } from "@/lib/workos-redirect";
4function trim(value: string | undefined): string | undefined {
5 const candidate = value?.trim();
6 return candidate && candidate.length > 0 ? candidate : undefined;
7}
8
9function 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}
18 4
19function workosConfigured(): boolean {5function workosConfigured(): boolean {
20 return Boolean(6 return Boolean(
@@ -24,7 +10,12 @@ function workosConfigured(): boolean { · 3 unmodified lines
24 );10 );
25}11}
26 12
27const resolvedWorkosRedirectUri = workosRedirectUri();13function trim(value: string | undefined): string | undefined {
14 const candidate = value?.trim();
15 return candidate && candidate.length > 0 ? candidate : undefined;
16}
17
18const resolvedWorkosRedirectUri = resolveWorkosRedirectUri();
28 19
29if (!trim(process.env.WORKOS_REDIRECT_URI) && resolvedWorkosRedirectUri) {20if (!trim(process.env.WORKOS_REDIRECT_URI) && resolvedWorkosRedirectUri) {
30 process.env.WORKOS_REDIRECT_URI = resolvedWorkosRedirectUri;21 process.env.WORKOS_REDIRECT_URI = resolvedWorkosRedirectUri;