1
0
mirror of synced 2026-10-07 23:31:24 +08:00

feat(auth): prefer YunLeFun host authorization

This commit is contained in:
YunYouJun
2026-08-17 16:38:23 +08:00
parent 8a84989926
commit 359ddca5f2
4 changed files with 126 additions and 3 deletions

View File

@@ -19,6 +19,10 @@ import {
consumeSsoRedirect,
startSsoRedirect,
} from '@yunlefun/sso'
import {
requestHostSsoAuthorization,
SsoIdentityAdoptionError,
} from '@yunlefun/sso/browser'
import {
consumeNativeSsoCallback,
isNativeSsoCallbackUrl,
@@ -88,15 +92,47 @@ export interface CloudbaseIdentityDependencies {
getAuth?: (env: string) => Promise<CloudbaseAuthClient>
}
export interface WebYunlefunAuthorizationDependencies {
requestHostAuthorization?: typeof requestHostSsoAuthorization
startRedirect?: typeof startSsoRedirect
}
let cachedCloudbaseEnv = ''
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 () => startSsoRedirect(config.redirect),
start: async () => {
try {
const authorization = await requestHostAuthorization({
...config.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(config.redirect)
return null
},
}
}
@@ -129,6 +165,7 @@ export function createNativeYunlefunAuthorizationAdapter(
}, store, async (url) => {
await Browser.open({ url })
})
return null
},
}
}

View File

@@ -29,7 +29,7 @@ export type YunlefunAuthorizationResult
export interface YunlefunAuthorizationAdapter {
consumeInitial: () => Promise<YunlefunAuthorizationResult | null>
start: () => Promise<void>
start: () => Promise<YunlefunAuthorizationResult | null>
}
export interface YunlefunIdentityAdapter {
@@ -104,7 +104,9 @@ export function createYunlefunAuthController(
status: 'signing-in',
})
try {
await authorization.start()
const result = await authorization.start()
if (result)
await handleAuthorization(result)
}
catch {
update({

View File

@@ -71,6 +71,26 @@ describe('yunlefun auth controller', () => {
expect(nativeController.snapshot().status).toBe('signing-in')
})
it('adopts a host authorization without leaving the current page', async () => {
const web = fakeAuthorizationAdapter(null, authorization)
const identity = fakeIdentityAdapter()
const controller = createYunlefunAuthController({
identity,
native: fakeAuthorizationAdapter(),
platform: 'web',
web,
})
await controller.signIn()
expect(identity.adopted).toEqual([authorization])
expect(controller.snapshot()).toEqual({
account,
errorMessage: '',
status: 'signed-in',
})
})
it('clears both the identity session and public account on sign-out', async () => {
const identity = fakeIdentityAdapter(account)
const controller = createYunlefunAuthController({
@@ -133,6 +153,7 @@ describe('yunlefun auth controller', () => {
function fakeAuthorizationAdapter(
initial: SsoAuthorizationResult | null = null,
startedResult: SsoAuthorizationResult | null = null,
): YunlefunAuthorizationAdapter & { started: number } {
return {
started: 0,
@@ -141,6 +162,7 @@ function fakeAuthorizationAdapter(
},
async start() {
this.started += 1
return startedResult
},
}
}

View File

@@ -1,10 +1,13 @@
import type { SsoAuthorizationResult } from '@yunlefun/sso'
import type { YunlefunSsoConfig } from '../app/utils/yunlefunSsoConfig'
import { Browser } from '@capacitor/browser'
import { Preferences } from '@capacitor/preferences'
import { SsoIdentityAdoptionError } from '@yunlefun/sso/browser'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createCloudbaseIdentityAdapter,
createNativeYunlefunAuthorizationAdapter,
createWebYunlefunAuthorizationAdapter,
} from '../app/adapters/yunlefunAuth'
vi.mock('@capacitor/browser', () => ({
@@ -35,6 +38,65 @@ const config: YunlefunSsoConfig = {
},
}
const hostAuthorization: SsoAuthorizationResult = {
ok: true,
clientId: 'cook-web',
code: 'c'.repeat(43),
codeVerifier: 'v'.repeat(43),
issuer: 'https://www.yunle.fun',
nonce: 'n'.repeat(43),
redirectUri: 'https://cook.yunyoujun.cn/auth/callback',
scope: ['identity:bootstrap'],
}
describe('web authorization adapter', () => {
it('uses the YunLeFun host consent sheet without starting a redirect', async () => {
const requestHostAuthorization = vi.fn(async () => hostAuthorization)
const startRedirect = vi.fn()
const adapter = createWebYunlefunAuthorizationAdapter(config, {
requestHostAuthorization,
startRedirect,
})
await expect(adapter.start()).resolves.toEqual(hostAuthorization)
expect(requestHostAuthorization).toHaveBeenCalledWith({
...config.redirect,
prompt: 'consent',
})
expect(startRedirect).not.toHaveBeenCalled()
})
it('falls back to the top-level web redirect outside the YunLeFun host', async () => {
const startRedirect = vi.fn()
const adapter = createWebYunlefunAuthorizationAdapter(config, {
requestHostAuthorization: async () => null,
startRedirect,
})
await expect(adapter.start()).resolves.toBeNull()
expect(startRedirect).toHaveBeenCalledWith(config.redirect)
})
it('does not bypass an explicit host denial with a web redirect', async () => {
const startRedirect = vi.fn()
const adapter = createWebYunlefunAuthorizationAdapter(config, {
requestHostAuthorization: async () => {
throw new SsoIdentityAdoptionError(
'Host authorization was denied',
'access_denied',
)
},
startRedirect,
})
await expect(adapter.start()).resolves.toEqual({
ok: false,
reason: 'access_denied',
})
expect(startRedirect).not.toHaveBeenCalled()
})
})
describe('cloudbase identity adapter', () => {
it('rejects a sign-out response that contains a CloudBase error', async () => {
const identity = createCloudbaseIdentityAdapter(config, {