import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; import { ForbiddenError } from "@shared/_core/errors"; import axios, { type AxiosInstance } from "axios"; import { parse as parseCookieHeader } from "cookie"; import type { Request } from "express"; import { SignJWT, jwtVerify } from "jose"; import type { User } from "../../drizzle/schema"; import * as db from "../db"; import { ENV } from "./env"; import type { ExchangeTokenRequest, ExchangeTokenResponse, GetUserInfoResponse, GetUserInfoWithJwtRequest, GetUserInfoWithJwtResponse, } from "./types/manusTypes"; // Utility function const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.length > 0; export type SessionPayload = { openId: string; appId: string; name: string; }; const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`; const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`; const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`; class OAuthService { constructor(private client: ReturnType) { console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl); if (!ENV.oAuthServerUrl) { console.error( "[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable." ); } } private decodeState(state: string): string { const redirectUri = atob(state); return redirectUri; } async getTokenByCode( code: string, state: string ): Promise { const payload: ExchangeTokenRequest = { clientId: ENV.appId, grantType: "authorization_code", code, redirectUri: this.decodeState(state), }; const { data } = await this.client.post( EXCHANGE_TOKEN_PATH, payload ); return data; } async getUserInfoByToken( token: ExchangeTokenResponse ): Promise { const { data } = await this.client.post( GET_USER_INFO_PATH, { accessToken: token.accessToken, } ); return data; } } const createOAuthHttpClient = (): AxiosInstance => axios.create({ baseURL: ENV.oAuthServerUrl, timeout: AXIOS_TIMEOUT_MS, }); class SDKServer { private readonly client: AxiosInstance; private readonly oauthService: OAuthService; constructor(client: AxiosInstance = createOAuthHttpClient()) { this.client = client; this.oauthService = new OAuthService(this.client); } private deriveLoginMethod( platforms: unknown, fallback: string | null | undefined ): string | null { if (fallback && fallback.length > 0) return fallback; if (!Array.isArray(platforms) || platforms.length === 0) return null; const set = new Set( platforms.filter((p): p is string => typeof p === "string") ); if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email"; if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google"; if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple"; if ( set.has("REGISTERED_PLATFORM_MICROSOFT") || set.has("REGISTERED_PLATFORM_AZURE") ) return "microsoft"; if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github"; const first = Array.from(set)[0]; return first ? first.toLowerCase() : null; } /** * Exchange OAuth authorization code for access token * @example * const tokenResponse = await sdk.exchangeCodeForToken(code, state); */ async exchangeCodeForToken( code: string, state: string ): Promise { return this.oauthService.getTokenByCode(code, state); } /** * Get user information using access token * @example * const userInfo = await sdk.getUserInfo(tokenResponse.accessToken); */ async getUserInfo(accessToken: string): Promise { const data = await this.oauthService.getUserInfoByToken({ accessToken, } as ExchangeTokenResponse); const loginMethod = this.deriveLoginMethod( (data as any)?.platforms, (data as any)?.platform ?? data.platform ?? null ); return { ...(data as any), platform: loginMethod, loginMethod, } as GetUserInfoResponse; } private parseCookies(cookieHeader: string | undefined) { if (!cookieHeader) { return new Map(); } const parsed = parseCookieHeader(cookieHeader); return new Map(Object.entries(parsed)); } private getSessionSecret() { const secret = ENV.cookieSecret; return new TextEncoder().encode(secret); } /** * Create a session token for a Manus user openId * @example * const sessionToken = await sdk.createSessionToken(userInfo.openId); */ async createSessionToken( openId: string, options: { expiresInMs?: number; name?: string } = {} ): Promise { return this.signSession( { openId, appId: ENV.appId, name: options.name || "", }, options ); } async signSession( payload: SessionPayload, options: { expiresInMs?: number } = {} ): Promise { const issuedAt = Date.now(); const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS; const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000); const secretKey = this.getSessionSecret(); return new SignJWT({ openId: payload.openId, appId: payload.appId, name: payload.name, }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setExpirationTime(expirationSeconds) .sign(secretKey); } async verifySession( cookieValue: string | undefined | null ): Promise<{ openId: string; appId: string; name: string } | null> { if (!cookieValue) { console.warn("[Auth] Missing session cookie"); return null; } try { const secretKey = this.getSessionSecret(); const { payload } = await jwtVerify(cookieValue, secretKey, { algorithms: ["HS256"], }); const { openId, appId, name } = payload as Record; if ( !isNonEmptyString(openId) || !isNonEmptyString(appId) || !isNonEmptyString(name) ) { console.warn("[Auth] Session payload missing required fields"); return null; } return { openId, appId, name, }; } catch (error) { console.warn("[Auth] Session verification failed", String(error)); return null; } } async getUserInfoWithJwt( jwtToken: string ): Promise { const payload: GetUserInfoWithJwtRequest = { jwtToken, projectId: ENV.appId, }; const { data } = await this.client.post( GET_USER_INFO_WITH_JWT_PATH, payload ); const loginMethod = this.deriveLoginMethod( (data as any)?.platforms, (data as any)?.platform ?? data.platform ?? null ); return { ...(data as any), platform: loginMethod, loginMethod, } as GetUserInfoWithJwtResponse; } async authenticateRequest(req: Request): Promise { // Check for access key mode (Safari iframe fix - cookies don't work there) const accessKeyEnabled = !!process.env.ACCESS_KEY; const isHfOAuthConfigured = !!(ENV.hfOAuthClientId && ENV.hfOAuthClientSecret); const isDemoMode = accessKeyEnabled && !isHfOAuthConfigured; // Regular authentication flow const cookies = this.parseCookies(req.headers.cookie); const sessionCookie = cookies.get(COOKIE_NAME); const session = await this.verifySession(sessionCookie); // In demo mode (access key without OAuth), allow without valid session cookie // The access key was already validated via TRPC endpoint if (!session && isDemoMode) { console.log("[Auth] Demo mode - returning demo user (no cookie required)"); return { id: 1, openId: "demo-user", name: "Demo User", email: "demo@aimusic.attribution", loginMethod: "access_key", role: "user", createdAt: new Date(), updatedAt: new Date(), lastSignedIn: new Date(), } as User; } if (!session) { throw ForbiddenError("Invalid session cookie"); } const sessionUserId = session.openId; const signedInAt = new Date(); // Try to get user from database let user = await db.getUserByOpenId(sessionUserId); // If user not in DB, check if it's a dev user or try to sync from OAuth if (!user) { // Check if this is a dev user (openId starts with "dev-" or "hf-") // Allow in dev mode OR when HF OAuth is not configured (demo/access-key mode) const isDev = process.env.NODE_ENV !== "production"; const isHfOAuthConfigured = !!(ENV.hfOAuthClientId && ENV.hfOAuthClientSecret); const isDemoMode = !isHfOAuthConfigured; // Demo mode when no HF OAuth const isDevUser = sessionUserId.startsWith("dev-") && (isDev || isDemoMode); if (isDevUser) { // Return a mock user for development // console.log("[Auth] Using dev user:", sessionUserId); return { id: 1, openId: sessionUserId, name: session.name || "Dev User", email: "dev@localhost", loginMethod: "dev", role: "user", createdAt: new Date(), updatedAt: new Date(), lastSignedIn: signedInAt, } as User; } try { const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? ""); await db.upsertUser({ openId: userInfo.openId, name: userInfo.name || null, email: userInfo.email ?? null, loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null, lastSignedIn: signedInAt, }); user = await db.getUserByOpenId(userInfo.openId); } catch (error) { console.error("[Auth] Failed to sync user from OAuth:", error); throw ForbiddenError("Failed to sync user info"); } } if (!user) { throw ForbiddenError("User not found"); } await db.upsertUser({ openId: user.openId, lastSignedIn: signedInAt, }); return user; } } export const sdk = new SDKServer();