sdk.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
  2. import { ForbiddenError } from "@shared/_core/errors";
  3. import axios, { type AxiosInstance } from "axios";
  4. import { parse as parseCookieHeader } from "cookie";
  5. import type { Request } from "express";
  6. import { SignJWT, jwtVerify } from "jose";
  7. import type { User } from "../../drizzle/schema";
  8. import * as db from "../db";
  9. import { ENV } from "./env";
  10. import type {
  11. ExchangeTokenRequest,
  12. ExchangeTokenResponse,
  13. GetUserInfoResponse,
  14. GetUserInfoWithJwtRequest,
  15. GetUserInfoWithJwtResponse,
  16. } from "./types/manusTypes";
  17. // Utility function
  18. const isNonEmptyString = (value: unknown): value is string =>
  19. typeof value === "string" && value.length > 0;
  20. export type SessionPayload = {
  21. openId: string;
  22. appId: string;
  23. name: string;
  24. };
  25. const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
  26. const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
  27. const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
  28. class OAuthService {
  29. constructor(private client: ReturnType<typeof axios.create>) {
  30. console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
  31. if (!ENV.oAuthServerUrl) {
  32. console.error(
  33. "[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable."
  34. );
  35. }
  36. }
  37. private decodeState(state: string): string {
  38. const redirectUri = atob(state);
  39. return redirectUri;
  40. }
  41. async getTokenByCode(
  42. code: string,
  43. state: string
  44. ): Promise<ExchangeTokenResponse> {
  45. const payload: ExchangeTokenRequest = {
  46. clientId: ENV.appId,
  47. grantType: "authorization_code",
  48. code,
  49. redirectUri: this.decodeState(state),
  50. };
  51. const { data } = await this.client.post<ExchangeTokenResponse>(
  52. EXCHANGE_TOKEN_PATH,
  53. payload
  54. );
  55. return data;
  56. }
  57. async getUserInfoByToken(
  58. token: ExchangeTokenResponse
  59. ): Promise<GetUserInfoResponse> {
  60. const { data } = await this.client.post<GetUserInfoResponse>(
  61. GET_USER_INFO_PATH,
  62. {
  63. accessToken: token.accessToken,
  64. }
  65. );
  66. return data;
  67. }
  68. }
  69. const createOAuthHttpClient = (): AxiosInstance =>
  70. axios.create({
  71. baseURL: ENV.oAuthServerUrl,
  72. timeout: AXIOS_TIMEOUT_MS,
  73. });
  74. class SDKServer {
  75. private readonly client: AxiosInstance;
  76. private readonly oauthService: OAuthService;
  77. constructor(client: AxiosInstance = createOAuthHttpClient()) {
  78. this.client = client;
  79. this.oauthService = new OAuthService(this.client);
  80. }
  81. private deriveLoginMethod(
  82. platforms: unknown,
  83. fallback: string | null | undefined
  84. ): string | null {
  85. if (fallback && fallback.length > 0) return fallback;
  86. if (!Array.isArray(platforms) || platforms.length === 0) return null;
  87. const set = new Set<string>(
  88. platforms.filter((p): p is string => typeof p === "string")
  89. );
  90. if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
  91. if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
  92. if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
  93. if (
  94. set.has("REGISTERED_PLATFORM_MICROSOFT") ||
  95. set.has("REGISTERED_PLATFORM_AZURE")
  96. )
  97. return "microsoft";
  98. if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
  99. const first = Array.from(set)[0];
  100. return first ? first.toLowerCase() : null;
  101. }
  102. /**
  103. * Exchange OAuth authorization code for access token
  104. * @example
  105. * const tokenResponse = await sdk.exchangeCodeForToken(code, state);
  106. */
  107. async exchangeCodeForToken(
  108. code: string,
  109. state: string
  110. ): Promise<ExchangeTokenResponse> {
  111. return this.oauthService.getTokenByCode(code, state);
  112. }
  113. /**
  114. * Get user information using access token
  115. * @example
  116. * const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
  117. */
  118. async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
  119. const data = await this.oauthService.getUserInfoByToken({
  120. accessToken,
  121. } as ExchangeTokenResponse);
  122. const loginMethod = this.deriveLoginMethod(
  123. (data as any)?.platforms,
  124. (data as any)?.platform ?? data.platform ?? null
  125. );
  126. return {
  127. ...(data as any),
  128. platform: loginMethod,
  129. loginMethod,
  130. } as GetUserInfoResponse;
  131. }
  132. private parseCookies(cookieHeader: string | undefined) {
  133. if (!cookieHeader) {
  134. return new Map<string, string>();
  135. }
  136. const parsed = parseCookieHeader(cookieHeader);
  137. return new Map(Object.entries(parsed));
  138. }
  139. private getSessionSecret() {
  140. const secret = ENV.cookieSecret;
  141. return new TextEncoder().encode(secret);
  142. }
  143. /**
  144. * Create a session token for a Manus user openId
  145. * @example
  146. * const sessionToken = await sdk.createSessionToken(userInfo.openId);
  147. */
  148. async createSessionToken(
  149. openId: string,
  150. options: { expiresInMs?: number; name?: string } = {}
  151. ): Promise<string> {
  152. return this.signSession(
  153. {
  154. openId,
  155. appId: ENV.appId,
  156. name: options.name || "",
  157. },
  158. options
  159. );
  160. }
  161. async signSession(
  162. payload: SessionPayload,
  163. options: { expiresInMs?: number } = {}
  164. ): Promise<string> {
  165. const issuedAt = Date.now();
  166. const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
  167. const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
  168. const secretKey = this.getSessionSecret();
  169. return new SignJWT({
  170. openId: payload.openId,
  171. appId: payload.appId,
  172. name: payload.name,
  173. })
  174. .setProtectedHeader({ alg: "HS256", typ: "JWT" })
  175. .setExpirationTime(expirationSeconds)
  176. .sign(secretKey);
  177. }
  178. async verifySession(
  179. cookieValue: string | undefined | null
  180. ): Promise<{ openId: string; appId: string; name: string } | null> {
  181. if (!cookieValue) {
  182. console.warn("[Auth] Missing session cookie");
  183. return null;
  184. }
  185. try {
  186. const secretKey = this.getSessionSecret();
  187. const { payload } = await jwtVerify(cookieValue, secretKey, {
  188. algorithms: ["HS256"],
  189. });
  190. const { openId, appId, name } = payload as Record<string, unknown>;
  191. if (
  192. !isNonEmptyString(openId) ||
  193. !isNonEmptyString(appId) ||
  194. !isNonEmptyString(name)
  195. ) {
  196. console.warn("[Auth] Session payload missing required fields");
  197. return null;
  198. }
  199. return {
  200. openId,
  201. appId,
  202. name,
  203. };
  204. } catch (error) {
  205. console.warn("[Auth] Session verification failed", String(error));
  206. return null;
  207. }
  208. }
  209. async getUserInfoWithJwt(
  210. jwtToken: string
  211. ): Promise<GetUserInfoWithJwtResponse> {
  212. const payload: GetUserInfoWithJwtRequest = {
  213. jwtToken,
  214. projectId: ENV.appId,
  215. };
  216. const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
  217. GET_USER_INFO_WITH_JWT_PATH,
  218. payload
  219. );
  220. const loginMethod = this.deriveLoginMethod(
  221. (data as any)?.platforms,
  222. (data as any)?.platform ?? data.platform ?? null
  223. );
  224. return {
  225. ...(data as any),
  226. platform: loginMethod,
  227. loginMethod,
  228. } as GetUserInfoWithJwtResponse;
  229. }
  230. async authenticateRequest(req: Request): Promise<User> {
  231. // Regular authentication flow
  232. const cookies = this.parseCookies(req.headers.cookie);
  233. const sessionCookie = cookies.get(COOKIE_NAME);
  234. const session = await this.verifySession(sessionCookie);
  235. if (!session) {
  236. throw ForbiddenError("Invalid session cookie");
  237. }
  238. const sessionUserId = session.openId;
  239. const signedInAt = new Date();
  240. let user = await db.getUserByOpenId(sessionUserId);
  241. // If user not in DB, sync from OAuth server automatically
  242. if (!user) {
  243. try {
  244. const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
  245. await db.upsertUser({
  246. openId: userInfo.openId,
  247. name: userInfo.name || null,
  248. email: userInfo.email ?? null,
  249. loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
  250. lastSignedIn: signedInAt,
  251. });
  252. user = await db.getUserByOpenId(userInfo.openId);
  253. } catch (error) {
  254. console.error("[Auth] Failed to sync user from OAuth:", error);
  255. throw ForbiddenError("Failed to sync user info");
  256. }
  257. }
  258. if (!user) {
  259. throw ForbiddenError("User not found");
  260. }
  261. await db.upsertUser({
  262. openId: user.openId,
  263. lastSignedIn: signedInAt,
  264. });
  265. return user;
  266. }
  267. }
  268. export const sdk = new SDKServer();