- 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
Unified · Split
| @@ -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 | >() {} | ||
| @@ -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 | }; | ||
| @@ -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 | }; | ||