feat(cli): add OAuth device flow login (#857)

* feat(cli): add OAuth device flow login

Signed-off-by: Danny5487401 <64348131+Danny5487401@users.noreply.github.com>

* fix(cli): avoid browser launch in headless login

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): complete device flow runtime path

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: Danny5487401 <64348131+Danny5487401@users.noreply.github.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
Danny 2026-09-17 16:29:20 +08:00 committed by GitHub
parent 0d4f149e8a
commit 7cf9f22182
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 706 additions and 36 deletions

View file

@ -6,6 +6,8 @@ All notable CLI behavior changes are documented in this file.
### Added
- Add OAuth Device Flow to `skillhub login` when no API token is supplied, including best-effort
browser launch, a `--no-open` headless mode, bounded polling, and non-secret JSON progress output.
- Add the `pi` agent profile, displayed as Pi, with `--agent pi`, project-level
`<project>/.pi/skills/`, and user-level `~/.pi/agent/skills/` support.
- Add the user-level `astudio` agent profile, displayed as AStudio, with automatic detection of

View file

@ -18,8 +18,8 @@ bun add -g @astron-team/skillhub
## 🚀 Quick Start
```bash
# Login
skillhub login --token sk_xxx
# Log in interactively with OAuth Device Flow
skillhub login
# Search skills
skillhub search pdf
@ -68,7 +68,8 @@ set SKILLHUB_REGISTRY=https://skillhub.example.com
## 🔐 Authentication
Token resolution priority:
`skillhub login` uses OAuth Device Flow when no API token is supplied. Token resolution priority for
explicit token-based login and all other authenticated commands is:
1. `--token <token>` command-line argument
2. `SKILLHUB_TOKEN` environment variable
@ -77,14 +78,23 @@ Token resolution priority:
### Login
```bash
# Login with API token
skillhub login --token sk_xxx
# Interactive login: opens the registry's verification page and displays a user code
skillhub login
# Login to specific registry
# Interactive login on a remote/headless terminal
skillhub login --no-open --registry https://skillhub.example.com
# Non-interactive login with an API token
skillhub login --token sk_xxx --registry https://skillhub.example.com
```
`login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`.
During interactive login, complete authentication in the browser and enter the displayed user code.
The CLI polls only until the server-provided expiry. It then validates the issued token, stores it in
`~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`. `--no-open`
suppresses automatic browser launch while retaining the verification URL and code in the terminal.
Token-based login remains available for CI and other non-interactive automation. In both modes,
credentials are persisted only after `whoami` succeeds.
Both files are updated non-destructively: SkillHub CLI changes only its own `tokens` and `registry`
fields and preserves unknown fields written by other compatible tools. This allows tools that share
@ -461,7 +471,7 @@ Update mechanism:
|---------|-------------|
| `skillhub help [command]` | Display help information |
| `skillhub version [--json]`, `skillhub --version`, `skillhub -v` | Display CLI version |
| `skillhub login --token <token> [--registry <url>] [--json]` | Save token and registry configuration |
| `skillhub login [--no-open] [--token <token>] [--registry <url>] [--json]` | Log in with OAuth Device Flow or an API token |
| `skillhub logout [--registry <url>] [--json]` | Remove token for specified registry |
| `skillhub whoami [--registry <url>] [--token <token>] [--json]` | Validate current token and display user information |
| `skillhub search <query> [--registry <url>] [--token <token>] [--limit <n>] [--json]` | Search published skills |
@ -491,7 +501,10 @@ Update mechanism:
# Verify token validity
skillhub whoami
# Re-login
# Re-login interactively
skillhub login
# Or use a token for non-interactive automation
skillhub login --token sk_xxx
```

View file

@ -7,6 +7,20 @@ export interface WhoAmIResponse {
email?: string
}
export interface DeviceCodeResponse {
deviceCode: string
userCode: string
verificationUri: string
expiresIn: number
interval: number
}
export interface DeviceTokenResponse {
accessToken?: string | null
tokenType?: string | null
error?: string | null
}
export interface SearchItem {
namespace: string
slug: string
@ -146,6 +160,14 @@ export class SkillHubClient {
return this.getJson('/auth/whoami')
}
async requestDeviceCode(): Promise<DeviceCodeResponse> {
return this.postPublicJson('/api/v1/auth/device/code')
}
async pollDeviceToken(deviceCode: string): Promise<DeviceTokenResponse> {
return this.postPublicJson('/api/v1/auth/device/token', { deviceCode })
}
async serverMetadata(): Promise<ServerMetadata> {
let response: Response
try {
@ -347,6 +369,20 @@ export class SkillHubClient {
return this.handleJsonResponse<T>(response)
}
private async postPublicJson<T>(path: string, body?: Record<string, unknown>): Promise<T> {
let response: Response
try {
response = await this.fetchImpl(`${this.registry}${path}`, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
...(body ? { body: JSON.stringify(body) } : {})
})
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
return this.handleJsonResponse<T>(response)
}
private async handleJsonResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
throw await this.createResponseError(response, 'json')

View file

@ -14,9 +14,13 @@ export const commands = {
examples: ['skillhub version', 'skillhub version --json', 'skillhub --version', 'skillhub -v']
},
login: {
summary: 'Save registry and token',
usage: 'skillhub login [--token <token>] [--registry <url>] [--json]',
examples: ['skillhub login --token sk_xxx', 'skillhub login --registry https://skillhub.example.com']
summary: 'Log in with OAuth Device Flow or an API token',
usage: 'skillhub login [--token <token>] [--no-open] [--registry <url>] [--json]',
examples: [
'skillhub login --registry https://skillhub.example.com',
'skillhub login --registry https://skillhub.example.com --no-open',
'skillhub login --token sk_xxx'
]
},
logout: {
summary: 'Remove local token',

View file

@ -2,11 +2,13 @@ import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { AuthService } from '../services/auth-service'
import { resolveRegistry, resolveToken } from '../services/registry-service'
import { openExternalUrl } from '../platform/browser'
export interface LoginCommandOptions {
registry?: string
token?: string
json?: boolean
noOpen?: boolean
}
export async function loginCommand(options: LoginCommandOptions): Promise<string> {
@ -14,7 +16,21 @@ export async function loginCommand(options: LoginCommandOptions): Promise<string
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
const result = await new AuthService(configStore, credentialsStore).login(registry, token)
const authService = new AuthService(configStore, credentialsStore)
const result = token
? await authService.login(registry, token)
: await authService.loginWithDeviceFlow(registry, async details => {
const opened = options.noOpen ? false : openExternalUrl(details.verificationUri)
const message = options.json
? JSON.stringify({ event: 'device_authorization', ...details, browserOpened: opened })
: [
`Authorize this device at: ${details.verificationUri}`,
`Code: ${details.userCode}`,
opened ? 'Browser launch requested. If no browser opened, use the URL above.' : 'Open the URL in a browser to continue.',
`Waiting for authorization (expires in ${details.expiresIn}s)...`
].join('\n')
process.stderr.write(`${message}\n`)
})
return options.json
? JSON.stringify({ ok: true, registry, handle: result.handle })
: `Logged in to ${registry} as ${result.handle}`

View file

@ -232,12 +232,16 @@ cli
})
cli
.command('login', 'Save registry and token')
.command('login', 'Log in with OAuth Device Flow or an API token')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--no-open', 'Do not open the verification URL in a browser')
.option('--json', 'Output JSON')
.action((options: { registry?: string; token?: string; json?: boolean }) => {
return runCommand(() => loginCommand(options), Boolean(options.json))
.action((options: { registry?: string; token?: string; open?: boolean; json?: boolean }) => {
return runCommand(
() => loginCommand({ ...options, noOpen: options.open === false }),
Boolean(options.json)
)
})
cli

View file

@ -0,0 +1,56 @@
import { spawn } from 'node:child_process'
type SupportedPlatform = 'darwin' | 'linux' | 'win32'
type Launch = (command: string, args: string[]) => boolean
interface OpenExternalUrlOptions {
platform?: NodeJS.Platform
launch?: Launch
env?: NodeJS.ProcessEnv
}
export function canOpenBrowser(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env
): boolean {
if (env.CI || env.SSH_CONNECTION || env.SSH_TTY) return false
if (platform === 'linux') return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY)
return platform === 'darwin' || platform === 'win32'
}
function launchDetached(command: string, args: string[]): boolean {
try {
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
child.on('error', () => {})
child.unref()
return true
} catch {
return false
}
}
function launcherFor(platform: SupportedPlatform, url: string): [string, string[]] {
if (platform === 'darwin') return ['open', [url]]
if (platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler', url]]
return ['xdg-open', [url]]
}
export function openExternalUrl(url: string, options: OpenExternalUrlOptions = {}): boolean {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return false
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
const platform = options.platform ?? process.platform
if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') return false
if (!canOpenBrowser(platform, options.env ?? process.env)) return false
const [command, args] = launcherFor(platform, parsed.toString())
try {
return (options.launch ?? launchDetached)(command, args)
} catch {
return false
}
}

View file

@ -5,21 +5,82 @@ import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
export class AuthService {
private readonly clientFactory: (registry: string, token?: string) => SkillHubClient
private readonly sleep: (milliseconds: number) => Promise<void>
private readonly now: () => number
constructor(
private readonly configStore: ConfigStore,
private readonly credentialsStore: CredentialsStore
) {}
private readonly credentialsStore: CredentialsStore,
options: {
clientFactory?: (registry: string, token?: string) => SkillHubClient
sleep?: (milliseconds: number) => Promise<void>
now?: () => number
} = {}
) {
this.clientFactory = options.clientFactory ?? ((registry, token) => new SkillHubClient(registry, token))
this.sleep = options.sleep ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)))
this.now = options.now ?? Date.now
}
async login(registry: string, token?: string): Promise<{ handle: string }> {
if (!token) {
throw new CliError('token is required', EXIT.usage, { next: 'pass --token, set SKILLHUB_TOKEN, or use interactive login' })
}
const user = await new SkillHubClient(registry, token).whoami()
async login(registry: string, token: string): Promise<{ handle: string }> {
const user = await this.clientFactory(registry, token).whoami()
await this.configStore.setRegistry(registry)
await this.credentialsStore.setToken(registry, token)
return { handle: user.handle }
}
async loginWithDeviceFlow(
registry: string,
onVerification: (details: { userCode: string; verificationUri: string; expiresIn: number }) => Promise<void>
): Promise<{ handle: string }> {
const client = this.clientFactory(registry)
const device = await client.requestDeviceCode()
if (
!device.deviceCode ||
!device.userCode ||
!device.verificationUri ||
!Number.isFinite(device.expiresIn) ||
!Number.isFinite(device.interval) ||
device.expiresIn <= 0 ||
device.interval <= 0
) {
throw new CliError('registry returned invalid device authorization data', EXIT.auth, { registry })
}
let verificationUri: string
try {
const parsedVerificationUri = new URL(device.verificationUri, `${registry}/`)
if (parsedVerificationUri.protocol !== 'http:' && parsedVerificationUri.protocol !== 'https:') {
throw new Error('unsupported protocol')
}
verificationUri = parsedVerificationUri.toString()
} catch {
throw new CliError('registry returned invalid device verification URL', EXIT.auth, { registry })
}
await onVerification({ userCode: device.userCode, verificationUri, expiresIn: device.expiresIn })
const expiresAt = this.now() + device.expiresIn * 1_000
const intervalMilliseconds = device.interval * 1_000
while (this.now() < expiresAt) {
await this.sleep(Math.min(intervalMilliseconds, Math.max(0, expiresAt - this.now())))
if (this.now() >= expiresAt) break
const response = await client.pollDeviceToken(device.deviceCode)
if (response.accessToken) {
if (response.tokenType && response.tokenType.toLowerCase() !== 'bearer') {
throw new CliError('registry returned unsupported device token type', EXIT.auth, { registry })
}
return this.login(registry, response.accessToken)
}
if (response.error && response.error !== 'authorization_pending') {
throw new CliError(`device authorization failed: ${response.error}`, EXIT.auth, { registry })
}
}
throw new CliError('device authorization expired', EXIT.auth, { registry, next: 'run `skillhub login` again' })
}
async logout(registry: string): Promise<void> {
await this.credentialsStore.deleteToken(registry)
}

View file

@ -168,6 +168,13 @@ interface FakeRegistryOptions {
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
publishStatus?: string
namespacePageSize?: number
deviceFlow?: {
accessToken: string
pendingPolls?: number
userCode?: string
expiresIn?: number
interval?: number
}
/**
* Per-endpoint failure injection. When set for an endpoint, that endpoint
* ignores all other logic and returns the specified failure (or throws for
@ -228,6 +235,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
downloads: number
reviews: number
namespaceRequests: number
devicePolls: number
} = {
publish: null,
resolve: null,
@ -237,7 +245,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
resolves: 0,
downloads: 0,
reviews: 0,
namespaceRequests: 0
namespaceRequests: 0,
devicePolls: 0
}
// If any endpoint is configured with 'network' failure mode, we need a real
@ -283,6 +292,33 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
const path = url.pathname
const baseUrl = `${url.protocol}//${url.host}`
// ------------------------------------------------------------------ //
// POST /api/v1/auth/device/code and /api/v1/auth/device/token
// ------------------------------------------------------------------ //
if (path === '/api/v1/auth/device/code' && req.method === 'POST' && options.deviceFlow) {
return Response.json({
code: 0,
data: {
deviceCode: 'device-secret',
userCode: options.deviceFlow.userCode ?? 'ABCD-2345',
verificationUri: `${baseUrl}/device`,
expiresIn: options.deviceFlow.expiresIn ?? 60,
interval: options.deviceFlow.interval ?? 0.001
}
})
}
if (path === '/api/v1/auth/device/token' && req.method === 'POST' && options.deviceFlow) {
state.devicePolls += 1
if (state.devicePolls <= (options.deviceFlow.pendingPolls ?? 0)) {
return Response.json({ code: 0, data: { accessToken: null, tokenType: null, error: 'authorization_pending' } })
}
return Response.json({
code: 0,
data: { accessToken: options.deviceFlow.accessToken, tokenType: 'Bearer', error: null }
})
}
// ------------------------------------------------------------------ //
// GET /api/cli/v1/auth/whoami
// ------------------------------------------------------------------ //

View file

@ -41,7 +41,9 @@ export async function runCli(
const proc = Bun.spawn({
cmd: [bunPath, entry, ...args],
cwd: options.cwd ?? cliRoot,
env: { ...sanitizeProcessEnv(), ...env },
// Integration tests must never launch real desktop applications. Tests
// that exercise browser-launch behavior inject a fake launcher directly.
env: { ...sanitizeProcessEnv(), CI: 'true', ...env },
stdout: 'pipe',
stderr: 'pipe'
})

View file

@ -3,7 +3,7 @@ import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
let registry: { url: string; stop: () => void } | undefined
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop()
@ -79,18 +79,52 @@ describe('auth commands', () => {
expect(result.stderr).toContain('authentication failed')
})
// [P0] missing token → EXIT.usage, stderr contains "token is required"
test('login without --token exits with usage error', async () => {
test('login without a token uses device flow and never prints the access token', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
registry = await startFakeRegistry({
token: 'oauth-secret',
user: { handle: 'oauth-user', displayName: 'OAuth User' },
deviceFlow: { accessToken: 'oauth-secret', pendingPolls: 1 }
})
const result = await runCli(['login', '--registry', registry.url], {
const result = await runCli(['login', '--registry', registry.url, '--no-open'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(5) // EXIT.usage
expect(result.stderr).toContain('token is required')
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Logged in')
expect(result.stderr).toContain('ABCD-2345')
expect(result.stderr).toContain('/device')
expect(`${result.stdout}\n${result.stderr}`).not.toContain('oauth-secret')
expect(await Bun.file(`${env.home}/.skillhub/credentials.json`).json())
.toMatchObject({ tokens: { [registry.url]: 'oauth-secret' } })
expect(registry.received.devicePolls).toBe(2)
})
test('device login --json emits a non-secret authorization event and final result', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'oauth-secret',
user: { handle: 'oauth-user', displayName: 'OAuth User' },
deviceFlow: { accessToken: 'oauth-secret' }
})
const result = await runCli(['login', '--registry', registry.url, '--no-open', '--json'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
expect(JSON.parse(result.stdout)).toEqual({ ok: true, registry: registry.url, handle: 'oauth-user' })
expect(JSON.parse(result.stderr)).toMatchObject({
event: 'device_authorization',
userCode: 'ABCD-2345',
verificationUri: `${registry.url}/device`,
browserOpened: false
})
expect(`${result.stdout}\n${result.stderr}`).not.toContain('oauth-secret')
expect(`${result.stdout}\n${result.stderr}`).not.toContain('device-secret')
})
// [P0] whoami failure must NOT write credentials

View file

@ -2,6 +2,14 @@ import { describe, expect, test } from 'bun:test'
import { runCli } from '../helpers/run-cli'
describe('help command', () => {
test('documents interactive device login and the headless fallback', async () => {
const result = await runCli(['help', 'login'])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('OAuth Device Flow')
expect(result.stdout).toContain('--no-open')
expect(result.stdout).toContain('--token')
})
test('prints detailed help for install', async () => {
const result = await runCli(['help', 'install'])
expect(result.exitCode).toBe(0)

View file

@ -133,6 +133,39 @@ describe('SkillHubClient', () => {
await err.toHaveProperty('exitCode', EXIT.auth)
})
test('device authorization uses the public endpoints without bearer credentials', async () => {
const requests: Array<{ path: string; authorization: string | null; body?: unknown }> = []
const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => {
const url = new URL(String(input))
requests.push({
path: url.pathname,
authorization: new Headers(init?.headers).get('authorization'),
...(init?.body ? { body: JSON.parse(String(init.body)) } : {})
})
if (url.pathname.endsWith('/code')) {
return Response.json({
data: {
deviceCode: 'device-secret',
userCode: 'ABCD-2345',
verificationUri: '/device',
expiresIn: 60,
interval: 5
}
})
}
return Response.json({ data: { accessToken: null, tokenType: null, error: 'authorization_pending' } })
}) as unknown as typeof fetch
const client = new SkillHubClient('https://skillhub.example.com', 'stored-token', fetchImpl)
await client.requestDeviceCode()
await client.pollDeviceToken('device-secret')
expect(requests).toEqual([
{ path: '/api/v1/auth/device/code', authorization: null },
{ path: '/api/v1/auth/device/token', authorization: null, body: { deviceCode: 'device-secret' } }
])
})
// --- search() (P1) ---
test('search() returns items', async () => {

View file

@ -0,0 +1,72 @@
import { describe, expect, mock, test } from 'bun:test'
import { canOpenBrowser, openExternalUrl } from '../../../src/platform/browser'
describe('openExternalUrl', () => {
test.each([
['darwin', 'open', ['https://skillhub.example.com/device']],
['linux', 'xdg-open', ['https://skillhub.example.com/device']],
['win32', 'rundll32', ['url.dll,FileProtocolHandler', 'https://skillhub.example.com/device']]
] as const)('uses the platform launcher on %s', (platform, command, args) => {
const launch = mock(() => true)
const env = platform === 'linux' ? { DISPLAY: ':0' } : {}
expect(openExternalUrl('https://skillhub.example.com/device', { platform, launch, env })).toBe(true)
expect(launch).toHaveBeenCalledWith(command, [...args])
})
test('rejects non-http URLs without launching a process', () => {
const launch = mock(() => true)
expect(openExternalUrl('javascript:alert(1)', { platform: 'linux', launch, env: { DISPLAY: ':0' } })).toBe(false)
expect(launch).not.toHaveBeenCalled()
})
test('falls back cleanly when the browser launcher is unavailable', () => {
const launch = mock(() => false)
expect(openExternalUrl('https://skillhub.example.com/device', { platform: 'linux', launch, env: { DISPLAY: ':0' } })).toBe(false)
})
test.each([
['CI', { CI: 'true', DISPLAY: ':0' }],
['SSH', { SSH_CONNECTION: 'client server', DISPLAY: ':0' }],
['SSH TTY', { SSH_TTY: '/dev/pts/0', DISPLAY: ':0' }],
['Linux without a display', {}]
])('does not launch in %s environments', (_name, env) => {
const launch = mock(() => true)
expect(openExternalUrl('https://skillhub.example.com/device', { platform: 'linux', launch, env })).toBe(false)
expect(launch).not.toHaveBeenCalled()
})
test('allows Linux desktops with X11 or Wayland', () => {
expect(canOpenBrowser('linux', { DISPLAY: ':0' })).toBe(true)
expect(canOpenBrowser('linux', { WAYLAND_DISPLAY: 'wayland-0' })).toBe(true)
})
test('rejects unsupported platforms without launching a process', () => {
const launch = mock(() => true)
expect(openExternalUrl('https://skillhub.example.com/device', {
platform: 'freebsd',
launch,
env: {}
})).toBe(false)
expect(launch).not.toHaveBeenCalled()
})
test('contains a synchronous launcher failure', () => {
const launch = mock(() => { throw new Error('launcher unavailable') })
expect(() => openExternalUrl('https://skillhub.example.com/device', {
platform: 'darwin',
launch,
env: {}
})).not.toThrow()
expect(openExternalUrl('https://skillhub.example.com/device', {
platform: 'darwin',
launch,
env: {}
})).toBe(false)
})
})

View file

@ -0,0 +1,249 @@
import { describe, expect, mock, test } from 'bun:test'
import { AuthService } from '../../../src/services/auth-service'
import { CliError } from '../../../src/shared/errors'
import { EXIT } from '../../../src/shared/constants'
describe('AuthService device flow', () => {
test('polls pending authorization, validates the token, then persists credentials', async () => {
const setRegistry = mock(async () => {})
const setToken = mock(async () => {})
const sleep = mock(async () => {})
const pollDeviceToken = mock()
.mockResolvedValueOnce({ error: 'authorization_pending' })
.mockResolvedValueOnce({ accessToken: 'oauth-secret', tokenType: 'Bearer' })
const clientFactory = mock((_registry: string, token?: string) => token
? { whoami: async () => ({ handle: 'oauth-user', displayName: 'OAuth User' }) }
: {
requestDeviceCode: async () => ({
deviceCode: 'device-secret',
userCode: 'ABCD-2345',
verificationUri: '/device',
expiresIn: 60,
interval: 2
}),
pollDeviceToken
})
const service = new AuthService(
{ setRegistry } as never,
{ setToken } as never,
{ clientFactory: clientFactory as never, sleep, now: () => 0 }
)
const onVerification = mock(async () => {})
const result = await service.loginWithDeviceFlow('https://skillhub.example.com', onVerification)
expect(result).toEqual({ handle: 'oauth-user' })
expect(onVerification).toHaveBeenCalledWith({
userCode: 'ABCD-2345',
verificationUri: 'https://skillhub.example.com/device',
expiresIn: 60
})
expect(sleep).toHaveBeenCalledTimes(2)
expect(sleep).toHaveBeenCalledWith(2_000)
expect(setRegistry).toHaveBeenCalledWith('https://skillhub.example.com')
expect(setToken).toHaveBeenCalledWith('https://skillhub.example.com', 'oauth-secret')
})
test('stops at expiry without persisting credentials', async () => {
const setRegistry = mock(async () => {})
const setToken = mock(async () => {})
let now = 0
const service = new AuthService(
{ setRegistry } as never,
{ setToken } as never,
{
clientFactory: (() => ({
requestDeviceCode: async () => ({
deviceCode: 'device-secret',
userCode: 'ABCD-2345',
verificationUri: '/device',
expiresIn: 1,
interval: 1
}),
pollDeviceToken: async () => ({ error: 'authorization_pending' })
})) as never,
sleep: async (milliseconds: number) => { now += milliseconds },
now: () => now
}
)
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
.rejects.toMatchObject({ message: 'device authorization expired', exitCode: 2 })
expect(setRegistry).not.toHaveBeenCalled()
expect(setToken).not.toHaveBeenCalled()
})
test('stops when the authorization server denies the request', async () => {
const setToken = mock(async () => {})
const service = new AuthService(
{ setRegistry: mock(async () => {}) } as never,
{ setToken } as never,
{
clientFactory: (() => ({
requestDeviceCode: async () => ({
deviceCode: 'device-secret',
userCode: 'ABCD-2345',
verificationUri: '/device',
expiresIn: 60,
interval: 1
}),
pollDeviceToken: async () => ({ error: 'access_denied' })
})) as never,
sleep: async () => {},
now: () => 0
}
)
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
.rejects.toMatchObject({ message: 'device authorization failed: access_denied', exitCode: 2 })
expect(setToken).not.toHaveBeenCalled()
})
test('rejects a non-positive polling interval from the registry', async () => {
const service = new AuthService(
{ setRegistry: mock(async () => {}) } as never,
{ setToken: mock(async () => {}) } as never,
{
clientFactory: (() => ({
requestDeviceCode: async () => ({
deviceCode: 'device-secret',
userCode: 'ABCD-2345',
verificationUri: '/device',
expiresIn: 60,
interval: 0
})
})) as never
}
)
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
.rejects.toMatchObject({ message: 'registry returned invalid device authorization data', exitCode: 2 })
})
test('rejects a non-HTTP verification URL from the registry', async () => {
const service = new AuthService(
{ setRegistry: mock(async () => {}) } as never,
{ setToken: mock(async () => {}) } as never,
{
clientFactory: (() => ({
requestDeviceCode: async () => ({
deviceCode: 'device-secret',
userCode: 'ABCD-2345',
verificationUri: 'javascript:alert(1)',
expiresIn: 60,
interval: 1
})
})) as never
}
)
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
.rejects.toMatchObject({ message: 'registry returned invalid device verification URL', exitCode: 2 })
})
test.each([
['missing device code', { deviceCode: '', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: 60, interval: 1 }],
['missing user code', { deviceCode: 'device-secret', userCode: '', verificationUri: '/device', expiresIn: 60, interval: 1 }],
['missing verification URI', { deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '', expiresIn: 60, interval: 1 }],
['zero expiry', { deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: 0, interval: 1 }],
['negative expiry', { deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: -1, interval: 1 }],
['non-finite expiry', { deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: Number.NaN, interval: 1 }]
])('rejects malformed device authorization data: %s', async (_name, device) => {
const service = new AuthService(
{ setRegistry: mock(async () => {}) } as never,
{ setToken: mock(async () => {}) } as never,
{ clientFactory: (() => ({ requestDeviceCode: async () => device })) as never }
)
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
.rejects.toMatchObject({ message: 'registry returned invalid device authorization data', exitCode: EXIT.auth })
})
test('propagates a device-code request network failure without persisting credentials', async () => {
const setToken = mock(async () => {})
const service = new AuthService(
{ setRegistry: mock(async () => {}) } as never,
{ setToken } as never,
{
clientFactory: (() => ({
requestDeviceCode: async () => { throw new CliError('registry unreachable', EXIT.network) }
})) as never
}
)
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
.rejects.toMatchObject({ message: 'registry unreachable', exitCode: EXIT.network })
expect(setToken).not.toHaveBeenCalled()
})
test('propagates a polling network failure without persisting credentials', async () => {
const setToken = mock(async () => {})
const service = new AuthService(
{ setRegistry: mock(async () => {}) } as never,
{ setToken } as never,
{
clientFactory: (() => ({
requestDeviceCode: async () => ({
deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: 60, interval: 1
}),
pollDeviceToken: async () => { throw new CliError('registry unreachable', EXIT.network) }
})) as never,
sleep: async () => {},
now: () => 0
}
)
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
.rejects.toMatchObject({ message: 'registry unreachable', exitCode: EXIT.network })
expect(setToken).not.toHaveBeenCalled()
})
test('rejects a non-Bearer device token without persisting credentials', async () => {
const setToken = mock(async () => {})
const service = new AuthService(
{ setRegistry: mock(async () => {}) } as never,
{ setToken } as never,
{
clientFactory: (() => ({
requestDeviceCode: async () => ({
deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: 60, interval: 1
}),
pollDeviceToken: async () => ({ accessToken: 'oauth-secret', tokenType: 'MAC' })
})) as never,
sleep: async () => {},
now: () => 0
}
)
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
.rejects.toMatchObject({ message: 'registry returned unsupported device token type', exitCode: EXIT.auth })
expect(setToken).not.toHaveBeenCalled()
})
test('does not persist the issued token when whoami validation fails', async () => {
const setRegistry = mock(async () => {})
const setToken = mock(async () => {})
const service = new AuthService(
{ setRegistry } as never,
{ setToken } as never,
{
clientFactory: ((_registry: string, token?: string) => token
? { whoami: async () => { throw new CliError('authentication failed', EXIT.auth) } }
: {
requestDeviceCode: async () => ({
deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: 60, interval: 1
}),
pollDeviceToken: async () => ({ accessToken: 'oauth-secret', tokenType: 'Bearer' })
}) as never,
sleep: async () => {},
now: () => 0
}
)
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
.rejects.toMatchObject({ message: 'authentication failed', exitCode: EXIT.auth })
expect(setRegistry).not.toHaveBeenCalled()
expect(setToken).not.toHaveBeenCalled()
})
})

View file

@ -75,7 +75,7 @@ services:
SKILLHUB_SUITE_REVIEW_WRITES_ENABLED: ${SKILLHUB_SUITE_REVIEW_WRITES_ENABLED:-true}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-}
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-/device}
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:?required}
SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3}
STORAGE_BASE_PATH: /var/lib/skillhub/storage

View file

@ -0,0 +1,30 @@
# AI 变更记录CLI OAuth Device Flow 登录
## 需求背景
SkillHub 服务端已经提供 Device Code 申请、授权轮询和浏览器确认能力,但 CLI `0.1.12`
在没有 API Token 时仍直接返回 `token is required`,无法使用既有的 OAuth 登录链路。
对应社区需求记录在 Issue #856
## 实现内容
- `skillhub login` 未显式提供 Token 时,调用现有 `/api/v1/auth/device/code` 发起授权。
- 展示服务端返回的验证地址和用户码,并在受支持的平台上尝试打开默认浏览器。
- 增加 `--no-open`,支持远程服务器和无图形界面的终端。
- 按服务端返回的 `interval``expiresIn` 轮询 `/api/v1/auth/device/token`,处理等待、拒绝、过期和网络失败。
- 获取 Bearer Token 后先调用 `whoami` 验证,成功后才写入现有配置与凭据存储。
- 保留 `--token``SKILLHUB_TOKEN` 和已存 Token 的兼容行为,供 CI 与自动化使用。
- JSON 模式只输出验证地址、用户码、有效期和浏览器打开状态,不输出 device code 或 access token。
## 安全边界
- 只允许打开 HTTP/HTTPS 验证地址,不通过 shell 拼接 URL。
- access token 不进入标准输出或错误输出。
- Device Flow 失败、拒绝或超时时不更新本地凭据。
- 浏览器打开失败不会中断流程,用户仍可复制终端中的验证地址继续授权。
## 测试策略
- 单元测试覆盖轮询成功、等待、拒绝、过期、非法服务端参数和跨平台浏览器启动。
- 客户端测试验证 Device Flow 使用公开端点且不携带已有 Bearer Token。
- 集成测试覆盖普通输出、JSON 输出、`--no-open`、Token 不泄漏及原有 Token 登录兼容性。

View file

@ -208,7 +208,7 @@ skillhub:
editable: false
requires-review: false
device-auth:
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth}
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/device}
security:
scanner:
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true}

View file

@ -41,7 +41,7 @@ public class DeviceAuthService {
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
ApiTokenService apiTokenService,
ObjectMapper objectMapper,
@Value("${skillhub.device-auth.verification-uri:/cli/auth}") String verificationUri) {
@Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) {
this.redisTemplate = redisTemplate;
this.apiTokenService = apiTokenService;
this.objectMapper = objectMapper;

View file

@ -44,7 +44,7 @@ class DeviceAuthServiceTest {
@BeforeEach
void setUp() {
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
service = new DeviceAuthService(redisTemplate, apiTokenService, new ObjectMapper(), "/cli/auth");
service = new DeviceAuthService(redisTemplate, apiTokenService, new ObjectMapper(), "/device");
}
/**

View file

@ -45,4 +45,10 @@ describe('router', () => {
const childPaths = children.map((route) => route.fullPath ?? route.path)
expect(childPaths).toContain('/space/$namespace/$slug/compare')
})
it('registers the device authorization route', () => {
const children = (router.routeTree.children ?? []) as Array<{ fullPath?: string; path?: string }>
const childPaths = children.map((route) => route.fullPath ?? route.path)
expect(childPaths).toContain('/device')
})
})

View file

@ -86,6 +86,7 @@ function createRoleProtectedRouteComponent<TModule extends Record<string, unknow
const LandingPage = createLazyRouteComponent(() => import('@/pages/landing'), 'LandingPage')
const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage')
const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage')
const DeviceAuthPage = createLazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage')
const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage')
const ResetPasswordPage = createLazyRouteComponent(() => import('@/pages/reset-password'), 'ResetPasswordPage')
const PrivacyPolicyPage = createLazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage')
@ -587,6 +588,12 @@ const cliAuthRoute = createRoute({
},
})
const deviceAuthRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'device',
component: DeviceAuthPage,
})
const settingsSecurityRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'settings/security',
@ -683,6 +690,7 @@ const routeTree = rootRoute.addChildren([
dashboardNotificationsRoute,
dashboardTokensRoute,
cliAuthRoute,
deviceAuthRoute,
settingsSecurityRoute,
settingsProfileRoute,
settingsNotificationsRoute,