import type { AcquisitionContext, SsoAuthorizationResult, } from '@yunlefun/sso' import type { YunlefunAccount, YunlefunAuthorizationAdapter, YunlefunIdentityAdapter, } from '~/utils/yunlefunAuth' import type { YunlefunSsoConfig } from '~/utils/yunlefunSsoConfig' import { adoptSsoCode, consumeSsoRedirect, startSsoRedirect, } from '@yunlefun/sso' import { requestHostSsoAuthorization, SsoIdentityAdoptionError, } from '@yunlefun/sso/browser' import { readCookAcquisition } from '~/utils/acquisition' interface CloudbaseUser { avatar_url?: string email?: string id?: string is_anonymous?: boolean name?: string phone?: string phone_number?: string picture?: string sub?: string uid?: string user_metadata?: Record username?: string } interface CloudbaseSession { access_token?: string user?: CloudbaseUser } export interface CloudbaseAuthClient { getSession: () => Promise<{ data?: { session?: CloudbaseSession | null } error?: unknown }> getUser: () => Promise<{ data?: { user?: CloudbaseUser | null } error?: unknown }> signInWithCustomTicket: (getTicket: () => Promise) => Promise signOut: () => Promise<{ error?: unknown } | void> } /** Calls a cloud function with the signed-in session and resolves its `result`. */ export type CloudbaseFunctionCaller = (name: string, data: Record) => Promise interface CloudbaseApp { auth: (options?: { persistence: 'session' }) => CloudbaseAuthClient callFunction: (options: { name: string, data: Record }) => Promise<{ errCode?: number | string errMsg?: string result?: unknown } | undefined> } interface CloudbaseModule { default?: { init: (config: { auth: { detectSessionInUrl: false } env: string region: 'ap-shanghai' }) => CloudbaseApp } init?: (config: { auth: { detectSessionInUrl: false } env: string region: 'ap-shanghai' }) => CloudbaseApp } export interface CloudbaseIdentityDependencies { getAuth?: (env: string) => Promise } export interface WebYunlefunAuthorizationDependencies { requestHostAuthorization?: typeof requestHostSsoAuthorization startRedirect?: typeof startSsoRedirect readAcquisition?: () => AcquisitionContext | null } let cachedCloudbaseEnv = '' let cachedCloudbaseApp: Promise | undefined let cachedCloudbaseAuth: CloudbaseAuthClient | undefined export function createWebYunlefunAuthorizationAdapter( config: YunlefunSsoConfig, dependencies: WebYunlefunAuthorizationDependencies = {}, ): YunlefunAuthorizationAdapter { const requestHostAuthorization = dependencies.requestHostAuthorization ?? requestHostSsoAuthorization const startRedirect = dependencies.startRedirect ?? startSsoRedirect return { consumeInitial: async () => consumeSsoRedirect(), start: async () => { const acquisition = (dependencies.readAcquisition ?? readCookAcquisition)() const redirect = { ...config.redirect, ...(acquisition ? { acquisition } : {}) } try { const authorization = await requestHostAuthorization({ ...redirect, prompt: 'consent', }) if (authorization) return authorization } catch (error) { if (error instanceof SsoIdentityAdoptionError && error.reason === 'access_denied') { return { ok: false, reason: 'access_denied', } } throw error } await startRedirect(redirect) return null }, } } export function createCloudbaseIdentityAdapter( config: YunlefunSsoConfig, dependencies: CloudbaseIdentityDependencies = {}, ): YunlefunIdentityAdapter { const loadAuth = dependencies.getAuth ?? getCloudbaseAuth return { adopt: async (authorization: SsoAuthorizationResult) => { const auth = await loadAuth(config.cloudbaseEnv) const adopted = await adoptSsoCode(auth, authorization, config.exchange) return adopted ? readAuthenticatedAccount(auth) : null }, restore: async () => { const auth = await loadAuth(config.cloudbaseEnv) return readAuthenticatedAccount(auth) }, signOut: async () => { const auth = await loadAuth(config.cloudbaseEnv) const result = await auth.signOut() if (result?.error) throw new Error('CloudBase sign-out failed') }, } } function getCloudbaseApp(env: string): Promise { if (cachedCloudbaseApp) { if (cachedCloudbaseEnv !== env) return Promise.reject(new Error('CloudBase environment cannot change at runtime')) return cachedCloudbaseApp } cachedCloudbaseEnv = env cachedCloudbaseApp = (async () => { const cloudbaseModule = await import('@cloudbase/js-sdk') as unknown as CloudbaseModule const cloudbase = cloudbaseModule.default ?? cloudbaseModule if (!cloudbase.init) throw new Error('@cloudbase/js-sdk does not expose init()') return cloudbase.init({ auth: { detectSessionInUrl: false }, env, region: 'ap-shanghai', }) })() cachedCloudbaseApp.catch(() => { cachedCloudbaseApp = undefined }) return cachedCloudbaseApp } async function getCloudbaseAuth(env: string): Promise { const app = await getCloudbaseApp(env) cachedCloudbaseAuth ??= app.auth({ persistence: 'session' }) return cachedCloudbaseAuth } /** Call a cloud function on the same CloudBase app that holds the SSO session. */ export async function callCloudbaseFunction( env: string, name: string, data: Record, ): Promise { // Initialise auth first so the call carries the signed-in session. await getCloudbaseAuth(env) const app = await getCloudbaseApp(env) const response = await app.callFunction({ name, data }) // A missing result means the call itself failed (not deployed, crashed, …). if (!response || response.result === undefined) throw new Error(`CloudBase function ${name} failed: ${response?.errCode ?? response?.errMsg ?? 'no result'}`) return response.result } async function readAuthenticatedAccount(auth: CloudbaseAuthClient): Promise { const sessionResult = await auth.getSession() const session = sessionResult.data?.session if (sessionResult.error || !session || typeof session.access_token !== 'string' || session.access_token.length < 16 || session.user?.is_anonymous) { return null } const userResult = await auth.getUser() if (userResult.error) return null return normalizeAccount(userResult.data?.user ?? session.user) } function normalizeAccount(user: CloudbaseUser | null | undefined): YunlefunAccount | null { const uid = firstString(user?.id, user?.uid, user?.sub) if (!user || user.is_anonymous || !uid) return null const metadata = user.user_metadata ?? {} const displayName = firstString( metadata.displayName, metadata.name, metadata.nickName, metadata.username, user.name, user.username, user.email, user.phone, user.phone_number, uid.slice(0, 8), ) const avatarUrl = firstString( metadata.avatarUrl, metadata.avatar_url, metadata.picture, user.avatar_url, user.picture, ) return { uid, displayName: displayName ?? uid.slice(0, 8), ...(avatarUrl ? { avatarUrl } : {}), } } function firstString(...values: unknown[]): string | undefined { return values.find(value => typeof value === 'string' && value.trim())?.toString().trim() }