feat(auth): prefer YunLeFun host authorization
This commit is contained in:
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user