File size: 5,983 Bytes
cd2a4c2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | import { COOKIE_NAME, ONE_YEAR_MS } from "../../shared/const.js";
import type { Express, Request, Response } from "express";
import { getUserByOpenId, upsertUser } from "../db";
import { getSessionCookieOptions } from "./cookies";
import { sdk } from "./sdk";
function getQueryParam(req: Request, key: string): string | undefined {
const value = req.query[key];
return typeof value === "string" ? value : undefined;
}
async function syncUser(userInfo: {
openId?: string | null;
name?: string | null;
email?: string | null;
loginMethod?: string | null;
platform?: string | null;
}) {
if (!userInfo.openId) {
throw new Error("openId missing from user info");
}
const lastSignedIn = new Date();
await upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? null,
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
lastSignedIn,
});
const saved = await getUserByOpenId(userInfo.openId);
return (
saved ?? {
openId: userInfo.openId,
name: userInfo.name,
email: userInfo.email,
loginMethod: userInfo.loginMethod ?? null,
lastSignedIn,
}
);
}
function buildUserResponse(
user:
| Awaited<ReturnType<typeof getUserByOpenId>>
| {
openId: string;
name?: string | null;
email?: string | null;
loginMethod?: string | null;
lastSignedIn?: Date | null;
},
) {
return {
id: (user as any)?.id ?? null,
openId: user?.openId ?? null,
name: user?.name ?? null,
email: user?.email ?? null,
loginMethod: user?.loginMethod ?? null,
lastSignedIn: (user?.lastSignedIn ?? new Date()).toISOString(),
};
}
export function registerOAuthRoutes(app: Express) {
app.get("/api/oauth/callback", async (req: Request, res: Response) => {
const code = getQueryParam(req, "code");
const state = getQueryParam(req, "state");
if (!code || !state) {
res.status(400).json({ error: "code and state are required" });
return;
}
try {
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
await syncUser(userInfo);
const sessionToken = await sdk.createSessionToken(userInfo.openId!, {
name: userInfo.name || "",
expiresInMs: ONE_YEAR_MS,
});
const cookieOptions = getSessionCookieOptions(req);
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
// Redirect to the frontend URL (Expo web on port 8081)
// Cookie is set with parent domain so it works across both 3000 and 8081 subdomains
const frontendUrl =
process.env.EXPO_WEB_PREVIEW_URL ||
process.env.EXPO_PACKAGER_PROXY_URL ||
"http://localhost:8081";
res.redirect(302, frontendUrl);
} catch (error) {
console.error("[OAuth] Callback failed", error);
res.status(500).json({ error: "OAuth callback failed" });
}
});
app.get("/api/oauth/mobile", async (req: Request, res: Response) => {
const code = getQueryParam(req, "code");
const state = getQueryParam(req, "state");
if (!code || !state) {
res.status(400).json({ error: "code and state are required" });
return;
}
try {
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
const user = await syncUser(userInfo);
const sessionToken = await sdk.createSessionToken(userInfo.openId!, {
name: userInfo.name || "",
expiresInMs: ONE_YEAR_MS,
});
const cookieOptions = getSessionCookieOptions(req);
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
res.json({
app_session_id: sessionToken,
user: buildUserResponse(user),
});
} catch (error) {
console.error("[OAuth] Mobile exchange failed", error);
res.status(500).json({ error: "OAuth mobile exchange failed" });
}
});
app.post("/api/auth/logout", (req: Request, res: Response) => {
const cookieOptions = getSessionCookieOptions(req);
res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
res.json({ success: true });
});
// Get current authenticated user - works with both cookie (web) and Bearer token (mobile)
app.get("/api/auth/me", async (req: Request, res: Response) => {
try {
const user = await sdk.authenticateRequest(req);
res.json({ user: buildUserResponse(user) });
} catch (error) {
console.error("[Auth] /api/auth/me failed:", error);
res.status(401).json({ error: "Not authenticated", user: null });
}
});
// Establish session cookie from Bearer token
// Used by iframe preview: frontend receives token via postMessage, then calls this endpoint
// to get a proper Set-Cookie response from the backend (3000-xxx domain)
app.post("/api/auth/session", async (req: Request, res: Response) => {
try {
// Authenticate using Bearer token from Authorization header
const user = await sdk.authenticateRequest(req);
// Get the token from the Authorization header to set as cookie
const authHeader = req.headers.authorization || req.headers.Authorization;
if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) {
res.status(400).json({ error: "Bearer token required" });
return;
}
const token = authHeader.slice("Bearer ".length).trim();
// Set cookie for this domain (3000-xxx)
const cookieOptions = getSessionCookieOptions(req);
res.cookie(COOKIE_NAME, token, { ...cookieOptions, maxAge: ONE_YEAR_MS });
res.json({ success: true, user: buildUserResponse(user) });
} catch (error) {
console.error("[Auth] /api/auth/session failed:", error);
res.status(401).json({ error: "Invalid token" });
}
});
}
|