372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import type {
|
|
AcquisitionContext,
|
|
SsoAuthorizationResult,
|
|
} from '@yunlefun/sso'
|
|
import type {
|
|
NativeSsoTransaction,
|
|
NativeSsoTransactionStore,
|
|
} from '~/utils/nativeSso'
|
|
import type {
|
|
YunlefunAccount,
|
|
YunlefunAuthorizationAdapter,
|
|
YunlefunAuthorizationResult,
|
|
YunlefunIdentityAdapter,
|
|
} from '~/utils/yunlefunAuth'
|
|
import type { YunlefunSsoConfig } from '~/utils/yunlefunSsoConfig'
|
|
import { Browser } from '@capacitor/browser'
|
|
import { Preferences } from '@capacitor/preferences'
|
|
import {
|
|
adoptSsoCode,
|
|
consumeSsoRedirect,
|
|
sanitizeAcquisitionContext,
|
|
startSsoRedirect,
|
|
} from '@yunlefun/sso'
|
|
import {
|
|
requestHostSsoAuthorization,
|
|
SsoIdentityAdoptionError,
|
|
} from '@yunlefun/sso/browser'
|
|
import { readCookAcquisition } from '~/utils/acquisition'
|
|
import {
|
|
consumeNativeSsoCallback,
|
|
isNativeSsoCallbackUrl,
|
|
openNativeSsoRedirect,
|
|
} from '~/utils/nativeSso'
|
|
|
|
const NATIVE_TRANSACTION_KEY = 'cook:sso:v3:native-transaction'
|
|
|
|
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<string, unknown>
|
|
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<string>) => Promise<unknown>
|
|
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<string, unknown>) => Promise<unknown>
|
|
|
|
interface CloudbaseApp {
|
|
auth: (options?: { persistence: 'session' }) => CloudbaseAuthClient
|
|
callFunction: (options: { name: string, data: Record<string, unknown> }) => 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 NativeYunlefunAuthorizationAdapter extends YunlefunAuthorizationAdapter {
|
|
cancel: () => Promise<void>
|
|
consumeUrl: (url: string) => Promise<YunlefunAuthorizationResult | null>
|
|
}
|
|
|
|
export interface CloudbaseIdentityDependencies {
|
|
getAuth?: (env: string) => Promise<CloudbaseAuthClient>
|
|
}
|
|
|
|
export interface WebYunlefunAuthorizationDependencies {
|
|
requestHostAuthorization?: typeof requestHostSsoAuthorization
|
|
startRedirect?: typeof startSsoRedirect
|
|
readAcquisition?: () => AcquisitionContext | null
|
|
}
|
|
|
|
let cachedCloudbaseEnv = ''
|
|
let cachedCloudbaseApp: Promise<CloudbaseApp> | 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 createNativeYunlefunAuthorizationAdapter(
|
|
config: YunlefunSsoConfig,
|
|
initialUrl: () => Promise<string | null>,
|
|
readAcquisition: () => AcquisitionContext | null = readCookAcquisition,
|
|
): NativeYunlefunAuthorizationAdapter {
|
|
const store = createNativeTransactionStore()
|
|
|
|
async function consumeUrl(url: string): Promise<YunlefunAuthorizationResult | null> {
|
|
if (!isNativeSsoCallbackUrl(url, config.redirect.redirectUri))
|
|
return null
|
|
|
|
const result = await consumeNativeSsoCallback(url, store)
|
|
await Browser.close().catch(() => undefined)
|
|
return result
|
|
}
|
|
|
|
return {
|
|
cancel: async () => store.clear(),
|
|
consumeInitial: async () => {
|
|
const url = await initialUrl()
|
|
return url ? consumeUrl(url) : null
|
|
},
|
|
consumeUrl,
|
|
start: async () => {
|
|
const acquisition = readAcquisition()
|
|
await openNativeSsoRedirect({
|
|
...config.redirect,
|
|
prompt: 'consent',
|
|
...(acquisition ? { acquisition } : {}),
|
|
}, store, async (url) => {
|
|
await Browser.open({ url })
|
|
})
|
|
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 createNativeTransactionStore(): NativeSsoTransactionStore {
|
|
return {
|
|
clear: async () => {
|
|
await Preferences.remove({ key: NATIVE_TRANSACTION_KEY })
|
|
},
|
|
load: async () => {
|
|
const { value } = await Preferences.get({ key: NATIVE_TRANSACTION_KEY })
|
|
if (!value)
|
|
return null
|
|
|
|
try {
|
|
return readNativeTransaction(JSON.parse(value) as unknown)
|
|
}
|
|
catch {
|
|
return null
|
|
}
|
|
},
|
|
save: async (transaction) => {
|
|
await Preferences.set({
|
|
key: NATIVE_TRANSACTION_KEY,
|
|
value: JSON.stringify(transaction),
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
function getCloudbaseApp(env: string): Promise<CloudbaseApp> {
|
|
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<CloudbaseAuthClient> {
|
|
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<string, unknown>,
|
|
): Promise<unknown> {
|
|
// 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<YunlefunAccount | null> {
|
|
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()
|
|
}
|
|
|
|
function readNativeTransaction(value: unknown): NativeSsoTransaction | null {
|
|
if (!isRecord(value)
|
|
|| typeof value.clientId !== 'string'
|
|
|| typeof value.codeVerifier !== 'string'
|
|
|| typeof value.createdAt !== 'number'
|
|
|| typeof value.issuer !== 'string'
|
|
|| typeof value.nonce !== 'string'
|
|
|| typeof value.redirectUri !== 'string'
|
|
|| !Array.isArray(value.scope)
|
|
|| !value.scope.every(scope => typeof scope === 'string')) {
|
|
return null
|
|
}
|
|
|
|
const acquisition = sanitizeAcquisitionContext(value.acquisition)
|
|
return {
|
|
clientId: value.clientId,
|
|
codeVerifier: value.codeVerifier,
|
|
createdAt: value.createdAt,
|
|
issuer: value.issuer,
|
|
nonce: value.nonce,
|
|
redirectUri: value.redirectUri,
|
|
scope: value.scope,
|
|
...(acquisition ? { acquisition } : {}),
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
}
|