Spaces:
Sleeping
Sleeping
| 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<typeof axios.create>) { | |
| 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<ExchangeTokenResponse> { | |
| const payload: ExchangeTokenRequest = { | |
| clientId: ENV.appId, | |
| grantType: "authorization_code", | |
| code, | |
| redirectUri: this.decodeState(state), | |
| }; | |
| const { data } = await this.client.post<ExchangeTokenResponse>( | |
| EXCHANGE_TOKEN_PATH, | |
| payload | |
| ); | |
| return data; | |
| } | |
| async getUserInfoByToken( | |
| token: ExchangeTokenResponse | |
| ): Promise<GetUserInfoResponse> { | |
| const { data } = await this.client.post<GetUserInfoResponse>( | |
| 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<string>( | |
| 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<ExchangeTokenResponse> { | |
| 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<GetUserInfoResponse> { | |
| 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<string, string>(); | |
| } | |
| 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<string> { | |
| return this.signSession( | |
| { | |
| openId, | |
| appId: ENV.appId, | |
| name: options.name || "", | |
| }, | |
| options | |
| ); | |
| } | |
| async signSession( | |
| payload: SessionPayload, | |
| options: { expiresInMs?: number } = {} | |
| ): Promise<string> { | |
| 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<string, unknown>; | |
| 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<GetUserInfoWithJwtResponse> { | |
| const payload: GetUserInfoWithJwtRequest = { | |
| jwtToken, | |
| projectId: ENV.appId, | |
| }; | |
| const { data } = await this.client.post<GetUserInfoWithJwtResponse>( | |
| 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<User> { | |
| // 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(); | |