Files
what-to-eat/test/nativeSso.test.ts
T

164 lines
5.5 KiB
TypeScript

import type {
NativeSsoTransaction,
NativeSsoTransactionStore,
} from '../apps/web/app/utils/nativeSso'
import { encodeSsoRedirectResult } from '@yunlefun/sso'
import { describe, expect, it } from 'vitest'
import {
beginNativeSsoRedirect,
consumeNativeSsoCallback,
openNativeSsoRedirect,
stripNativeSsoCallbackHash,
} from '../apps/web/app/utils/nativeSso'
class MemoryTransactionStore implements NativeSsoTransactionStore {
transaction: NativeSsoTransaction | null = null
async clear(): Promise<void> {
this.transaction = null
}
async load(): Promise<NativeSsoTransaction | null> {
return this.transaction
}
async save(transaction: NativeSsoTransaction): Promise<void> {
this.transaction = transaction
}
}
const options = {
clientId: 'cook-mobile',
redirectUri: 'https://cook.yunyoujun.cn/auth/callback?platform=native',
scope: ['identity:bootstrap'],
ssoOrigin: 'https://www.yunle.fun',
} as const
describe('native SSO redirect', () => {
it('retains only valid optional acquisition through the native PKCE callback', async () => {
const touch = {
capturedAt: 1000,
landingHost: 'cook.yunle.fun',
landingPath: '/random',
referrerHost: 'www.baidu.com',
utmSource: '',
utmMedium: '',
utmCampaign: '',
}
const acquisition = { version: 1 as const, firstTouch: touch, registerTouch: touch }
const store = new MemoryTransactionStore()
const url = await beginNativeSsoRedirect({ ...options, acquisition }, store, {
now: () => 1000,
randomBase64Url: sequence('n'.repeat(43), 'v'.repeat(43)),
})
expect(JSON.parse(new URL(url).searchParams.get('acquisition')!)).toEqual(acquisition)
const result = await consumeNativeSsoCallback(callbackUrl({
code: 'c'.repeat(43),
issuer: 'https://www.yunle.fun',
nonce: 'n'.repeat(43),
}), store, { now: () => 2000 })
expect(result).toMatchObject({ ok: true, acquisition })
})
it('accepts a matching claimed HTTPS callback exactly once', async () => {
const store = new MemoryTransactionStore()
const authorizationUrl = await beginNativeSsoRedirect(options, store, {
now: () => 1_000,
randomBase64Url: sequence('n'.repeat(43), 'v'.repeat(43)),
})
const request = new URL(authorizationUrl)
expect(request.origin + request.pathname).toBe('https://www.yunle.fun/auth/sso')
expect(request.searchParams.get('client_id')).toBe('cook-mobile')
expect(request.searchParams.get('redirect_uri')).toBe(options.redirectUri)
expect(request.searchParams.get('nonce')).toBe('n'.repeat(43))
expect(request.searchParams.get('code_challenge_method')).toBe('S256')
const callback = callbackUrl({
code: 'c'.repeat(43),
issuer: 'https://www.yunle.fun',
nonce: 'n'.repeat(43),
})
await expect(consumeNativeSsoCallback(callback, store, { now: () => 2_000 })).resolves.toEqual({
ok: true,
clientId: 'cook-mobile',
code: 'c'.repeat(43),
codeVerifier: 'v'.repeat(43),
issuer: 'https://www.yunle.fun',
nonce: 'n'.repeat(43),
redirectUri: options.redirectUri,
scope: ['identity:bootstrap'],
})
await expect(consumeNativeSsoCallback(callback, store, { now: () => 2_000 })).resolves.toBeNull()
})
it('rejects an altered callback path and consumes the pending transaction', async () => {
const store = new MemoryTransactionStore()
await beginNativeSsoRedirect(options, store, {
now: () => 1_000,
randomBase64Url: sequence('n'.repeat(43), 'v'.repeat(43)),
})
const callback = callbackUrl({
code: 'c'.repeat(43),
issuer: 'https://www.yunle.fun',
nonce: 'n'.repeat(43),
}).replace('/auth/callback', '/evil/callback')
await expect(consumeNativeSsoCallback(callback, store, { now: () => 2_000 })).resolves.toBeNull()
await expect(consumeNativeSsoCallback(callbackUrl({
code: 'c'.repeat(43),
issuer: 'https://www.yunle.fun',
nonce: 'n'.repeat(43),
}), store, { now: () => 2_000 })).resolves.toBeNull()
})
it('rejects an expired transaction', async () => {
const store = new MemoryTransactionStore()
await beginNativeSsoRedirect(options, store, {
now: () => 1_000,
randomBase64Url: sequence('n'.repeat(43), 'v'.repeat(43)),
})
await expect(consumeNativeSsoCallback(callbackUrl({
code: 'c'.repeat(43),
issuer: 'https://www.yunle.fun',
nonce: 'n'.repeat(43),
}), store, { now: () => 601_001 })).resolves.toBeNull()
})
it('clears the pending transaction when the system browser cannot open', async () => {
const store = new MemoryTransactionStore()
await expect(openNativeSsoRedirect(options, store, async () => {
throw new Error('browser unavailable')
}, {
now: () => 1_000,
randomBase64Url: sequence('n'.repeat(43), 'v'.repeat(43)),
})).rejects.toThrow('browser unavailable')
expect(store.transaction).toBeNull()
})
it('removes authorization data from a browser fallback URL', () => {
expect(stripNativeSsoCallbackHash(
`${options.redirectUri}#ylf_sso=sensitive-authorization-result`,
)).toBe('/auth/callback?platform=native')
})
})
function callbackUrl(input: { code: string, issuer: string, nonce: string }): string {
const result = encodeSsoRedirectResult({
code: input.code,
issuer: input.issuer,
nonce: input.nonce,
ok: true,
})
return `${options.redirectUri}#ylf_sso=${result}`
}
function sequence(...values: string[]): () => string {
let index = 0
return () => values[index++] ?? ''
}