History

WorkOS auth: sealed sessions for cloud app

- 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

Rhys Sullivan committed 7 months ago

Commit 47e5e321 parent 072519d3 files · +295 −0Browse files

Unified · Split

3 files
  1. apps/cloud/src/auth/context.ts +10 −0
  2. apps/cloud/src/auth/workos.ts +142 −0
  3. apps/cloud/src/handlers/auth.ts +143 −0
apps/cloud/src/auth/context.ts+10−0
@@ -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+142−0
@@ -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+143−0
@@ -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+};