Files
what-to-eat/apps/web/app/utils/nativeSso.ts
T

212 lines
5.6 KiB
TypeScript

import type {
AcquisitionContext,
RedirectSsoOptions,
SsoAuthorizationResult,
SsoRedirectResult,
} from '@yunlefun/sso'
import {
buildSsoRequestUrl,
deriveSsoPkceChallenge,
isSsoPkceVerifier,
parseSsoRedirectResult,
readSsoClientId,
readSsoRedirectUri,
readSsoScope,
sanitizeAcquisitionContext,
YLF_DEFAULT_SSO_ORIGIN,
} from '@yunlefun/sso'
const TRANSACTION_TTL_MS = 10 * 60 * 1000
const NONCE_RE = /^[\w-]{32,128}$/
const BASE64_PADDING_RE = /=+$/
export interface NativeSsoTransaction {
clientId: string
codeVerifier: string
createdAt: number
issuer: string
nonce: string
redirectUri: string
scope: string[]
acquisition?: AcquisitionContext
}
export interface NativeSsoTransactionStore {
clear: () => Promise<void>
load: () => Promise<NativeSsoTransaction | null>
save: (transaction: NativeSsoTransaction) => Promise<void>
}
export interface NativeSsoDependencies {
now?: () => number
randomBase64Url?: () => string
}
export async function beginNativeSsoRedirect(
options: RedirectSsoOptions,
store: NativeSsoTransactionStore,
dependencies: NativeSsoDependencies = {},
): Promise<string> {
const issuer = new URL(options.ssoOrigin ?? YLF_DEFAULT_SSO_ORIGIN).origin
const random = dependencies.randomBase64Url ?? (() => randomBase64Url(32))
const nonce = random()
const codeVerifier = random()
const codeChallenge = await deriveSsoPkceChallenge(codeVerifier)
const acquisition = sanitizeAcquisitionContext(options.acquisition)
const authorizationUrl = buildSsoRequestUrl({
clientId: options.clientId,
codeChallenge,
nonce,
redirectUri: options.redirectUri,
scope: options.scope,
ssoOrigin: issuer,
...(options.prompt ? { prompt: options.prompt } : {}),
...(acquisition ? { acquisition } : {}),
})
await store.save({
clientId: options.clientId,
codeVerifier,
createdAt: (dependencies.now ?? Date.now)(),
issuer,
nonce,
redirectUri: options.redirectUri,
scope: [...options.scope],
...(acquisition ? { acquisition } : {}),
})
return authorizationUrl
}
export async function openNativeSsoRedirect(
options: RedirectSsoOptions,
store: NativeSsoTransactionStore,
open: (url: string) => Promise<void>,
dependencies: NativeSsoDependencies = {},
): Promise<void> {
const url = await beginNativeSsoRedirect(options, store, dependencies)
try {
await open(url)
}
catch (error) {
await store.clear()
throw error
}
}
export async function consumeNativeSsoCallback(
callbackUrl: string,
store: NativeSsoTransactionStore,
dependencies: Pick<NativeSsoDependencies, 'now'> = {},
): Promise<SsoAuthorizationResult | Extract<SsoRedirectResult, { ok: false }> | null> {
const stored = await store.load()
await store.clear()
const transaction = normalizeTransaction(stored)
const now = (dependencies.now ?? Date.now)()
if (!transaction
|| transaction.createdAt + TRANSACTION_TTL_MS <= now
|| !callbackMatchesRedirect(callbackUrl, transaction.redirectUri)) {
return null
}
const result = parseSsoRedirectResult({
expectedIssuer: transaction.issuer,
expectedNonce: transaction.nonce,
hash: new URL(callbackUrl).hash,
})
if (!result?.ok)
return result
return Object.freeze({
...result,
clientId: transaction.clientId,
codeVerifier: transaction.codeVerifier,
redirectUri: transaction.redirectUri,
scope: Object.freeze([...transaction.scope]),
...(transaction.acquisition ? { acquisition: transaction.acquisition } : {}),
})
}
export function isNativeSsoCallbackUrl(callbackUrl: string, redirectUri: string): boolean {
return callbackMatchesRedirect(callbackUrl, redirectUri)
}
export function stripNativeSsoCallbackHash(callbackUrl: string): string {
try {
const url = new URL(callbackUrl)
return `${url.pathname}${url.search}`
}
catch {
return ''
}
}
function callbackMatchesRedirect(callbackUrl: string, redirectUri: string): boolean {
try {
const callback = new URL(callbackUrl)
const callbackWithoutHash = `${callback.origin}${callback.pathname}${callback.search}`
return callback.protocol === 'https:'
&& !callback.username
&& !callback.password
&& Boolean(callback.hash)
&& callbackWithoutHash === redirectUri
}
catch {
return false
}
}
function normalizeTransaction(value: NativeSsoTransaction | null): NativeSsoTransaction | null {
if (!value)
return null
const clientId = readSsoClientId(value.clientId)
const redirectUri = readSsoRedirectUri(value.redirectUri)
const scope = readSsoScope(value.scope.join(' '))
let issuer = ''
try {
const issuerUrl = new URL(value.issuer)
issuer = issuerUrl.protocol === 'https:' && issuerUrl.origin === value.issuer
? issuerUrl.origin
: ''
}
catch {
issuer = ''
}
if (!clientId
|| !redirectUri
|| scope.length === 0
|| !issuer
|| !NONCE_RE.test(value.nonce)
|| !isSsoPkceVerifier(value.codeVerifier)
|| !Number.isSafeInteger(value.createdAt)) {
return null
}
const acquisition = sanitizeAcquisitionContext(value.acquisition)
return {
clientId,
codeVerifier: value.codeVerifier,
createdAt: value.createdAt,
issuer,
nonce: value.nonce,
redirectUri,
scope,
...(acquisition ? { acquisition } : {}),
}
}
function randomBase64Url(byteLength: number): string {
const bytes = new Uint8Array(byteLength)
globalThis.crypto.getRandomValues(bytes)
let binary = ''
for (const byte of bytes)
binary += String.fromCharCode(byte)
return globalThis.btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(BASE64_PADDING_RE, '')
}