542 lines
20 KiB
TypeScript
542 lines
20 KiB
TypeScript
import { createError } from 'h3'
|
|
import { createHmac, randomUUID } from 'node:crypto'
|
|
import { db } from '~/drizzle/db'
|
|
import { systemSettings } from '~/drizzle/schema'
|
|
import { asc } from 'drizzle-orm'
|
|
import {
|
|
isSafeAggregateOAuthUrl,
|
|
getAggregateOAuthLoginTypesOrDefault
|
|
} from './oauth-providers'
|
|
|
|
export interface OAuthUserInfo {
|
|
id: string
|
|
username: string
|
|
email?: string
|
|
name?: string
|
|
avatar?: string
|
|
}
|
|
|
|
export interface OAuthStrategy {
|
|
/**
|
|
* 获取授权跳转 URL
|
|
*/
|
|
getAuthorizeUrl(redirectUri: string, state: string): string
|
|
|
|
/**
|
|
* 使用 code 换取 access_token
|
|
*/
|
|
exchangeToken(code: string, redirectUri: string): Promise<string>
|
|
|
|
/**
|
|
* 获取用户信息
|
|
*/
|
|
getUserInfo(accessToken: string): Promise<OAuthUserInfo>
|
|
}
|
|
|
|
// GitHub 策略实现
|
|
const githubStrategy: OAuthStrategy = {
|
|
getAuthorizeUrl(redirectUri: string, state: string) {
|
|
const clientId = process.env.GITHUB_CLIENT_ID
|
|
if (!clientId)
|
|
throw createError({ statusCode: 500, message: 'GitHub Client ID not configured' })
|
|
|
|
return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${encodeURIComponent(state)}`
|
|
},
|
|
|
|
async exchangeToken(code: string, redirectUri: string) {
|
|
const clientId = process.env.GITHUB_CLIENT_ID
|
|
const clientSecret = process.env.GITHUB_CLIENT_SECRET
|
|
|
|
if (!clientId || !clientSecret) {
|
|
throw createError({ statusCode: 500, message: 'GitHub config missing' })
|
|
}
|
|
|
|
try {
|
|
const tokenResponse = await $fetch<any>('https://github.com/login/oauth/access_token', {
|
|
method: 'POST',
|
|
body: {
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code,
|
|
redirect_uri: redirectUri
|
|
},
|
|
headers: { Accept: 'application/json' }
|
|
})
|
|
|
|
if (tokenResponse.error) {
|
|
console.error('GitHub Token Error:', tokenResponse.error)
|
|
throw new Error('授权失败,请重试')
|
|
}
|
|
|
|
if (!tokenResponse.access_token) {
|
|
console.error('GitHub Token Response missing access_token')
|
|
throw new Error('未能获取访问令牌')
|
|
}
|
|
return tokenResponse.access_token
|
|
} catch (e: any) {
|
|
console.error('GitHub token exchange failed', e.message || e)
|
|
const errorMessage = e.data?.error_description || e.message || '令牌请求失败'
|
|
throw new Error(errorMessage)
|
|
}
|
|
},
|
|
|
|
async getUserInfo(accessToken: string) {
|
|
try {
|
|
const userInfo = await $fetch<any>('https://api.github.com/user', {
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
})
|
|
|
|
return {
|
|
id: String(userInfo.id),
|
|
username: userInfo.login,
|
|
name: userInfo.name,
|
|
email: userInfo.email,
|
|
avatar: userInfo.avatar_url
|
|
}
|
|
} catch (e: any) {
|
|
console.error('GitHub user info failed', e)
|
|
throw new Error('获取用户信息失败')
|
|
}
|
|
}
|
|
}
|
|
|
|
// Casdoor 策略实现
|
|
const casdoorStrategy: OAuthStrategy = {
|
|
getAuthorizeUrl(redirectUri: string, state: string) {
|
|
const clientId = process.env.CASDOOR_CLIENT_ID
|
|
const endpoint = process.env.CASDOOR_ENDPOINT
|
|
if (!clientId || !endpoint)
|
|
throw createError({ statusCode: 500, message: 'Casdoor config missing' })
|
|
|
|
return `${endpoint}/login/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=read&state=${encodeURIComponent(state)}`
|
|
},
|
|
|
|
async exchangeToken(code: string, redirectUri: string) {
|
|
const clientId = process.env.CASDOOR_CLIENT_ID
|
|
const clientSecret = process.env.CASDOOR_CLIENT_SECRET
|
|
const endpoint = process.env.CASDOOR_ENDPOINT
|
|
|
|
if (!clientId || !clientSecret || !endpoint) {
|
|
throw createError({ statusCode: 500, message: 'Casdoor config missing' })
|
|
}
|
|
|
|
try {
|
|
const tokenResponse = await $fetch<any>(`${endpoint}/login/oauth/access_token`, {
|
|
method: 'POST',
|
|
body: {
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code,
|
|
grant_type: 'authorization_code',
|
|
redirect_uri: redirectUri
|
|
},
|
|
headers: { Accept: 'application/json' }
|
|
})
|
|
|
|
if (tokenResponse.error) {
|
|
console.error('Casdoor Token Error:', tokenResponse.error, tokenResponse.error_description)
|
|
throw new Error(tokenResponse.error_description || '授权失败,请重试')
|
|
}
|
|
|
|
if (!tokenResponse.access_token) {
|
|
console.error('Casdoor Token Response missing access_token')
|
|
throw new Error('未能获取访问令牌,请检查 Casdoor 配置')
|
|
}
|
|
return tokenResponse.access_token
|
|
} catch (e: any) {
|
|
console.error('Casdoor token exchange failed', e.message || e)
|
|
const errorMessage = e.data?.error_description || e.message || '令牌请求失败'
|
|
throw new Error(errorMessage)
|
|
}
|
|
},
|
|
|
|
async getUserInfo(accessToken: string) {
|
|
const endpoint = process.env.CASDOOR_ENDPOINT
|
|
if (!endpoint) throw createError({ statusCode: 500, message: 'Casdoor config missing' })
|
|
|
|
let userInfo: any = {}
|
|
try {
|
|
// Casdoor 用户信息端点通常是 /api/get-account 或 /api/userinfo
|
|
// 标准 OIDC 使用 /userinfo
|
|
try {
|
|
userInfo = await $fetch(`${endpoint}/api/userinfo`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
})
|
|
} catch (e) {
|
|
// 尝试备用端点
|
|
userInfo = await $fetch(`${endpoint}/api/get-account`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
})
|
|
}
|
|
|
|
return {
|
|
id: String(userInfo.id || userInfo.sub),
|
|
username: userInfo.name || userInfo.preferred_username || userInfo.username,
|
|
name: userInfo.displayName || userInfo.name,
|
|
email: userInfo.email,
|
|
avatar: userInfo.avatar
|
|
}
|
|
} catch (e: any) {
|
|
console.error('Casdoor user info failed', e)
|
|
throw new Error('获取用户信息失败')
|
|
}
|
|
}
|
|
}
|
|
|
|
// Google 策略实现
|
|
const googleStrategy: OAuthStrategy = {
|
|
getAuthorizeUrl(redirectUri: string, state: string) {
|
|
const clientId = process.env.GOOGLE_CLIENT_ID
|
|
if (!clientId)
|
|
throw createError({ statusCode: 500, message: 'Google Client ID not configured' })
|
|
|
|
return `https://accounts.google.com/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=openid%20email%20profile&state=${encodeURIComponent(state)}`
|
|
},
|
|
|
|
async exchangeToken(code: string, redirectUri: string) {
|
|
const clientId = process.env.GOOGLE_CLIENT_ID
|
|
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
|
|
|
|
if (!clientId || !clientSecret) {
|
|
throw createError({ statusCode: 500, message: 'Google config missing' })
|
|
}
|
|
|
|
try {
|
|
const tokenResponse = await $fetch<any>('https://oauth2.googleapis.com/token', {
|
|
method: 'POST',
|
|
body: {
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code,
|
|
grant_type: 'authorization_code',
|
|
redirect_uri: redirectUri
|
|
},
|
|
headers: { Accept: 'application/json' }
|
|
})
|
|
|
|
if (tokenResponse.error) {
|
|
console.error('Google Token Error:', tokenResponse.error)
|
|
throw new Error('授权失败,请重试')
|
|
}
|
|
|
|
if (!tokenResponse.access_token) {
|
|
console.error('Google Token Response missing access_token')
|
|
throw new Error('未能获取访问令牌')
|
|
}
|
|
return tokenResponse.access_token
|
|
} catch (e: any) {
|
|
console.error('Google token exchange failed', e.message || e)
|
|
const errorMessage = e.data?.error_description || e.message || '令牌请求失败'
|
|
throw new Error(errorMessage)
|
|
}
|
|
},
|
|
|
|
async getUserInfo(accessToken: string) {
|
|
try {
|
|
const userInfo = await $fetch<any>('https://www.googleapis.com/oauth2/v3/userinfo', {
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
})
|
|
|
|
return {
|
|
id: userInfo.sub,
|
|
username: userInfo.email, // Google doesn't have a username, use email
|
|
name: userInfo.name,
|
|
email: userInfo.email,
|
|
avatar: userInfo.picture
|
|
}
|
|
} catch (e: any) {
|
|
console.error('Google user info failed', e)
|
|
throw new Error('获取用户信息失败')
|
|
}
|
|
}
|
|
}
|
|
|
|
// 策略注册表
|
|
const strategies: Record<string, OAuthStrategy> = {
|
|
github: githubStrategy,
|
|
casdoor: casdoorStrategy,
|
|
google: googleStrategy
|
|
}
|
|
|
|
async function loadAggregateSettings() {
|
|
const row = await db
|
|
.select({
|
|
aggregateOAuthEnabled: systemSettings.aggregateOAuthEnabled,
|
|
aggregateOAuthAppId: systemSettings.aggregateOAuthAppId,
|
|
aggregateOAuthAppKey: systemSettings.aggregateOAuthAppKey,
|
|
aggregateOAuthLoginType: systemSettings.aggregateOAuthLoginType,
|
|
aggregateOAuthEndpoint: systemSettings.aggregateOAuthEndpoint
|
|
})
|
|
.from(systemSettings)
|
|
.orderBy(asc(systemSettings.id))
|
|
.limit(1)
|
|
const base = row[0] ?? null
|
|
const envAppId = process.env.AGGREGATE_OAUTH_APP_ID
|
|
const envAppKey = process.env.AGGREGATE_OAUTH_APP_KEY
|
|
const envLoginType = process.env.AGGREGATE_OAUTH_LOGIN_TYPE
|
|
const envEndpoint = process.env.AGGREGATE_OAUTH_ENDPOINT
|
|
const hasEnvConfig = !!(envAppId && envAppKey && envEndpoint)
|
|
const dbEnabled = base?.aggregateOAuthEnabled
|
|
const dbHasConfig = !!(
|
|
base?.aggregateOAuthAppId &&
|
|
base?.aggregateOAuthAppKey &&
|
|
base?.aggregateOAuthEndpoint
|
|
)
|
|
const enabled = Boolean(dbEnabled ?? hasEnvConfig)
|
|
const appId = (base?.aggregateOAuthAppId || envAppId || '') as string
|
|
const appKey = (base?.aggregateOAuthAppKey || envAppKey || '') as string
|
|
const loginType = ((base?.aggregateOAuthLoginType as string) || envLoginType || '') as string
|
|
const endpoint = ((base?.aggregateOAuthEndpoint as string) || envEndpoint || '') as string
|
|
|
|
return {
|
|
aggregateOAuthEnabled: enabled && !!(appId && appKey && endpoint),
|
|
aggregateOAuthAppId: appId,
|
|
aggregateOAuthAppKey: appKey,
|
|
aggregateOAuthLoginType: loginType,
|
|
aggregateOAuthEndpoint: endpoint,
|
|
_dbHasConfig: dbHasConfig,
|
|
_hasEnvConfig: hasEnvConfig
|
|
} as any
|
|
}
|
|
|
|
function normalizeEndpoint(endpoint: string): string {
|
|
return endpoint.replace(/\/+$/, '')
|
|
}
|
|
|
|
function aggregateSign(params: Record<string, string>, appKey: string): string {
|
|
const sorted = Object.keys(params)
|
|
.filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== '')
|
|
.sort()
|
|
.map((k) => `${k}=${params[k]}`)
|
|
.join('&')
|
|
return createHmac('sha256', appKey).update(sorted).digest('hex')
|
|
}
|
|
|
|
const auxxUserInfoCache = new Map<string, any>()
|
|
|
|
function isAuxxEndpoint(endpoint: string): boolean {
|
|
return /(^|\.)auxx\.cn(\/|$)/i.test(endpoint)
|
|
}
|
|
|
|
const auxxStrategy: OAuthStrategy = {
|
|
async getAuthorizeUrl(redirectUri: string, state: string) {
|
|
const settings = await loadAggregateSettings()
|
|
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
|
|
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
|
}
|
|
if (!isSafeAggregateOAuthUrl(settings.aggregateOAuthEndpoint)) {
|
|
throw createError({ statusCode: 500, message: '聚合登录端点不安全或格式错误' })
|
|
}
|
|
const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType)
|
|
const loginType = loginTypes[0] || 'qq'
|
|
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
|
const loginQuery = new URLSearchParams({
|
|
act: 'login',
|
|
appid: settings.aggregateOAuthAppId,
|
|
appkey: settings.aggregateOAuthAppKey,
|
|
type: loginType,
|
|
redirect_uri: redirectUri
|
|
}).toString()
|
|
try {
|
|
const resp: any = await $fetch(`${endpoint}/connect.php?${loginQuery}`, {
|
|
headers: { Accept: 'application/json' }
|
|
})
|
|
if (resp.code !== 0 || !resp.url) {
|
|
throw new Error(resp.msg || '获取登录跳转地址失败')
|
|
}
|
|
const sep = resp.url.includes('?') ? '&' : '?'
|
|
return `${resp.url}${sep}state=${encodeURIComponent(state)}`
|
|
} catch (e: any) {
|
|
console.error('Auxx OAuth authorize URL failed', e)
|
|
throw new Error(e.message || '获取登录跳转地址失败')
|
|
}
|
|
},
|
|
async exchangeToken(code: string, redirectUri: string) {
|
|
const settings = await loadAggregateSettings()
|
|
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
|
|
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
|
}
|
|
const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType)
|
|
const loginType = loginTypes[0] || 'qq'
|
|
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
|
const cbQuery = new URLSearchParams({
|
|
act: 'callback',
|
|
appid: settings.aggregateOAuthAppId,
|
|
appkey: settings.aggregateOAuthAppKey,
|
|
type: loginType,
|
|
code
|
|
}).toString()
|
|
try {
|
|
const resp: any = await $fetch(`${endpoint}/connect.php?${cbQuery}`, {
|
|
headers: { Accept: 'application/json' }
|
|
})
|
|
if (resp.code !== 0 || !resp.access_token || !resp.social_uid) {
|
|
throw new Error(resp.msg || '回调换取用户信息失败')
|
|
}
|
|
const cacheKey = `${settings.aggregateOAuthAppId}:${resp.access_token}`
|
|
auxxUserInfoCache.set(cacheKey, resp)
|
|
setTimeout(() => auxxUserInfoCache.delete(cacheKey), 10 * 60 * 1000)
|
|
return String(resp.access_token)
|
|
} catch (e: any) {
|
|
console.error('Auxx OAuth token exchange failed', e)
|
|
throw new Error(e.message || '回调换取用户信息失败')
|
|
}
|
|
},
|
|
async getUserInfo(accessToken: string) {
|
|
const settings = await loadAggregateSettings()
|
|
const appId = settings?.aggregateOAuthAppId || ''
|
|
const cacheKey = `${appId}:${accessToken}`
|
|
let resp = auxxUserInfoCache.get(cacheKey)
|
|
if (!resp) {
|
|
resp = Array.from(auxxUserInfoCache.values()).find((r: any) => r.access_token === accessToken)
|
|
}
|
|
if (!resp || resp.code !== 0 || !resp.social_uid) {
|
|
throw new Error('获取用户信息失败')
|
|
}
|
|
return {
|
|
id: String(resp.social_uid),
|
|
username: resp.nickname || `wx_${String(resp.social_uid).slice(0, 8)}`,
|
|
name: resp.nickname || undefined,
|
|
email: undefined,
|
|
avatar: resp.faceimg || undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
const standardAggregateStrategy: OAuthStrategy = {
|
|
async getAuthorizeUrl(redirectUri: string, state: string) {
|
|
const settings = await loadAggregateSettings()
|
|
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
|
|
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
|
}
|
|
if (!isSafeAggregateOAuthUrl(settings.aggregateOAuthEndpoint)) {
|
|
throw createError({ statusCode: 500, message: '聚合登录端点不安全或格式错误' })
|
|
}
|
|
|
|
const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType)
|
|
const loginType = loginTypes[0] || 'qq'
|
|
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
const nonce = randomUUID().replace(/-/g, '')
|
|
const baseParams: Record<string, string> = {
|
|
app_id: settings.aggregateOAuthAppId,
|
|
response_type: 'code',
|
|
redirect_uri: redirectUri,
|
|
state,
|
|
login_type: loginType,
|
|
timestamp,
|
|
nonce
|
|
}
|
|
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
|
const query = new URLSearchParams({ ...baseParams, sign }).toString()
|
|
return `${endpoint}/oauth/authorize?${query}`
|
|
},
|
|
|
|
async exchangeToken(code: string, redirectUri: string) {
|
|
const settings = await loadAggregateSettings()
|
|
if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) {
|
|
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
|
}
|
|
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
const nonce = randomUUID().replace(/-/g, '')
|
|
const baseParams: Record<string, string> = {
|
|
app_id: settings.aggregateOAuthAppId,
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: redirectUri,
|
|
timestamp,
|
|
nonce
|
|
}
|
|
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
|
|
|
try {
|
|
const tokenResponse = await $fetch<any>(`${endpoint}/oauth/token`, {
|
|
method: 'POST',
|
|
body: { ...baseParams, sign },
|
|
headers: { Accept: 'application/json' }
|
|
})
|
|
if (tokenResponse.error) {
|
|
throw new Error(tokenResponse.error_description || tokenResponse.error || '令牌请求失败')
|
|
}
|
|
const accessToken = tokenResponse.access_token || tokenResponse.accessToken
|
|
if (!accessToken) {
|
|
throw new Error('未能获取访问令牌')
|
|
}
|
|
return accessToken
|
|
} catch (e: any) {
|
|
console.error('Aggregate OAuth token exchange failed', e)
|
|
throw new Error(e.message || '令牌请求失败')
|
|
}
|
|
},
|
|
|
|
async getUserInfo(accessToken: string) {
|
|
const settings = await loadAggregateSettings()
|
|
if (!settings?.aggregateOAuthAppId || !settings?.aggregateOAuthAppKey || !settings?.aggregateOAuthEndpoint) {
|
|
throw createError({ statusCode: 500, message: '聚合登录未配置' })
|
|
}
|
|
const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint)
|
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
const nonce = randomUUID().replace(/-/g, '')
|
|
const baseParams: Record<string, string> = {
|
|
app_id: settings.aggregateOAuthAppId,
|
|
access_token: accessToken,
|
|
timestamp,
|
|
nonce
|
|
}
|
|
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
|
|
|
|
try {
|
|
const userInfo = await $fetch<any>(`${endpoint}/oauth/userinfo`, {
|
|
method: 'POST',
|
|
body: { ...baseParams, sign },
|
|
headers: { Accept: 'application/json' }
|
|
})
|
|
return {
|
|
id: String(userInfo.id || userInfo.openid || userInfo.sub || userInfo.userId || ''),
|
|
username: userInfo.username || userInfo.nickname || userInfo.name || String(userInfo.id || 'user'),
|
|
name: userInfo.name || userInfo.nickname || undefined,
|
|
email: userInfo.email || undefined,
|
|
avatar: userInfo.avatar || userInfo.avatar_url || userInfo.headimgurl || undefined
|
|
}
|
|
} catch (e: any) {
|
|
console.error('Aggregate OAuth user info failed', e)
|
|
throw new Error('获取用户信息失败')
|
|
}
|
|
}
|
|
}
|
|
|
|
const aggregateStrategy: OAuthStrategy = {
|
|
async getAuthorizeUrl(redirectUri: string, state: string) {
|
|
const settings = await loadAggregateSettings()
|
|
const endpoint = settings?.aggregateOAuthEndpoint || ''
|
|
return isAuxxEndpoint(endpoint)
|
|
? auxxStrategy.getAuthorizeUrl(redirectUri, state)
|
|
: standardAggregateStrategy.getAuthorizeUrl(redirectUri, state)
|
|
},
|
|
async exchangeToken(code: string, redirectUri: string) {
|
|
const settings = await loadAggregateSettings()
|
|
const endpoint = settings?.aggregateOAuthEndpoint || ''
|
|
return isAuxxEndpoint(endpoint)
|
|
? auxxStrategy.exchangeToken(code, redirectUri)
|
|
: standardAggregateStrategy.exchangeToken(code, redirectUri)
|
|
},
|
|
async getUserInfo(accessToken: string) {
|
|
const settings = await loadAggregateSettings()
|
|
const endpoint = settings?.aggregateOAuthEndpoint || ''
|
|
return isAuxxEndpoint(endpoint)
|
|
? auxxStrategy.getUserInfo(accessToken)
|
|
: standardAggregateStrategy.getUserInfo(accessToken)
|
|
}
|
|
}
|
|
|
|
strategies.aggregate = aggregateStrategy
|
|
|
|
export const getOAuthStrategy = (provider: string): OAuthStrategy => {
|
|
const strategy = strategies[provider]
|
|
if (!strategy) {
|
|
throw createError({ statusCode: 400, message: `Unknown provider: ${provider}` })
|
|
}
|
|
return strategy
|
|
}
|