mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(kiro): 支持 AWS SSO OIDC 设备授权流程
为 Kiro provider 新增 Device Authorization 模式,替代原先禁用 OAuth 的限制: - 后端实现 device-authorize / device-poll 端点,完成客户端注册、设备码签发、token 轮询和自动建 Key - 前端 OAuthAccountDialog 新增设备授权 UI,支持 Start URL/Region 输入、验证链接跳转、倒计时和自动轮询
This commit is contained in:
@@ -55,3 +55,48 @@ export async function importProviderRefreshToken(
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
// Device Authorization (AWS SSO OIDC)
|
||||
|
||||
export interface DeviceAuthorizeRequest {
|
||||
start_url?: string
|
||||
region?: string
|
||||
proxy_node_id?: string
|
||||
}
|
||||
|
||||
export interface DeviceAuthorizeResponse {
|
||||
session_id: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
verification_uri_complete: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface DevicePollRequest {
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface DevicePollResponse {
|
||||
status: 'pending' | 'authorized' | 'slow_down' | 'expired' | 'error'
|
||||
key_id?: string
|
||||
email?: string
|
||||
error?: string
|
||||
replaced?: boolean
|
||||
}
|
||||
|
||||
export async function startDeviceAuthorize(
|
||||
providerId: string,
|
||||
data: DeviceAuthorizeRequest
|
||||
): Promise<DeviceAuthorizeResponse> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/device-authorize`, data)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function pollDeviceAuthorize(
|
||||
providerId: string,
|
||||
data: DevicePollRequest
|
||||
): Promise<DevicePollResponse> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/device-poll`, data)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
@@ -63,16 +63,14 @@
|
||||
<div class="flex rounded-lg border border-border p-0.5 bg-muted/30">
|
||||
<button
|
||||
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
:disabled="isKiroProvider"
|
||||
:class="[
|
||||
mode === 'oauth'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
isKiroProvider ? 'opacity-50 cursor-not-allowed' : ''
|
||||
]"
|
||||
@click="switchMode('oauth')"
|
||||
>
|
||||
获取授权
|
||||
{{ isKiroProvider ? '设备授权' : '获取授权' }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all"
|
||||
@@ -87,65 +85,196 @@
|
||||
|
||||
<!-- Tab 内容:grid 叠放,高度取较高者 -->
|
||||
<div class="grid [&>*]:col-start-1 [&>*]:row-start-1">
|
||||
<!-- ===== 获取授权 ===== -->
|
||||
<!-- ===== 获取授权 / 设备授权 ===== -->
|
||||
<div
|
||||
class="space-y-4 transition-opacity duration-150"
|
||||
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<div
|
||||
v-if="oauth.starting && !oauth.authorization_url"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
正在准备授权...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="oauth.authorization_url">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
|
||||
<span class="text-xs font-medium">前往授权</span>
|
||||
</div>
|
||||
<div class="flex gap-2 pl-6">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="oauthBusy"
|
||||
@click="openAuthorizationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3 mr-1" />
|
||||
打开
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="oauthBusy"
|
||||
@click="copyToClipboard(oauth.authorization_url)"
|
||||
>
|
||||
<Copy class="w-3 h-3 mr-1" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
|
||||
<span class="text-xs font-medium">粘贴回调 URL</span>
|
||||
</div>
|
||||
<div class="pl-6">
|
||||
<Textarea
|
||||
v-model="oauth.callback_url"
|
||||
:disabled="oauthBusy"
|
||||
placeholder="http://localhost:xxx/callback?code=..."
|
||||
class="min-h-[120px] text-xs font-mono break-all !rounded-xl"
|
||||
<!-- Kiro: 设备授权模式 -->
|
||||
<template v-if="isKiroProvider">
|
||||
<!-- 初始状态:输入 Start URL / Region + 开始 -->
|
||||
<div
|
||||
v-if="!device.session_id && !device.starting"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">Start URL</label>
|
||||
<input
|
||||
v-model="device.start_url"
|
||||
type="text"
|
||||
placeholder="https://view.awsapps.com/start"
|
||||
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono"
|
||||
spellcheck="false"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium">Region</label>
|
||||
<input
|
||||
v-model="device.region"
|
||||
type="text"
|
||||
placeholder="us-east-1"
|
||||
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="!device.start_url.trim()"
|
||||
@click="startDeviceAuth"
|
||||
>
|
||||
开始授权
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 发起中 -->
|
||||
<div
|
||||
v-else-if="device.starting"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
正在注册设备...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 等待用户授权 -->
|
||||
<template v-else-if="device.session_id && device.status === 'pending'">
|
||||
<div class="rounded-xl border border-border bg-muted/20 p-5">
|
||||
<div class="flex flex-col items-center text-center space-y-4">
|
||||
<!-- 脉冲动画图标 -->
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||||
<div class="relative w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<ExternalLink class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示文字 -->
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">
|
||||
在浏览器中完成授权
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
授权完成后此页面将自动更新
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 倒计时 -->
|
||||
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div class="animate-spin rounded-full h-3 w-3 border-[1.5px] border-primary/30 border-t-primary" />
|
||||
<span>剩余 {{ deviceCountdownFormatted }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-2 w-full">
|
||||
<Button
|
||||
class="flex-1"
|
||||
size="sm"
|
||||
@click="openDeviceVerificationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5 mr-1.5" />
|
||||
打开授权页面
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="copyToClipboard(device.verification_uri_complete)"
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 错误/过期 -->
|
||||
<div
|
||||
v-else-if="device.status === 'error' || device.status === 'expired'"
|
||||
>
|
||||
<div class="rounded-xl border border-destructive/20 bg-destructive/5 p-5">
|
||||
<div class="flex flex-col items-center text-center space-y-3">
|
||||
<div class="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertCircle class="w-5 h-5 text-destructive" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-destructive">
|
||||
{{ device.status === 'expired' ? '授权已过期' : '授权失败' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ device.error || '请重试' }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="resetDevice"
|
||||
>
|
||||
重新开始
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 非 Kiro: 原有 OAuth 流程 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="oauth.starting && !oauth.authorization_url"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-3" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
正在准备授权...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="oauth.authorization_url">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
|
||||
<span class="text-xs font-medium">前往授权</span>
|
||||
</div>
|
||||
<div class="flex gap-2 pl-6">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="oauthBusy"
|
||||
@click="openAuthorizationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3 mr-1" />
|
||||
打开
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="oauthBusy"
|
||||
@click="copyToClipboard(oauth.authorization_url)"
|
||||
>
|
||||
<Copy class="w-3 h-3 mr-1" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
|
||||
<span class="text-xs font-medium">粘贴回调 URL</span>
|
||||
</div>
|
||||
<div class="pl-6">
|
||||
<Textarea
|
||||
v-model="oauth.callback_url"
|
||||
:disabled="oauthBusy"
|
||||
placeholder="http://localhost:xxx/callback?code=..."
|
||||
class="min-h-[120px] text-xs font-mono break-all !rounded-xl"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -260,14 +389,14 @@
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'oauth'"
|
||||
v-if="mode === 'oauth' && !isKiroProvider"
|
||||
:disabled="!canCompleteOAuth"
|
||||
@click="handleCompleteOAuth"
|
||||
>
|
||||
{{ oauth.completing ? '验证中...' : '验证' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
v-if="mode === 'import'"
|
||||
:disabled="!canImport"
|
||||
@click="handleImport"
|
||||
>
|
||||
@@ -278,9 +407,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { Dialog, Button, Textarea, Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
||||
import { UserPlus, Copy, ExternalLink, Upload, Globe } from 'lucide-vue-next'
|
||||
import { UserPlus, Copy, ExternalLink, Upload, Globe, AlertCircle } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
@@ -289,6 +418,8 @@ import {
|
||||
completeProviderLevelOAuth,
|
||||
importProviderRefreshToken,
|
||||
batchImportOAuth,
|
||||
startDeviceAuthorize,
|
||||
pollDeviceAuthorize,
|
||||
} from '@/api/endpoints'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
@@ -348,6 +479,42 @@ function createInitialOAuthState(): OAuthState {
|
||||
|
||||
const oauth = ref<OAuthState>(createInitialOAuthState())
|
||||
|
||||
// 设备授权状态
|
||||
interface DeviceAuthState {
|
||||
start_url: string
|
||||
region: string
|
||||
starting: boolean
|
||||
session_id: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
verification_uri_complete: string
|
||||
expires_at: number // unix timestamp (ms)
|
||||
interval: number // 轮询间隔 (秒)
|
||||
status: 'idle' | 'pending' | 'authorized' | 'expired' | 'error'
|
||||
error: string
|
||||
}
|
||||
|
||||
function createInitialDeviceState(): DeviceAuthState {
|
||||
return {
|
||||
start_url: '',
|
||||
region: 'us-east-1',
|
||||
starting: false,
|
||||
session_id: '',
|
||||
user_code: '',
|
||||
verification_uri: '',
|
||||
verification_uri_complete: '',
|
||||
expires_at: 0,
|
||||
interval: 5,
|
||||
status: 'idle',
|
||||
error: '',
|
||||
}
|
||||
}
|
||||
|
||||
const device = ref<DeviceAuthState>(createInitialDeviceState())
|
||||
let devicePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const deviceCountdown = ref(0)
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 导入状态
|
||||
const importText = ref('')
|
||||
const importFileName = ref('')
|
||||
@@ -361,6 +528,13 @@ const isOpen = computed(() => props.open)
|
||||
|
||||
const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() === 'kiro')
|
||||
|
||||
const deviceCountdownFormatted = computed(() => {
|
||||
const s = deviceCountdown.value
|
||||
const min = Math.floor(s / 60)
|
||||
const sec = s % 60
|
||||
return `${min}:${String(sec).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const oauthBusy = computed(() =>
|
||||
oauth.value.starting || oauth.value.completing
|
||||
)
|
||||
@@ -376,8 +550,29 @@ const canImport = computed(() => {
|
||||
return text.trim().length > 0 && !importing.value
|
||||
})
|
||||
|
||||
function stopDevicePolling() {
|
||||
if (devicePollTimer) {
|
||||
clearTimeout(devicePollTimer)
|
||||
devicePollTimer = null
|
||||
}
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetDevice() {
|
||||
stopDevicePolling()
|
||||
const { start_url, region } = device.value
|
||||
device.value = createInitialDeviceState()
|
||||
device.value.start_url = start_url
|
||||
device.value.region = region
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
oauth.value = createInitialOAuthState()
|
||||
stopDevicePolling()
|
||||
device.value = createInitialDeviceState()
|
||||
importText.value = ''
|
||||
importFileName.value = ''
|
||||
manualPasteText.value = ''
|
||||
@@ -386,7 +581,7 @@ function resetForm() {
|
||||
showManualInput.value = false
|
||||
proxyPopoverOpen.value = false
|
||||
selectedProxyNodeId.value = ''
|
||||
mode.value = isKiroProvider.value ? 'import' : 'oauth'
|
||||
mode.value = 'oauth'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
@@ -405,14 +600,8 @@ function clearImport() {
|
||||
function switchMode(newMode: DialogMode) {
|
||||
if (mode.value === newMode) return
|
||||
|
||||
if (newMode === 'oauth' && isKiroProvider.value) {
|
||||
showError('Kiro \u4e0d\u652f\u6301 OAuth \u6388\u6743\uff0c\u8bf7\u4f7f\u7528\u5bfc\u5165\u6388\u6743', '\u63d0\u793a')
|
||||
mode.value = 'import'
|
||||
return
|
||||
}
|
||||
|
||||
mode.value = newMode
|
||||
if (newMode === 'oauth' && !oauth.value.authorization_url && !oauth.value.starting) {
|
||||
if (newMode === 'oauth' && !isKiroProvider.value && !oauth.value.authorization_url && !oauth.value.starting) {
|
||||
initOAuth()
|
||||
}
|
||||
}
|
||||
@@ -608,13 +797,107 @@ async function handleImport() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==== 设备授权 ====
|
||||
|
||||
function openDeviceVerificationUrl() {
|
||||
const url = device.value.verification_uri_complete || device.value.verification_uri
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
deviceCountdown.value = Math.max(0, Math.round((device.value.expires_at - Date.now()) / 1000))
|
||||
countdownTimer = setInterval(() => {
|
||||
deviceCountdown.value = Math.max(0, Math.round((device.value.expires_at - Date.now()) / 1000))
|
||||
if (deviceCountdown.value <= 0 && countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function startDeviceAuth() {
|
||||
if (!props.providerId) return
|
||||
device.value.starting = true
|
||||
device.value.error = ''
|
||||
try {
|
||||
const resp = await startDeviceAuthorize(props.providerId, {
|
||||
start_url: device.value.start_url.trim() || undefined,
|
||||
region: device.value.region.trim() || undefined,
|
||||
proxy_node_id: selectedProxyNodeId.value || undefined,
|
||||
})
|
||||
device.value.session_id = resp.session_id
|
||||
device.value.user_code = resp.user_code
|
||||
device.value.verification_uri = resp.verification_uri
|
||||
device.value.verification_uri_complete = resp.verification_uri_complete
|
||||
device.value.expires_at = Date.now() + resp.expires_in * 1000
|
||||
device.value.interval = resp.interval || 5
|
||||
device.value.status = 'pending'
|
||||
startCountdown()
|
||||
scheduleDevicePoll()
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '发起设备授权失败')
|
||||
showError(errorMessage, '错误')
|
||||
device.value.status = 'error'
|
||||
device.value.error = errorMessage
|
||||
} finally {
|
||||
device.value.starting = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDevicePoll() {
|
||||
if (devicePollTimer) clearTimeout(devicePollTimer)
|
||||
devicePollTimer = setTimeout(() => pollDevice(), device.value.interval * 1000)
|
||||
}
|
||||
|
||||
async function pollDevice() {
|
||||
if (!props.providerId || !device.value.session_id || device.value.status !== 'pending') return
|
||||
|
||||
try {
|
||||
const result = await pollDeviceAuthorize(props.providerId, {
|
||||
session_id: device.value.session_id,
|
||||
})
|
||||
|
||||
switch (result.status) {
|
||||
case 'authorized':
|
||||
stopDevicePolling()
|
||||
device.value.status = 'authorized'
|
||||
success(result.email ? `授权成功: ${result.email}` : '授权成功,账号已添加')
|
||||
emit('saved')
|
||||
handleClose()
|
||||
return
|
||||
case 'pending':
|
||||
scheduleDevicePoll()
|
||||
return
|
||||
case 'slow_down':
|
||||
device.value.interval = Math.min(device.value.interval + 5, 30)
|
||||
scheduleDevicePoll()
|
||||
return
|
||||
case 'expired':
|
||||
stopDevicePolling()
|
||||
device.value.status = 'expired'
|
||||
device.value.error = result.error || '设备码已过期'
|
||||
return
|
||||
case 'error':
|
||||
stopDevicePolling()
|
||||
device.value.status = 'error'
|
||||
device.value.error = result.error || '授权失败'
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
// 网络错误等,继续轮询
|
||||
scheduleDevicePoll()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopDevicePolling()
|
||||
})
|
||||
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen) {
|
||||
// 预加载代理节点列表
|
||||
proxyNodesStore.ensureLoaded()
|
||||
if (isKiroProvider.value) {
|
||||
mode.value = 'import'
|
||||
} else {
|
||||
if (!isKiroProvider.value) {
|
||||
initOAuth()
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1964,3 +1964,403 @@ async def _batch_import_kiro_internal(
|
||||
failed=failed_count,
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Device Authorization (AWS SSO OIDC - RFC 8628)
|
||||
# ==============================================================================
|
||||
|
||||
_DEVICE_AUTH_SESSION_PREFIX = "device_auth_session:"
|
||||
_DEVICE_AUTH_SESSION_TTL_BUFFER = 60 # Redis TTL = expires_in + buffer
|
||||
|
||||
_KIRO_SSO_SCOPES = [
|
||||
"codewhisperer:completions",
|
||||
"codewhisperer:analysis",
|
||||
"codewhisperer:conversations",
|
||||
"codewhisperer:transformations",
|
||||
"codewhisperer:taskassist",
|
||||
]
|
||||
_KIRO_SSO_DEFAULT_START_URL = "https://view.awsapps.com/start"
|
||||
_KIRO_SSO_DEFAULT_REGION = "us-east-1"
|
||||
|
||||
|
||||
class DeviceAuthorizeRequest(BaseModel):
|
||||
start_url: str = Field(
|
||||
_KIRO_SSO_DEFAULT_START_URL,
|
||||
description="IAM Identity Center Start URL(如 https://your-org.awsapps.com/start)",
|
||||
)
|
||||
region: str = Field(
|
||||
_KIRO_SSO_DEFAULT_REGION,
|
||||
pattern=r"^[a-z0-9-]+$",
|
||||
description="IAM Identity Center 部署 region",
|
||||
)
|
||||
proxy_node_id: str | None = Field(None, description="代理节点 ID")
|
||||
|
||||
|
||||
class DeviceAuthorizeResponse(BaseModel):
|
||||
session_id: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
class DevicePollRequest(BaseModel):
|
||||
session_id: str = Field(..., description="设备授权会话 ID")
|
||||
|
||||
|
||||
class DevicePollResponse(BaseModel):
|
||||
status: str # pending / authorized / slow_down / expired / error
|
||||
key_id: str | None = None
|
||||
email: str | None = None
|
||||
error: str | None = None
|
||||
replaced: bool = False
|
||||
|
||||
|
||||
async def _sso_oidc_post(
|
||||
url: str,
|
||||
body: dict[str, Any],
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> dict[str, Any]:
|
||||
"""POST to AWS SSO OIDC endpoint, return parsed JSON or raise."""
|
||||
from src.clients.http_client import HTTPClientPool
|
||||
|
||||
client = await HTTPClientPool.get_proxy_client(proxy_config=proxy_config)
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=body,
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
# 返回原始 JSON 让调用方处理错误码
|
||||
try:
|
||||
err_data = resp.json()
|
||||
logger.warning(
|
||||
"SSO OIDC request failed: {} returned {} | body={}",
|
||||
url,
|
||||
resp.status_code,
|
||||
err_data,
|
||||
)
|
||||
return {"_error": True, "_status": resp.status_code, **err_data}
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"SSO OIDC request failed: {} returned {} | text={}",
|
||||
url,
|
||||
resp.status_code,
|
||||
resp.text[:200],
|
||||
)
|
||||
raise InvalidRequestException(f"AWS SSO OIDC 请求失败: HTTP {resp.status_code}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _register_sso_oidc_client(
|
||||
region: str,
|
||||
*,
|
||||
start_url: str,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""注册 AWS SSO OIDC 客户端 (public, device_code grant)。"""
|
||||
url = f"https://oidc.{region}.amazonaws.com/client/register"
|
||||
body = {
|
||||
"clientName": "Aether Gateway",
|
||||
"clientType": "public",
|
||||
"scopes": _KIRO_SSO_SCOPES,
|
||||
"grantTypes": [
|
||||
"urn:ietf:params:oauth:grant-type:device_code",
|
||||
"refresh_token",
|
||||
],
|
||||
"issuerUrl": start_url,
|
||||
}
|
||||
result = await _sso_oidc_post(url, body, proxy_config=proxy_config)
|
||||
if result.get("_error"):
|
||||
error_desc = result.get("error_description") or result.get("error") or "unknown"
|
||||
raise InvalidRequestException(f"注册 OIDC 客户端失败: {error_desc}")
|
||||
return result
|
||||
|
||||
|
||||
async def _start_device_authorization(
|
||||
region: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
*,
|
||||
start_url: str,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""发起设备授权,返回 device_code / user_code / verification_uri 等。"""
|
||||
url = f"https://oidc.{region}.amazonaws.com/device_authorization"
|
||||
body = {
|
||||
"clientId": client_id,
|
||||
"clientSecret": client_secret,
|
||||
"startUrl": start_url,
|
||||
}
|
||||
result = await _sso_oidc_post(url, body, proxy_config=proxy_config)
|
||||
if result.get("_error"):
|
||||
error_desc = result.get("error_description") or result.get("error") or "unknown"
|
||||
raise InvalidRequestException(f"发起设备授权失败: {error_desc}")
|
||||
return result
|
||||
|
||||
|
||||
async def _poll_device_token(
|
||||
region: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
device_code: str,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""轮询设备授权 token 端点,返回原始 JSON(含成功或错误信息)。"""
|
||||
url = f"https://oidc.{region}.amazonaws.com/token"
|
||||
body = {
|
||||
"clientId": client_id,
|
||||
"clientSecret": client_secret,
|
||||
"grantType": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"deviceCode": device_code,
|
||||
}
|
||||
return await _sso_oidc_post(url, body, proxy_config=proxy_config)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/providers/{provider_id}/device-authorize",
|
||||
response_model=DeviceAuthorizeResponse,
|
||||
)
|
||||
async def device_authorize(
|
||||
provider_id: str,
|
||||
payload: DeviceAuthorizeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> DeviceAuthorizeResponse:
|
||||
"""发起 AWS SSO OIDC 设备授权流程(仅限 Kiro provider)。"""
|
||||
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException("Provider 不存在", "provider")
|
||||
provider_type = _require_fixed_provider(provider)
|
||||
if provider_type != ProviderType.KIRO.value:
|
||||
raise InvalidRequestException("设备授权仅支持 Kiro provider")
|
||||
|
||||
proxy_config, key_proxy = _resolve_proxy_for_oauth(
|
||||
getattr(provider, "proxy", None), payload.proxy_node_id
|
||||
)
|
||||
|
||||
region = (payload.region or _KIRO_SSO_DEFAULT_REGION).strip()
|
||||
start_url = (payload.start_url or _KIRO_SSO_DEFAULT_START_URL).strip()
|
||||
|
||||
# 1. 注册 OIDC 客户端
|
||||
client_reg = await _register_sso_oidc_client(
|
||||
region, start_url=start_url, proxy_config=proxy_config
|
||||
)
|
||||
client_id = client_reg["clientId"]
|
||||
client_secret = client_reg["clientSecret"]
|
||||
|
||||
# 2. 发起设备授权
|
||||
device_auth = await _start_device_authorization(
|
||||
region, client_id, client_secret, start_url=start_url, proxy_config=proxy_config
|
||||
)
|
||||
device_code = device_auth.get("deviceCode") or device_auth.get("device_code") or ""
|
||||
user_code = device_auth.get("userCode") or device_auth.get("user_code") or ""
|
||||
verification_uri = (
|
||||
device_auth.get("verificationUri")
|
||||
or device_auth.get("verification_uri")
|
||||
or device_auth.get("verificationUrl")
|
||||
or ""
|
||||
)
|
||||
verification_uri_complete = (
|
||||
device_auth.get("verificationUriComplete")
|
||||
or device_auth.get("verification_uri_complete")
|
||||
or device_auth.get("verificationUrlComplete")
|
||||
or verification_uri
|
||||
)
|
||||
expires_in = int(device_auth.get("expiresIn") or device_auth.get("expires_in") or 600)
|
||||
interval = int(device_auth.get("interval") or 5)
|
||||
|
||||
# 3. 存入 Redis
|
||||
redis = await get_redis_client(require_redis=True)
|
||||
assert redis is not None
|
||||
|
||||
session_id = secrets.token_urlsafe(24)
|
||||
session_data = {
|
||||
"provider_id": provider_id,
|
||||
"region": region,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"device_code": device_code,
|
||||
"interval": interval,
|
||||
"expires_at": int(time.time()) + expires_in,
|
||||
"status": "pending",
|
||||
"proxy_node_id": (payload.proxy_node_id or "").strip() or None,
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
redis_key = f"{_DEVICE_AUTH_SESSION_PREFIX}{session_id}"
|
||||
await redis.setex(
|
||||
redis_key,
|
||||
expires_in + _DEVICE_AUTH_SESSION_TTL_BUFFER,
|
||||
json.dumps(session_data),
|
||||
)
|
||||
|
||||
return DeviceAuthorizeResponse(
|
||||
session_id=session_id,
|
||||
user_code=user_code,
|
||||
verification_uri=verification_uri,
|
||||
verification_uri_complete=verification_uri_complete,
|
||||
expires_in=expires_in,
|
||||
interval=interval,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/providers/{provider_id}/device-poll",
|
||||
response_model=DevicePollResponse,
|
||||
)
|
||||
async def device_poll(
|
||||
provider_id: str,
|
||||
payload: DevicePollRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> DevicePollResponse:
|
||||
"""轮询设备授权状态,授权成功时自动创建 Key。"""
|
||||
redis = await get_redis_client(require_redis=True)
|
||||
assert redis is not None
|
||||
|
||||
redis_key = f"{_DEVICE_AUTH_SESSION_PREFIX}{payload.session_id}"
|
||||
raw = await redis.get(redis_key)
|
||||
if not raw:
|
||||
return DevicePollResponse(status="expired", error="会话不存在或已过期")
|
||||
|
||||
session = json.loads(raw)
|
||||
if session.get("provider_id") != provider_id:
|
||||
return DevicePollResponse(status="error", error="会话与 Provider 不匹配")
|
||||
|
||||
# 已完成的会话直接返回缓存结果
|
||||
cached_status = session.get("status")
|
||||
if cached_status == "authorized":
|
||||
return DevicePollResponse(
|
||||
status="authorized",
|
||||
key_id=session.get("key_id"),
|
||||
email=session.get("email"),
|
||||
replaced=session.get("replaced", False),
|
||||
)
|
||||
if cached_status in ("expired", "error"):
|
||||
return DevicePollResponse(status=cached_status, error=session.get("error_msg"))
|
||||
|
||||
# 检查是否已过期
|
||||
if int(time.time()) > session.get("expires_at", 0):
|
||||
session["status"] = "expired"
|
||||
await redis.setex(redis_key, 30, json.dumps(session))
|
||||
return DevicePollResponse(status="expired", error="设备码已过期")
|
||||
|
||||
# 解析代理
|
||||
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
||||
proxy_config, key_proxy = _resolve_proxy_for_oauth(
|
||||
getattr(provider, "proxy", None) if provider else None,
|
||||
session.get("proxy_node_id"),
|
||||
)
|
||||
|
||||
# 轮询 token 端点
|
||||
region = session["region"]
|
||||
token_result = await _poll_device_token(
|
||||
region=region,
|
||||
client_id=session["client_id"],
|
||||
client_secret=session["client_secret"],
|
||||
device_code=session["device_code"],
|
||||
proxy_config=proxy_config,
|
||||
)
|
||||
|
||||
# 处理错误响应
|
||||
if token_result.get("_error"):
|
||||
error_code = token_result.get("error", "")
|
||||
if error_code == "authorization_pending":
|
||||
return DevicePollResponse(status="pending")
|
||||
if error_code == "slow_down":
|
||||
return DevicePollResponse(status="slow_down")
|
||||
if error_code == "expired_token":
|
||||
session["status"] = "expired"
|
||||
await redis.setex(redis_key, 30, json.dumps(session))
|
||||
return DevicePollResponse(status="expired", error="设备码已过期")
|
||||
if error_code == "access_denied":
|
||||
session["status"] = "error"
|
||||
session["error_msg"] = "用户拒绝授权"
|
||||
await redis.setex(redis_key, 30, json.dumps(session))
|
||||
return DevicePollResponse(status="error", error="用户拒绝授权")
|
||||
# 其他错误
|
||||
err_msg = token_result.get("error_description") or error_code or "未知错误"
|
||||
return DevicePollResponse(status="error", error=err_msg)
|
||||
|
||||
# 成功拿到 token,执行导入流程
|
||||
access_token_raw = token_result.get("accessToken") or ""
|
||||
refresh_token_raw = token_result.get("refreshToken") or ""
|
||||
expires_in = token_result.get("expiresIn")
|
||||
|
||||
if not access_token_raw or not refresh_token_raw:
|
||||
return DevicePollResponse(
|
||||
status="error", error="token 响应缺少 accessToken 或 refreshToken"
|
||||
)
|
||||
|
||||
# 构建 KiroAuthConfig 并验证
|
||||
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
|
||||
from src.services.provider.adapters.kiro.token_manager import refresh_access_token
|
||||
|
||||
cfg = KiroAuthConfig(
|
||||
auth_method="idc",
|
||||
refresh_token=refresh_token_raw,
|
||||
client_id=session["client_id"],
|
||||
client_secret=session["client_secret"],
|
||||
region=region,
|
||||
access_token=access_token_raw,
|
||||
expires_at=(int(time.time()) + int(expires_in) if expires_in else 0),
|
||||
)
|
||||
cfg.provider_type = ProviderType.KIRO.value
|
||||
|
||||
# 用 refresh_token 验证有效性并获取最新 token
|
||||
try:
|
||||
verified_access_token, new_cfg = await refresh_access_token(cfg, proxy_config=proxy_config)
|
||||
except Exception as e:
|
||||
logger.warning("设备授权 token 验证失败: {}", e)
|
||||
return DevicePollResponse(status="error", error=f"token 验证失败: {type(e).__name__}")
|
||||
|
||||
# 获取邮箱
|
||||
email = await _fetch_kiro_email(new_cfg.to_dict(), proxy_config=proxy_config)
|
||||
if email and not new_cfg.email:
|
||||
new_cfg.email = email
|
||||
|
||||
# 检查重复
|
||||
try:
|
||||
existing_key = _check_duplicate_oauth_account(db, provider_id, new_cfg.to_dict())
|
||||
except InvalidRequestException as e:
|
||||
return DevicePollResponse(status="error", error=str(e))
|
||||
|
||||
# 创建/更新 Key
|
||||
replaced = False
|
||||
name = _build_kiro_key_name(email, new_cfg.auth_method, new_cfg.refresh_token)
|
||||
api_formats = _get_provider_api_formats(provider) if provider else []
|
||||
|
||||
if existing_key:
|
||||
new_key = _update_existing_oauth_key(
|
||||
db, existing_key, verified_access_token, new_cfg.to_dict(), proxy=key_proxy
|
||||
)
|
||||
replaced = True
|
||||
else:
|
||||
new_key = _create_oauth_key(
|
||||
db,
|
||||
provider_id=provider_id,
|
||||
name=name,
|
||||
access_token=verified_access_token,
|
||||
auth_config=new_cfg.to_dict(),
|
||||
api_formats=api_formats,
|
||||
proxy=key_proxy,
|
||||
)
|
||||
|
||||
await _trigger_auto_fetch_models([str(new_key.id)])
|
||||
|
||||
# 更新 Redis session 为已完成(短 TTL 让前端最后一次轮询能拿到结果)
|
||||
session["status"] = "authorized"
|
||||
session["key_id"] = str(new_key.id)
|
||||
session["email"] = email
|
||||
session["replaced"] = replaced
|
||||
await redis.setex(redis_key, 60, json.dumps(session))
|
||||
|
||||
return DevicePollResponse(
|
||||
status="authorized",
|
||||
key_id=str(new_key.id),
|
||||
email=email,
|
||||
replaced=replaced,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user