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 @@
1import { Context } from "effect";
2
3export 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
5import { WorkOS } from "@workos-inc/node";
6
7const COOKIE_NAME = "wos-session";
8
9let workos: WorkOS | null = null;
10
11export const getWorkOS = (): WorkOS => {
12 if (!workos) {
13 workos = new WorkOS(process.env.WORKOS_API_KEY!);
14 }
15 return workos;
16};
17
18const 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
26export 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
35export 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 */
51export 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 */
78export 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 */
101export 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
119export 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
131export const clearSessionCookie = (): string =>
132 `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
133
134const 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
5import { makeUserStore } from "@executor/storage-postgres";
6import {
7 getAuthorizationUrl,
8 authenticateWithCode,
9 authenticateRequest,
10 getLogoutUrl,
11 makeSessionCookie,
12 clearSessionCookie,
13} from "../auth/workos";
14import type { DrizzleDb } from "../services/db";
15
16export 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
135const 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};