| 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"; |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| async exchangeCodeForToken( |
| code: string, |
| state: string |
| ): Promise<ExchangeTokenResponse> { |
| return this.oauthService.getTokenByCode(code, state); |
| } |
|
|
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| 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> { |
| |
| const cookies = this.parseCookies(req.headers.cookie); |
| const sessionCookie = cookies.get(COOKIE_NAME); |
| const session = await this.verifySession(sessionCookie); |
|
|
| if (!session) { |
| throw ForbiddenError("Invalid session cookie"); |
| } |
|
|
| const sessionUserId = session.openId; |
| const signedInAt = new Date(); |
| let user = await db.getUserByOpenId(sessionUserId); |
|
|
| |
| if (!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(); |
|
|