Skip to content

Commit c3f0b23

Browse files
authored
WorkOS auth: sealed sessions for cloud app (RhysSullivan#87)
- WorkOS AuthKit OAuth flow (login redirect, callback, code exchange) - Sealed session cookies via @workos-inc/node (no server-side session table) - Auto-refresh expired tokens on requests - Team ID stored in separate app cookie - Auth handlers: login, callback, logout, me
2 parents 072519d + 47e5e32 commit c3f0b23

3 files changed

Lines changed: 295 additions & 0 deletions

File tree

apps/cloud/src/auth/context.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Context } from "effect";
2+
3+
export class AuthContext extends Context.Tag("@executor/cloud/AuthContext")<
4+
AuthContext,
5+
{
6+
readonly userId: string;
7+
readonly teamId: string;
8+
readonly email: string;
9+
}
10+
>() {}

apps/cloud/src/auth/workos.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// ---------------------------------------------------------------------------
2+
// WorkOS AuthKit integration — sealed sessions, no server-side session store
3+
// ---------------------------------------------------------------------------
4+
5+
import { WorkOS } from "@workos-inc/node";
6+
7+
const COOKIE_NAME = "wos-session";
8+
9+
let workos: WorkOS | null = null;
10+
11+
export const getWorkOS = (): WorkOS => {
12+
if (!workos) {
13+
workos = new WorkOS(process.env.WORKOS_API_KEY!);
14+
}
15+
return workos;
16+
};
17+
18+
const getCookiePassword = (): string => {
19+
const password = process.env.WORKOS_COOKIE_PASSWORD;
20+
if (!password || password.length < 32) {
21+
throw new Error("WORKOS_COOKIE_PASSWORD must be at least 32 characters");
22+
}
23+
return password;
24+
};
25+
26+
export const getAuthorizationUrl = (redirectUri: string): string => {
27+
const wos = getWorkOS();
28+
return wos.userManagement.getAuthorizationUrl({
29+
provider: "authkit",
30+
redirectUri,
31+
clientId: process.env.WORKOS_CLIENT_ID!,
32+
});
33+
};
34+
35+
export const authenticateWithCode = async (code: string) => {
36+
const wos = getWorkOS();
37+
return wos.userManagement.authenticateWithCode({
38+
code,
39+
clientId: process.env.WORKOS_CLIENT_ID!,
40+
session: {
41+
sealSession: true,
42+
cookiePassword: getCookiePassword(),
43+
},
44+
});
45+
};
46+
47+
/**
48+
* Authenticate a request using the sealed session cookie.
49+
* Returns user info or null if not authenticated.
50+
*/
51+
export const authenticateRequest = async (request: Request) => {
52+
const cookieHeader = request.headers.get("cookie");
53+
const sessionData = parseCookie(cookieHeader, COOKIE_NAME);
54+
if (!sessionData) return null;
55+
56+
const wos = getWorkOS();
57+
const session = wos.userManagement.loadSealedSession({
58+
sessionData,
59+
cookiePassword: getCookiePassword(),
60+
});
61+
62+
const result = await session.authenticate();
63+
if (!result.authenticated) return null;
64+
65+
return {
66+
userId: result.user.id,
67+
email: result.user.email,
68+
firstName: result.user.firstName,
69+
lastName: result.user.lastName,
70+
avatarUrl: result.user.profilePictureUrl,
71+
sessionId: result.sessionId,
72+
};
73+
};
74+
75+
/**
76+
* Refresh the sealed session cookie. Returns new cookie value or null.
77+
*/
78+
export const refreshSession = async (request: Request) => {
79+
const cookieHeader = request.headers.get("cookie");
80+
const sessionData = parseCookie(cookieHeader, COOKIE_NAME);
81+
if (!sessionData) return null;
82+
83+
const wos = getWorkOS();
84+
const session = wos.userManagement.loadSealedSession({
85+
sessionData,
86+
cookiePassword: getCookiePassword(),
87+
});
88+
89+
const result = await session.refresh();
90+
if (!result.authenticated || !result.sealedSession) return null;
91+
92+
return {
93+
sealedSession: result.sealedSession,
94+
cookie: makeSessionCookie(result.sealedSession),
95+
};
96+
};
97+
98+
/**
99+
* Get logout URL for the current session.
100+
*/
101+
export const getLogoutUrl = async (request: Request) => {
102+
const cookieHeader = request.headers.get("cookie");
103+
const sessionData = parseCookie(cookieHeader, COOKIE_NAME);
104+
if (!sessionData) return null;
105+
106+
const wos = getWorkOS();
107+
const session = wos.userManagement.loadSealedSession({
108+
sessionData,
109+
cookiePassword: getCookiePassword(),
110+
});
111+
112+
return session.getLogoutUrl();
113+
};
114+
115+
// ---------------------------------------------------------------------------
116+
// Cookie helpers
117+
// ---------------------------------------------------------------------------
118+
119+
export const makeSessionCookie = (sealedSession: string): string => {
120+
const parts = [
121+
`${COOKIE_NAME}=${sealedSession}`,
122+
"Path=/",
123+
"HttpOnly",
124+
"SameSite=Lax",
125+
"Max-Age=604800", // 7 days
126+
];
127+
if (process.env.NODE_ENV === "production") parts.push("Secure");
128+
return parts.join("; ");
129+
};
130+
131+
export const clearSessionCookie = (): string =>
132+
`${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
133+
134+
const parseCookie = (cookieHeader: string | null, name: string): string | null => {
135+
if (!cookieHeader) return null;
136+
const match = cookieHeader
137+
.split(";")
138+
.map((c) => c.trim())
139+
.find((c) => c.startsWith(`${name}=`));
140+
if (!match) return null;
141+
return match.slice(name.length + 1) || null;
142+
};

apps/cloud/src/handlers/auth.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// ---------------------------------------------------------------------------
2+
// Auth handlers — login, callback, logout, me
3+
// ---------------------------------------------------------------------------
4+
5+
import { makeUserStore } from "@executor/storage-postgres";
6+
import {
7+
getAuthorizationUrl,
8+
authenticateWithCode,
9+
authenticateRequest,
10+
getLogoutUrl,
11+
makeSessionCookie,
12+
clearSessionCookie,
13+
} from "../auth/workos";
14+
import type { DrizzleDb } from "../services/db";
15+
16+
export const createAuthHandlers = (db: DrizzleDb) => {
17+
const userStore = makeUserStore(db);
18+
19+
const getBaseUrl = (): string => {
20+
if (process.env.APP_URL) return process.env.APP_URL;
21+
const port = process.env.PORT ?? "3000";
22+
return `http://localhost:${port}`;
23+
};
24+
25+
return {
26+
login: async (_request: Request): Promise<Response> => {
27+
const redirectUri = `${getBaseUrl()}/auth/callback`;
28+
const url = getAuthorizationUrl(redirectUri);
29+
return Response.redirect(url, 302);
30+
},
31+
32+
callback: async (request: Request): Promise<Response> => {
33+
const url = new URL(request.url);
34+
const code = url.searchParams.get("code");
35+
if (!code) {
36+
return new Response("Missing code parameter", { status: 400 });
37+
}
38+
39+
try {
40+
const result = await authenticateWithCode(code);
41+
const workosUser = result.user;
42+
43+
// Upsert user
44+
const user = await userStore.upsertUser({
45+
id: workosUser.id,
46+
email: workosUser.email,
47+
name: `${workosUser.firstName ?? ""} ${workosUser.lastName ?? ""}`.trim() || undefined,
48+
avatarUrl: workosUser.profilePictureUrl ?? undefined,
49+
});
50+
51+
// Check for pending invitations
52+
const pendingInvitations = await userStore.getPendingInvitations(user.email);
53+
let teamId: string;
54+
55+
if (pendingInvitations.length > 0) {
56+
const invitation = pendingInvitations[0]!;
57+
await userStore.acceptInvitation(invitation.id);
58+
await userStore.addMember(invitation.teamId, user.id, "member");
59+
teamId = invitation.teamId;
60+
} else {
61+
const teams = await userStore.getTeamsForUser(user.id);
62+
if (teams.length > 0) {
63+
teamId = teams[0]!.teamId;
64+
} else {
65+
const team = await userStore.createTeam(`${user.name ?? user.email}'s Team`);
66+
await userStore.addMember(team.id, user.id, "owner");
67+
teamId = team.id;
68+
}
69+
}
70+
71+
// Store teamId in a separate cookie (WorkOS sealed session doesn't carry app-specific data)
72+
const sealedSession = result.sealedSession;
73+
if (!sealedSession) {
74+
return new Response("Failed to create session", { status: 500 });
75+
}
76+
77+
return new Response(null, {
78+
status: 302,
79+
headers: [
80+
["Location", "/"],
81+
["Set-Cookie", makeSessionCookie(sealedSession)],
82+
["Set-Cookie", `executor_team=${teamId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800`],
83+
],
84+
});
85+
} catch (error) {
86+
console.error("Auth callback error:", error);
87+
return new Response("Authentication failed", { status: 500 });
88+
}
89+
},
90+
91+
logout: async (request: Request): Promise<Response> => {
92+
const logoutUrl = await getLogoutUrl(request);
93+
const headers: [string, string][] = [
94+
["Set-Cookie", clearSessionCookie()],
95+
["Set-Cookie", "executor_team=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"],
96+
];
97+
98+
if (logoutUrl) {
99+
headers.push(["Location", logoutUrl]);
100+
return new Response(null, { status: 302, headers });
101+
}
102+
103+
headers.push(["Location", "/login"]);
104+
return new Response(null, { status: 302, headers });
105+
},
106+
107+
me: async (request: Request): Promise<Response> => {
108+
const auth = await authenticateRequest(request);
109+
if (!auth) {
110+
return Response.json({ error: "Not authenticated" }, { status: 401 });
111+
}
112+
113+
const user = await userStore.getUser(auth.userId);
114+
if (!user) {
115+
return Response.json({ error: "User not found" }, { status: 401 });
116+
}
117+
118+
// Read teamId from cookie
119+
const teamId = parseCookie(request.headers.get("cookie"), "executor_team");
120+
const team = teamId ? await userStore.getTeam(teamId) : null;
121+
122+
return Response.json({
123+
user: {
124+
id: user.id,
125+
email: user.email,
126+
name: user.name,
127+
avatarUrl: user.avatarUrl,
128+
},
129+
team: team ? { id: team.id, name: team.name } : null,
130+
});
131+
},
132+
};
133+
};
134+
135+
const parseCookie = (cookieHeader: string | null, name: string): string | null => {
136+
if (!cookieHeader) return null;
137+
const match = cookieHeader
138+
.split(";")
139+
.map((c) => c.trim())
140+
.find((c) => c.startsWith(`${name}=`));
141+
if (!match) return null;
142+
return match.slice(name.length + 1) || null;
143+
};

0 commit comments

Comments
 (0)