fix(server-v2): treat already-authenticated OAuth start as success

When the Kimi OAuth toolkit short-circuits via its `ensureFresh` fast path (the cached refresh token is still usable), `toolkit.login` resolves without firing `onDeviceCode`. The v2 `OAuthService.startLogin` previously rejected in this case, so `POST /api/v1/oauth/login` returned a 500 that the web client swallowed and mislabeled as "current daemon does not support login". CLI-authenticated users could not (re)enter the v2 daemon through the web UI.

Treat that path as already authenticated: provision the managed provider (idempotent), refresh OAuth provider models so `defaultModel` is seeded (`/auth` then reports ready, matching v1 parity), and return a new `status: 'authenticated'` `OAuthFlowStart` variant so the web LoginDialog can skip the device-code UI entirely instead of falling into its catch-all "unsupported daemon" state.

The protocol `OAuthFlowStart` schema is now a discriminated union on `status`; kimi-web's wire / composable / LoginDialog types are updated to handle the new `authenticated` branch.

Tests: updated the v2 OAuthService test that previously asserted the buggy rejection to instead assert the fast path returns authenticated and provisions the provider.
This commit is contained in:
7Sageer 2026-07-07 12:59:22 +08:00
parent c51d8df461
commit ebe16e39ff
8 changed files with 283 additions and 79 deletions

View file

@ -26,6 +26,7 @@ import type {
KimiEventConnection,
KimiEventHandlers,
KimiWebApi,
OAuthLoginStartResult,
Page,
PageRequest,
PromptSubmission,
@ -1184,27 +1185,24 @@ export class DaemonKimiWebApi implements KimiWebApi {
};
}
async startOAuthLogin(): Promise<{
flowId: string;
provider: string;
verificationUri: string;
verificationUriComplete: string;
userCode: string;
expiresIn: number;
interval: number;
status: 'pending';
expiresAt: string;
}> {
async startOAuthLogin(): Promise<OAuthLoginStartResult> {
const data = await this.http.post<WireOAuthLoginStartResult>('/oauth/login', {});
if (data.status === 'authenticated') {
return {
flowId: data.flow_id,
provider: data.provider,
status: 'authenticated',
};
}
return {
flowId: data.flow_id,
provider: data.provider,
status: 'pending',
verificationUri: data.verification_uri,
verificationUriComplete: data.verification_uri_complete,
userCode: data.user_code,
expiresIn: data.expires_in,
interval: data.interval,
status: data.status,
expiresAt: data.expires_at,
};
}

View file

@ -413,18 +413,35 @@ export interface WireAuthResult {
managed_provider: WireManagedProvider | null;
}
export interface WireOAuthLoginStartResult {
// `POST /oauth/login` returns one of two shapes, discriminated by `status`:
// - `pending`: a real device-code flow was started; all device fields are
// populated so the client can render the device-code step and poll.
// - `authenticated`: the toolkit already had a usable token and short-
// circuited via its `ensureFresh` fast path, so no device code was
// issued; the client can skip the device-code step and treat the login
// as already complete.
interface WireOAuthLoginStartPending {
flow_id: string;
provider: string;
status: 'pending';
verification_uri: string;
verification_uri_complete: string;
user_code: string;
expires_in: number;
interval: number;
status: 'pending';
expires_at: string;
}
interface WireOAuthLoginStartAuthenticated {
flow_id: string;
provider: string;
status: 'authenticated';
}
export type WireOAuthLoginStartResult =
| WireOAuthLoginStartPending
| WireOAuthLoginStartAuthenticated;
export interface WireOAuthLoginPollResult {
flow_id: string;
status: 'pending' | 'authenticated' | 'expired' | 'cancelled';

View file

@ -734,17 +734,7 @@ export interface KimiWebApi {
defaultModel: string | null;
managedProvider: { status: string } | null;
}>;
startOAuthLogin(): Promise<{
flowId: string;
provider: string;
verificationUri: string;
verificationUriComplete: string;
userCode: string;
expiresIn: number;
interval: number;
status: 'pending';
expiresAt: string;
}>;
startOAuthLogin(): Promise<OAuthLoginStartResult>;
pollOAuthLogin(): Promise<{
flowId: string;
status: 'pending' | 'authenticated' | 'expired' | 'cancelled';
@ -753,3 +743,22 @@ export interface KimiWebApi {
cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }>;
logout(): Promise<{ loggedOut: boolean }>;
}
/** Result of `startOAuthLogin()`, mirroring the wire discriminated union. */
export type OAuthLoginStartResult =
| {
flowId: string;
provider: string;
status: 'pending';
verificationUri: string;
verificationUriComplete: string;
userCode: string;
expiresIn: number;
interval: number;
expiresAt: string;
}
| {
flowId: string;
provider: string;
status: 'authenticated';
};

View file

@ -33,17 +33,25 @@ const emit = defineEmits<{
// -------------------------------------------------------------------------
const props = defineProps<{
onStartOAuthLogin: () => Promise<{
flowId: string;
provider: string;
verificationUri: string;
verificationUriComplete: string;
userCode: string;
expiresIn: number;
interval: number;
status: 'pending';
expiresAt: string;
} | null>;
onStartOAuthLogin: () => Promise<
| {
flowId: string;
provider: string;
status: 'pending';
verificationUri: string;
verificationUriComplete: string;
userCode: string;
expiresIn: number;
interval: number;
expiresAt: string;
}
| {
flowId: string;
provider: string;
status: 'authenticated';
}
| null
>;
onPollOAuthLogin: () => Promise<{
flowId: string;
status: 'pending' | 'authenticated' | 'expired' | 'cancelled';
@ -107,6 +115,19 @@ async function startFlow(): Promise<void> {
return;
}
// Already-authenticated fast path: the server had a usable cached token and
// did not issue a device code. Skip the device-code UI entirely and surface
// the success state the poller is irrelevant here.
if (result.status === 'authenticated') {
stopTimers();
step.value = 'success';
setTimeout(() => {
emit('success');
emit('close');
}, 800);
return;
}
flow.value = {
flowId: result.flowId,
verificationUri: result.verificationUri,

View file

@ -7,7 +7,15 @@
import { ref, type ComputedRef } from 'vue';
import { getKimiWebApi } from '../../api';
import type { AppMessage, AppModel, AppProvider, AppSession, AppSkill, ThinkingLevel } from '../../api/types';
import type {
AppMessage,
AppModel,
AppProvider,
AppSession,
AppSkill,
OAuthLoginStartResult,
ThinkingLevel,
} from '../../api/types';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
import { coerceThinkingForModel } from '../../lib/modelThinking';
import type { ActivityState } from '../../types';
@ -340,17 +348,7 @@ export function useModelProviderState(
}
/** Start managed Kimi OAuth device flow. Returns flow data or null on error. */
async function startOAuthLogin(): Promise<{
flowId: string;
provider: string;
verificationUri: string;
verificationUriComplete: string;
userCode: string;
expiresIn: number;
interval: number;
status: 'pending';
expiresAt: string;
} | null> {
async function startOAuthLogin(): Promise<OAuthLoginStartResult | null> {
try {
const api = getKimiWebApi();
return await api.startOAuthLogin();

View file

@ -32,6 +32,7 @@ import {
import type {
OAuthFlowSnapshot,
OAuthFlowStart,
OAuthFlowStartPending,
OAuthFlowStatus,
OAuthLoginCancelResponse,
OAuthLogoutResponse,
@ -134,20 +135,28 @@ export class OAuthService extends Disposable implements IOAuthService {
resolveDevice(auth);
},
});
const fastPath: Promise<OAuthFlowStart | undefined> = loginPromise.then(async () => {
if (state.device !== undefined) return undefined;
this.log.info('oauth startLogin: toolkit resolved without device code (already authenticated)', {
provider,
});
await this.completeAlreadyAuthenticatedLogin(state);
return {
flow_id: state.flowId,
provider: state.provider,
status: 'authenticated',
};
});
loginPromise.then(
() => {
this.log.info('oauth startLogin: toolkit.login resolved', {
provider,
deviceArrived: state.device !== undefined,
});
if (state.device === undefined) {
this.flows.delete(provider);
rejectDevice(
new Error('OAuth login completed without issuing a device code (already authenticated).'),
);
return;
if (state.device !== undefined) {
this.handleSuccess(state);
}
this.handleSuccess(state);
},
(error) => {
this.log.warn('oauth startLogin: toolkit.login rejected', {
@ -159,8 +168,16 @@ export class OAuthService extends Disposable implements IOAuthService {
},
);
this.log.info('oauth startLogin: awaiting deviceReady', { provider });
const device = await deviceReady;
this.log.info('oauth startLogin: awaiting device flow start', { provider });
const winner = await Promise.race([
deviceReady.then((device) => ({ kind: 'device' as const, device })),
fastPath.then((result) => ({ kind: 'fast' as const, result })),
]);
if (winner.kind === 'fast' && winner.result !== undefined) {
this.log.info('oauth startLogin: fast path returned authenticated', { provider });
return winner.result;
}
const device = winner.kind === 'device' ? winner.device : await deviceReady;
this.log.info('oauth startLogin: deviceReady resolved', { provider });
return this.toFlowStart(state, device);
}
@ -347,23 +364,42 @@ export class OAuthService extends Disposable implements IOAuthService {
private handleSuccess(state: FlowState): void {
if (state.status !== 'pending') return;
this.setTerminal(state, 'authenticated');
void this.provisionProvider(state.provider, state.oauthRef);
void this.provisionProvider(state.provider, state.oauthRef).catch((error: unknown) => {
this.log.warn('oauth provider provisioning failed', {
provider: state.provider,
error: error instanceof Error ? error.message : String(error),
});
});
}
private async completeAlreadyAuthenticatedLogin(state: FlowState): Promise<void> {
if (state.status !== 'pending') return;
await this.provisionProvider(state.provider, state.oauthRef);
if (state.status !== 'pending') return;
if (state.provider === KIMI_CODE_PROVIDER_NAME) {
await this.refreshOAuthProviderModelsBestEffort(state.provider);
if (state.status !== 'pending') return;
}
this.setTerminal(state, 'authenticated');
}
private async provisionProvider(provider: string, oauthRef: OAuthRef | undefined): Promise<void> {
if (oauthRef === undefined) return;
const baseUrl = this.providerService.get(provider)?.baseUrl ?? kimiCodeBaseUrl();
try {
await this.providerService.set(provider, {
type: 'kimi',
baseUrl,
apiKey: '',
oauth: oauthRef,
});
} catch (error) {
this.log.warn('oauth provider provisioning failed', {
await this.providerService.set(provider, {
type: 'kimi',
baseUrl,
apiKey: '',
oauth: oauthRef,
});
}
private async refreshOAuthProviderModelsBestEffort(provider: string): Promise<void> {
const result = await this.refreshOAuthProviderModels();
if (result.failed.length > 0) {
this.log.warn('oauth startLogin: model refresh failed on already-authenticated fast path', {
provider,
error: error instanceof Error ? error.message : String(error),
failures: result.failed,
});
}
}
@ -416,7 +452,7 @@ export class OAuthService extends Disposable implements IOAuthService {
state.gcTimer = timer;
}
private toFlowStart(state: FlowState, device: DeviceAuthorization): OAuthFlowStart {
private toFlowStart(state: FlowState, device: DeviceAuthorization): OAuthFlowStartPending {
const expiresIn = device.expiresIn ?? DEFAULT_DEVICE_EXPIRES_IN_SEC;
return {
flow_id: state.flowId,

View file

@ -70,13 +70,39 @@ describe('OAuthService', () => {
},
[NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' },
};
providerSet = vi.fn().mockResolvedValue(undefined);
providerSet = vi.fn(async (name: string, config: ProviderConfig) => {
providers = { ...providers, [name]: config };
});
models = {};
services = undefined;
defaultModel = undefined;
defaultThinking = undefined;
configSet = vi.fn().mockResolvedValue(undefined);
configReplace = vi.fn().mockResolvedValue(undefined);
configSet = vi.fn(async (domain: string, value: unknown) => {
if (domain === 'defaultModel') {
defaultModel = value as string | undefined;
return;
}
if (domain === 'defaultThinking') {
defaultThinking = value as boolean | undefined;
return;
}
throw new Error(`unexpected config set: ${domain}`);
});
configReplace = vi.fn(async (domain: string, value: unknown) => {
if (domain === 'providers') {
providers = value as Record<string, ProviderConfig>;
return;
}
if (domain === 'models') {
models = value as Record<string, ModelAlias>;
return;
}
if (domain === 'services') {
services = value as Record<string, unknown> | undefined;
return;
}
throw new Error(`unexpected config replace: ${domain}`);
});
events = [];
toolkit = {
login: vi.fn(),
@ -135,6 +161,24 @@ describe('OAuthService', () => {
return { providers, models, services, defaultModel, defaultThinking };
}
function stubManagedModelsFetch(): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{
id: 'kimi-k2',
context_length: 131072,
supports_reasoning: true,
display_name: 'Kimi K2',
},
],
}),
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
it('startLogin resolves a device-code flow and flips to authenticated on success', async () => {
toolkit.login.mockImplementation(async (_provider, options) => {
options.onDeviceCode(deviceAuth);
@ -156,8 +200,7 @@ describe('OAuthService', () => {
expect.objectContaining({ oauthRef: { storage: 'file', key: 'oauth/kimi-code' } }),
);
await flush();
expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('authenticated');
await vi.waitFor(() => expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('authenticated'));
});
it('provisions the managed provider through the provider service after login', async () => {
@ -213,11 +256,68 @@ describe('OAuthService', () => {
);
});
it('startLogin rejects when login completes without issuing a device code', async () => {
it('startLogin returns authenticated when login resolves without issuing a device code (already-authenticated fast path)', async () => {
const fetchMock = stubManagedModelsFetch();
toolkit.login.mockResolvedValue({ providerName: OAUTH_PROVIDER, ok: true });
const svc = createService();
await expect(svc.startLogin(OAUTH_PROVIDER)).rejects.toThrow('already authenticated');
expect(svc.getFlow(OAUTH_PROVIDER)).toBeUndefined();
const start = await svc.startLogin(OAUTH_PROVIDER);
expect(start).toMatchObject({
provider: OAUTH_PROVIDER,
status: 'authenticated',
flow_id: expect.any(String),
});
expect(providerSet).toHaveBeenCalledWith(
OAUTH_PROVIDER,
expect.objectContaining({
type: 'kimi',
baseUrl: 'https://api.example.com',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
}),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(configSet).toHaveBeenCalledWith('defaultModel', 'kimi-code/kimi-k2');
});
it('startLogin returns authenticated when model refresh fails on the already-authenticated fast path', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('network disabled in test'));
vi.stubGlobal('fetch', fetchMock);
toolkit.login.mockResolvedValue({ providerName: OAUTH_PROVIDER, ok: true });
const svc = createService();
await expect(svc.startLogin(OAUTH_PROVIDER)).resolves.toMatchObject({
provider: OAUTH_PROVIDER,
status: 'authenticated',
flow_id: expect.any(String),
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(providerSet).toHaveBeenCalledWith(
OAUTH_PROVIDER,
expect.objectContaining({
type: 'kimi',
baseUrl: 'https://api.example.com',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
}),
);
expect(configSet).not.toHaveBeenCalledWith('defaultModel', expect.any(String));
});
it('keeps a device-code login authenticated when model fetch is unavailable after authorization', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('network disabled in test'));
vi.stubGlobal('fetch', fetchMock);
toolkit.login.mockImplementation(async (_provider, options) => {
options.onDeviceCode(deviceAuth);
return { providerName: OAUTH_PROVIDER, ok: true };
});
const svc = createService();
await expect(svc.startLogin(OAUTH_PROVIDER)).resolves.toMatchObject({
provider: OAUTH_PROVIDER,
status: 'pending',
});
await vi.waitFor(() => expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('authenticated'));
expect(fetchMock).not.toHaveBeenCalled();
expect(configSet).not.toHaveBeenCalledWith('defaultModel', expect.any(String));
});
it('cancelLogin aborts a pending flow and marks it cancelled', async () => {

View file

@ -22,17 +22,42 @@ export const oauthLoginStartRequestSchema = z.object({
});
export type OAuthLoginStartRequest = z.infer<typeof oauthLoginStartRequestSchema>;
export const oauthFlowStartSchema = z.object({
/**
* Result of `POST /v1/oauth/login`.
*
* Two shapes, discriminated by `status`:
* - `pending`: a real device-code flow was started; the `verification_*`,
* `user_code`, `expires_*`, and `interval` fields are populated so the
* client can render the device-code step and start polling.
* - `authenticated`: the toolkit already had a usable token and short-
* circuited via its `ensureFresh` fast path, so no device code was
* issued. The client can skip the device-code step and treat the login
* as already complete.
*/
export const oauthFlowStartPendingSchema = z.object({
flow_id: z.string().min(1),
provider: z.string().min(1),
status: z.literal('pending'),
verification_uri: z.string().url(),
verification_uri_complete: z.string().url(),
user_code: z.string().min(1),
expires_in: z.number().int().positive(),
interval: z.number().int().positive(),
status: z.literal('pending'),
expires_at: isoDateTimeSchema,
});
export type OAuthFlowStartPending = z.infer<typeof oauthFlowStartPendingSchema>;
export const oauthFlowStartAuthenticatedSchema = z.object({
flow_id: z.string().min(1),
provider: z.string().min(1),
status: z.literal('authenticated'),
});
export type OAuthFlowStartAuthenticated = z.infer<typeof oauthFlowStartAuthenticatedSchema>;
export const oauthFlowStartSchema = z.discriminatedUnion('status', [
oauthFlowStartPendingSchema,
oauthFlowStartAuthenticatedSchema,
]);
export type OAuthFlowStart = z.infer<typeof oauthFlowStartSchema>;
export const oauthFlowSnapshotSchema = z.object({