feat(auth-login): add Remote Control auth login page (#224)

* feat(auth-login): add Remote Control auth login page

* chore: bump kimi-code submodule

* chore: bump kimi-code submodule

* feat(auth-login): make redirect_uri optional and show a signed-in state without it

* feat(auth-login): rename the token cookie to kimi-auth

* feat(auth-login): move the copy-link button to a second row on mobile

* feat(auth-login): make the mobile copy-link button full-width

* feat(auth-login): detect locale from the shared KIMI_LOCALE cookie

* fix(auth-login): address review findings on cookie handling and polling

* ci: include auth-login in root typecheck and changeset ignore

* feat(auth-login): drop the oauth_host override

* feat(app-ui): split self-hosted fonts into a separate fonts.css entry

* fix(auth-login): fall back from crypto.randomUUID for insecure contexts
This commit is contained in:
liruifengv 2026-08-14 11:11:37 +08:00 committed by GitHub
parent db79803a9c
commit 35746f75ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1245 additions and 30 deletions

View file

@ -14,6 +14,7 @@
"updateInternalDependencies": "patch",
"ignore": [
"kimi-code-web",
"kimi-code-auth-login",
"@moonshot-ai/vite-preset",
"@moonshot-ai/app-core",
"@moonshot-ai/app-i18n",

View file

@ -27,6 +27,7 @@
- `apps/desktop`Electron 壳(`kimi-code-app`);简介见 `apps/desktop/README.md`,原生功能分叉清单见 `apps/desktop/docs/native-todos.md`。新测试:主进程进 `tests/main/`renderer 进 `tests/renderer/`
- `apps/web`:浏览器 Web UI`kimi-code-web`Vue 3 + Vite + vue-i18n。dev 时 Vite 把 `/api/v1`REST + WS代理到 `KIMI_SERVER_URL`(默认 `http://127.0.0.1:58627`)。
- `apps/auth-login`Remote Control 鉴权中间页(`kimi-code-auth-login`单页、移动端优先OAuth device flow 经 `@moonshot-ai/kimi-code-oauth/device` 直连 auth.kimi.comCORS 直连,无 dev proxytoken 写 `kimi-auth` cookie`redirect_uri` 可选,有则登录后回跳。方案见 `docs/plans/2026-08-13-rc-auth-login.md`
- `packages/*``@moonshot-ai/{app-core,app-i18n,app-markdown,app-ui,app-client}` + `vite-preset`exports→src被 apps/web 与 desktop renderer 复用——app-core 是无 Vue 依赖的纯层api 客户端 / lib 纯函数 / client 渲染类型与热路径纯模块app-client 是 Vue composables 层(注入 api / t / tracker共享字体产物在 `app-ui/src/assets/fonts`gitignored`scripts/prepare-fonts.mjs` 自动准备。
- `kimi-code/`git submodule核心仓`kimi-code/packages/*` 提供 `kap-server``agent-core-v2``kimi-code-sdk` 等源码。
- `scripts/sync-web-to-kimi-code.mjs``apps/web/dist``<kimi-code checkout>/apps/kimi-code/dist-web``KIMI_CODE_REPO` 必传,指定目标 checkout

View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en" data-color-scheme="system">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="64x64" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)" />
<!-- Auth interstitial for RC tunnels: never indexable. -->
<meta name="robots" content="noindex" />
<title>Kimi Code</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,33 @@
{
"name": "kimi-code-auth-login",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"prepare:fonts": "node ../../scripts/prepare-fonts.mjs",
"predev": "npm run prepare:fonts",
"dev": "vite",
"prebuild": "npm run prepare:fonts",
"build": "vite build",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@moonshot-ai/app-client": "workspace:*",
"@moonshot-ai/app-core": "workspace:*",
"@moonshot-ai/app-i18n": "workspace:*",
"@moonshot-ai/app-ui": "workspace:*",
"@moonshot-ai/kimi-code-oauth": "workspace:*",
"vue": "^3.5.35",
"vue-i18n": "^11.4.5"
},
"devDependencies": {
"@moonshot-ai/vite-preset": "workspace:*",
"@vitejs/plugin-vue": "^5.2.4",
"typescript": "6.0.2",
"unplugin-icons": "^23.0.0",
"vite": "^6.3.3",
"vitest": "4.1.4",
"vue-tsc": "~3.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

428
apps/auth-login/src/App.vue Normal file
View file

@ -0,0 +1,428 @@
<!-- apps/auth-login/src/App.vue -->
<!-- Remote Control auth interstitial: a single mobile-first page that runs the
Kimi Code OAuth device flow directly against the OAuth host and stores the
access token in the `kimi-auth` cookie. With `?redirect_uri=` (the tunnel
entry case) it redirects back after sign-in; without one it simply lands
on a "signed in" state. `?force_relogin=1` drops any existing cookie and
starts over (the tunnel uses it after rejecting a token). Desktop widths
get a centered card. -->
<script setup lang="ts">
import { computed, onMounted, ref, useId } from 'vue';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from '@moonshot-ai/app-core/lib';
import { useOAuthLoginFlow } from '@moonshot-ai/app-client/composables';
import { AuthStateIcon, Button, Icon, Spinner } from '@moonshot-ai/app-ui';
import {
buildTokenCookie,
clearTokenCookie,
parseRedirectUri,
readTokenCookie,
} from './auth-token';
import { createAuthLoginFlow } from './flow';
const { t } = useI18n();
// Per-instance mask id so the logo's eye cutouts can't collide.
const maskId = `bl-eyes-${useId()}`;
// The page's own steps precede the OAuth flow's: an existing token cookie
// short-circuits the flow entirely (redirect when a target is known, a plain
// "signed in" state otherwise).
type PageStep = 'checking' | 'signed-in' | 'flow';
const pageStep = ref<PageStep>('checking');
// Optional redirect target. Missing or invalid values both degrade to "no
// redirect" a malformed param must not strand the user off the sign-in flow.
const redirectUri = ref<string | null>(null);
function writeTokenCookie(accessToken: string, expiresAt: number): void {
document.cookie = buildTokenCookie(accessToken, {
expiresAt,
secure: location.protocol === 'https:',
});
}
const authFlow = createAuthLoginFlow({
// Persist the cookie the instant the token arrives the success state (and
// its "you can close" hint) shows during the state machine's dwell, so
// waiting for onSuccess would let an early tab-close lose the sign-in.
onToken: (token) => writeTokenCookie(token.accessToken, token.expiresAt),
});
const { step, pollError, flow, secondsLeft, startFlow } = useOAuthLoginFlow({
...authFlow.callbacks,
onSuccess: finishSignIn,
});
const displayStep = computed(() => (pageStep.value === 'flow' ? step.value : pageStep.value));
function finishSignIn(): void {
// The cookie is already persisted (onToken); here we only navigate.
const target = redirectUri.value;
if (target) {
// replace(): the interstitial should not stay in browser history.
location.replace(target);
}
}
onMounted(async () => {
document.title = t('login.title');
redirectUri.value = parseRedirectUri(location.search);
// The tunnel bounces users back with force_relogin=1 when the presented
// cookie was rejected (revoked, wrong environment): drop it and start over
// instead of ping-ponging between this page and the target.
const forceRelogin = new URLSearchParams(location.search).get('force_relogin') === '1';
if (forceRelogin) {
document.cookie = clearTokenCookie();
} else if (readTokenCookie(document.cookie)) {
// A readable cookie is unexpired by definition (Expires handles eviction),
// so its presence means "already signed in".
if (redirectUri.value) {
location.replace(redirectUri.value);
} else {
pageStep.value = 'signed-in';
}
return;
}
pageStep.value = 'flow';
await startFlow();
});
const copied = ref(false);
async function copyLink(): Promise<void> {
if (!flow.value) return;
const ok = await copyTextToClipboard(flow.value.verificationUriComplete);
if (!ok) return;
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
}
// Format seconds as m:ss
function formatSeconds(s: number): string {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${String(sec).padStart(2, '0')}`;
}
</script>
<template>
<main class="page">
<div class="panel">
<header class="brand">
<!-- Legacy "little blue" brand mark (static rendering of the web
onboarding BrandLogo, minus the blink easter egg). -->
<svg
class="brand-logo"
viewBox="0 0 32 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Kimi Code"
>
<defs>
<mask :id="maskId" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="22" fill="#fff" />
<g fill="#000">
<rect x="11.8" y="7" width="2.8" height="8" rx="1.4" />
<rect x="17.4" y="7" width="2.8" height="8" rx="1.4" />
</g>
</mask>
</defs>
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" :mask="`url(#${maskId})`" />
</svg>
<h1 class="title">{{ t('login.title') }}</h1>
<p class="subtitle">{{ t('login.rcSubtitle') }}</p>
</header>
<Transition name="step" mode="out-in">
<!-- Checking cookie / starting the flow -->
<div v-if="displayStep === 'checking' || displayStep === 'starting'" key="busy" class="center-body">
<Spinner size="md" />
<span class="center-text">{{ displayStep === 'checking' ? t('login.rcChecking') : t('login.starting') }}</span>
</div>
<!-- Device-code step -->
<div v-else-if="displayStep === 'device-code' && flow" key="device-code" class="flow-body">
<p class="lead">{{ t('login.rcLead') }}</p>
<!-- Primary path: open the complete URI (device code embedded). An
anchor, not a Button it must keep href/target (same pattern as
the client's LoginDialog). -->
<a
class="primary-link"
:href="flow.verificationUriComplete"
target="_blank"
rel="noopener noreferrer"
>
{{ t('login.rcAuthorize') }}
<Icon name="external-link" size="sm" />
</a>
<!-- Verification code + copyable link for the "open it elsewhere"
path -->
<div class="code-row">
<div class="code-meta">
<span class="code-label">{{ t('login.rcUserCodeLabel') }}</span>
<span class="code">{{ flow.userCode }}</span>
</div>
<Button class="copy-btn" :class="{ 'is-copied': copied }" variant="secondary" size="sm" @click="copyLink">
<template v-if="copied">
<Icon name="check" size="sm" />
{{ t('login.copied') }}
</template>
<template v-else>
<Icon name="copy" size="sm" />
{{ t('login.copyLink') }}
</template>
</Button>
</div>
<!-- Status -->
<div class="status">
<Spinner size="sm" :label="t('login.waitingAuth')" />
<span class="status-text">{{ t('login.waitingAutoClose') }}</span>
<span class="countdown">{{ formatSeconds(secondsLeft) }}</span>
</div>
</div>
<!-- Success (just authorized) -->
<div v-else-if="displayStep === 'success'" key="success" class="center-body">
<AuthStateIcon kind="success" />
<span class="center-text success-text">{{ t('login.success') }}</span>
<span class="center-hint">{{ redirectUri ? t('login.rcSuccessHint') : t('login.rcSuccessNoRedirect') }}</span>
</div>
<!-- Already signed in (valid cookie, no redirect target) -->
<div v-else-if="displayStep === 'signed-in'" key="signed-in" class="center-body">
<AuthStateIcon kind="success" />
<span class="center-text success-text">{{ t('login.success') }}</span>
<span class="center-hint">{{ t('login.rcSuccessNoRedirect') }}</span>
</div>
<!-- Expired / declined -->
<div v-else-if="displayStep === 'expired'" key="expired" class="center-body">
<AuthStateIcon kind="expired" />
<span class="center-text err-text">{{ t('login.rcExpiredTitle') }}</span>
<span class="center-hint">{{ t('login.expiredHint') }}</span>
<Button variant="primary" class="retry-btn" @click="startFlow">{{ t('login.retry') }}</Button>
</div>
<!-- Error (start failure or repeated poll failures) -->
<div v-else-if="displayStep === 'error'" key="error" class="center-body">
<AuthStateIcon kind="error" />
<span class="center-text warn-text">
{{ pollError ? t('login.pollErrorTitle') : t('login.rcStartErrorTitle') }}
</span>
<span class="center-hint">{{ t('login.rcConnectionErrorHint') }}</span>
<Button variant="primary" class="retry-btn" @click="startFlow">{{ t('login.retry') }}</Button>
</div>
</Transition>
</div>
</main>
</template>
<style scoped>
.page {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: calc(var(--space-4) + env(safe-area-inset-top))
calc(var(--space-4) + env(safe-area-inset-right))
calc(var(--space-4) + env(safe-area-inset-bottom))
calc(var(--space-4) + env(safe-area-inset-left));
}
.panel {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* Desktop widths: centered quiet card (mobile keeps the flat page). */
@media (min-width: 640px) {
.panel {
background: var(--color-surface);
border: var(--p-hairline) solid var(--color-line);
border-radius: var(--radius-xl);
padding: var(--space-8);
}
}
/* Brand */
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
text-align: center;
}
.brand-logo {
width: 56px;
height: auto;
display: block;
}
.title {
margin: 0;
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
color: var(--color-text);
}
.subtitle {
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
/* Centered single-state bodies */
.center-body {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) 0 var(--space-2);
text-align: center;
}
.center-text {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--color-text);
}
.success-text { color: var(--color-success); }
.err-text { color: var(--color-danger); }
.warn-text { color: var(--color-warning); }
.center-hint {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.retry-btn {
width: 100%;
margin-top: var(--space-2);
}
/* Device-code body */
.flow-body {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.lead {
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-muted);
line-height: var(--leading-normal);
text-align: center;
}
/* Primary action: open the complete verification URI. */
.primary-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
width: 100%;
min-height: var(--touch-target-min);
padding: 0 var(--space-4);
box-sizing: border-box;
background: var(--color-accent);
color: var(--color-text-on-accent);
border: var(--p-hairline) solid var(--color-accent);
border-radius: var(--radius-md);
font-size: var(--text-base);
font-weight: var(--weight-medium);
cursor: pointer;
text-decoration: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
}
/* Hover only where hover exists (touch taps would flash it). */
@media (hover: hover) and (pointer: fine) {
.primary-link:hover { background: var(--color-accent-hover); border-color: var(--color-accent-hover); }
}
.primary-link:active { transform: scale(0.98); }
.primary-link:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
/* Code + copy row */
.code-row {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface-sunken);
border: var(--p-hairline) solid var(--color-line);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.code-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.code-label {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.code {
font-family: var(--font-mono);
font-size: var(--text-xl);
font-weight: var(--weight-medium);
color: var(--color-text);
letter-spacing: 0.14em;
user-select: text;
}
.copy-btn { flex: none; }
.copy-btn.is-copied { color: var(--color-success); border-color: var(--color-success-bd); }
/* Mobile: the copy action drops to a full-width second row under the code. */
@media (max-width: 639px) {
.code-row {
flex-direction: column;
align-items: stretch;
}
.copy-btn {
min-height: 34px;
}
}
/* Status */
.status {
display: flex;
align-items: center;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: var(--p-hairline) solid var(--color-line);
}
.status-text {
flex: 1;
min-width: 0;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.countdown {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* State transitions: short fade + slight rise (reduced-motion friendly). */
.step-enter-active,
.step-leave-active {
transition: opacity var(--duration-fast) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
}
.step-enter-from { opacity: 0; transform: translateY(4px); }
.step-leave-to { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
.step-enter-active,
.step-leave-active { transition: none; }
}
</style>

View file

@ -0,0 +1,84 @@
// Cookie + query-param helpers for the auth-login page. Pure functions so the
// contract stays unit-testable; the page wires them to document/location.
export const TOKEN_COOKIE_NAME = 'kimi-auth';
/** Read the access token from a `document.cookie`-shaped string. Returns null
when absent or empty. */
export function readTokenCookie(cookieString: string): string | null {
for (const part of cookieString.split(';')) {
const [name, ...rest] = part.trim().split('=');
if (name === TOKEN_COOKIE_NAME) {
const value = rest.join('=');
return value.length > 0 ? decodeURIComponent(value) : null;
}
}
return null;
}
export interface TokenCookieOptions {
/** Unix seconds when the token expires; mapped to the cookie's Expires. */
readonly expiresAt: number;
/** Emit the Secure attribute. True on https; false for http://localhost dev. */
readonly secure: boolean;
}
/** Serialize the token cookie. SameSite=Lax so the tunnel's top-level
navigation back to the app carries it; Path=/ so every route on the host
sees it. */
export function buildTokenCookie(accessToken: string, options: TokenCookieOptions): string {
const expires = new Date(options.expiresAt * 1000).toUTCString();
const parts = [
`${TOKEN_COOKIE_NAME}=${encodeURIComponent(accessToken)}`,
'Path=/',
`Expires=${expires}`,
'SameSite=Lax',
];
if (options.secure) parts.push('Secure');
return parts.join('; ');
}
/** Serialize an immediately-expired token cookie (i.e. delete it). Used when
the server side has rejected the token e.g. the tunnel bounces the user
back with `force_relogin=1` so a stale cookie can't ping-pong the user
between this page and the target. */
export function clearTokenCookie(): string {
return `${TOKEN_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
}
/** Parse and validate the `redirect_uri` query param. Only absolute http(s)
URLs are accepted anything else (javascript:, relative, protocol-relative)
is rejected so the page cannot become an open-redirect trampoline onto
non-http schemes. Returns null when missing or invalid. */
export function parseRedirectUri(search: string): string | null {
const raw = new URLSearchParams(search).get('redirect_uri');
if (!raw) return null;
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
return url.toString();
}
const LOCALE_COOKIE_NAME = 'KIMI_LOCALE';
/** Read the locale preference from the shared `KIMI_LOCALE` cookie (set on the
root domain, so the main site's language choice carries over to this page).
Only en/zh are recognized (regional variants like zh-CN / en-US count);
anything else or absence returns null and the caller falls back to the
browser language, then English. */
export function readLocaleCookie(cookieString: string): 'en' | 'zh' | null {
for (const part of cookieString.split(';')) {
const [name, ...rest] = part.trim().split('=');
if (name === LOCALE_COOKIE_NAME) {
const value = rest.join('=').toLowerCase();
if (value.startsWith('zh')) return 'zh';
if (value.startsWith('en')) return 'en';
return null;
}
}
return null;
}

21
apps/auth-login/src/env.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
/// <reference types="vite/client" />
// `~icons/*?raw` module declarations live in the shared preset.
import '@moonshot-ai/vite-preset/icons';
declare global {
// Injected by Vite `define` (see vite.config.ts): this bundle never ships
// inside the desktop app.
const __KIMI_WEB_DESKTOP__: boolean;
// Injected by Vite `define` (from @moonshot-ai/vite-preset): bundle build
// time (ISO). Declared for shared sources that may reference it.
const __KIMI_BUILD_TIME__: string;
}
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}

160
apps/auth-login/src/flow.ts Normal file
View file

@ -0,0 +1,160 @@
// Adapts the shared useOAuthLoginFlow state machine to direct browser-side
// device-flow calls against the OAuth host (the desktop/web apps inject daemon
// callbacks instead). The OAuth host allows cross-origin POSTs, so the page
// talks to it straight from the browser.
import type {
OAuthLoginFlowCallbacks,
OAuthLoginStartResult,
} from '@moonshot-ai/app-client/composables';
import {
KIMI_CODE_FLOW_CONFIG,
pollDeviceToken,
requestDeviceAuthorization,
type TokenInfo,
} from '@moonshot-ai/kimi-code-oauth/device';
const DEVICE_ID_STORAGE_KEY = 'kimi-code-auth-login.device-id';
const APP_VERSION = '0.0.0';
/** Fallback when the OAuth host omits expires_in (it currently sends 1800). */
const DEFAULT_EXPIRES_IN = 1800;
/** Extra delay folded into a poll callback after a `slow_down` response
(RFC 8628 §3.5 asks for +5s). The shared state machine schedules polls at a
fixed interval, so the backoff is absorbed here by returning later. */
const SLOW_DOWN_EXTRA_MS = 5000;
const sleep = (ms: number): Promise<void> =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
/** crypto.randomUUID is secure-context-only, and this page may well be served
over plain http (LAN IP, plain-http tunnel) fall back to getRandomValues
(available in every context), then Math.random. The id feeds an auxiliary
header, not a secret, so the weakest fallback is still acceptable. */
function randomId(): string {
const c = globalThis.crypto;
if (typeof c?.randomUUID === 'function') return c.randomUUID();
const bytes = new Uint8Array(16);
if (typeof c?.getRandomValues === 'function') {
c.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
}
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
}
let memoryDeviceId: string | null = null;
/** Stable per-browser id, the equivalent of the CLI's `<home>/device_id`.
Falls back to a per-page random id when storage is unavailable (private
mode, blocked origin) the header is auxiliary and must not block sign-in. */
function deviceId(): string {
try {
const stored = localStorage.getItem(DEVICE_ID_STORAGE_KEY);
if (stored) return stored;
const id = randomId();
localStorage.setItem(DEVICE_ID_STORAGE_KEY, id);
return id;
} catch {
memoryDeviceId ??= randomId();
return memoryDeviceId;
}
}
/** X-Msh-* identity reported to the OAuth host: the platform value reuses the
CLI's by decision, and the User-Agent stays the browser's own it cannot
be overridden from JS. */
function deviceHeaders(): Record<string, string> {
return {
'X-Msh-Platform': 'kimi_code_cli',
'X-Msh-Version': APP_VERSION,
'X-Msh-Device-Id': deviceId(),
};
}
export interface AuthLoginFlowOptions {
/** Called the instant a poll returns the token before the state machine's
success dwell so the cookie can be persisted even if the user closes
the tab while the success state is showing. */
readonly onToken?: (token: TokenInfo) => void;
}
export interface AuthLoginFlow {
readonly callbacks: OAuthLoginFlowCallbacks;
/** Token captured by the latest successful poll. */
readonly authenticatedToken: () => TokenInfo | null;
}
export function createAuthLoginFlow(options?: AuthLoginFlowOptions): AuthLoginFlow {
let deviceCode: string | null = null;
let token: TokenInfo | null = null;
async function onStartOAuthLogin(): Promise<OAuthLoginStartResult | null> {
let auth;
try {
auth = await requestDeviceAuthorization(KIMI_CODE_FLOW_CONFIG, {
deviceHeaders: deviceHeaders(),
});
} catch {
// Start failure → the state machine shows the error step.
return null;
}
deviceCode = auth.deviceCode;
token = null;
const expiresIn = auth.expiresIn ?? DEFAULT_EXPIRES_IN;
return {
flowId: auth.deviceCode,
provider: KIMI_CODE_FLOW_CONFIG.name,
status: 'pending',
verificationUri: auth.verificationUri,
verificationUriComplete: auth.verificationUriComplete,
userCode: auth.userCode,
expiresIn,
interval: auth.interval,
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
};
}
async function onPollOAuthLogin(): Promise<{
flowId: string;
status: 'pending' | 'authenticated' | 'expired' | 'cancelled';
resolvedAt?: string;
} | null> {
if (!deviceCode) return null;
let result;
try {
result = await pollDeviceToken(KIMI_CODE_FLOW_CONFIG, deviceCode, {
deviceHeaders: deviceHeaders(),
});
} catch {
// Transport/unknown failure reads as a blip; the state machine retries
// and turns three in a row into the error step.
return null;
}
const flowId = deviceCode;
const resolvedAt = new Date().toISOString();
switch (result.kind) {
case 'success':
token = result.token;
options?.onToken?.(result.token);
return { flowId, status: 'authenticated', resolvedAt };
case 'pending':
if (result.errorCode === 'slow_down') await sleep(SLOW_DOWN_EXTRA_MS);
return { flowId, status: 'pending' };
case 'expired':
return { flowId, status: 'expired', resolvedAt };
case 'denied':
return { flowId, status: 'cancelled', resolvedAt };
}
}
async function onCancelOAuthLogin(): Promise<void> {
deviceCode = null;
}
return {
callbacks: { onStartOAuthLogin, onPollOAuthLogin, onCancelOAuthLogin },
authenticatedToken: () => token,
};
}

View file

@ -0,0 +1,22 @@
import { createKimiI18n } from '@moonshot-ai/app-i18n';
import { readLocaleCookie } from './auth-token';
// Locale resolution for this page: the shared `KIMI_LOCALE` cookie (a
// root-domain cookie, so the main site's language choice carries over) →
// browser language → English.
function detectLocale(): 'en' | 'zh' {
try {
const fromCookie = readLocaleCookie(document.cookie);
if (fromCookie) return fromCookie;
} catch {
// document unavailable — fall through to the browser language.
}
return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en';
}
// Single i18n instance from the shared app-i18n factory: the page reuses the
// client's `login` namespace (plus the rc* keys) and `common` (Spinner's aria
// label).
export const i18n = createKimiI18n({ locale: detectLocale() });
export default i18n;

View file

@ -0,0 +1,22 @@
import { createApp } from 'vue';
import { IconResolverKey } from '@moonshot-ai/app-ui';
import { KimiI18nKey, type KimiI18nApi } from '@moonshot-ai/app-i18n';
import { getIcon, type IconName } from '@moonshot-ai/app-client/icons';
import App from './App.vue';
import i18n from './i18n';
import './style.css';
document.documentElement.lang = i18n.global.locale.value === 'zh' ? 'zh-CN' : 'en';
const app = createApp(App).use(i18n);
// Let package components (e.g. Spinner's aria label) translate without
// importing the global vue-i18n directly.
const kimiI18n: KimiI18nApi = {
t: (key, params) => i18n.global.t(key, params as never),
locale: i18n.global.locale.value,
};
app.provide(KimiI18nKey, kimiI18n);
// Bridge app-ui's <Icon> to the icon registry (unplugin-icons `kimi`
// collection, wired in vite.config.ts).
app.provide(IconResolverKey, (name) => getIcon(name as IconName)?.component);
app.mount('#app');

View file

@ -0,0 +1,21 @@
@import '@moonshot-ai/app-ui/style.css';
html,
body {
margin: 0;
padding: 0;
}
body {
font-family: var(--font-ui);
background: var(--color-bg);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
#app {
min-height: 100dvh;
display: flex;
flex-direction: column;
}

View file

@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import {
buildTokenCookie,
clearTokenCookie,
parseRedirectUri,
readLocaleCookie,
readTokenCookie,
TOKEN_COOKIE_NAME,
} from '../src/auth-token';
describe('readTokenCookie', () => {
it('returns null when the cookie is absent', () => {
expect(readTokenCookie('')).toBeNull();
expect(readTokenCookie('other=abc')).toBeNull();
});
it('reads the token among several cookies', () => {
expect(readTokenCookie(`a=1; ${TOKEN_COOKIE_NAME}=tok123; b=2`)).toBe('tok123');
});
it('returns null for an empty value', () => {
expect(readTokenCookie(`${TOKEN_COOKIE_NAME}=`)).toBeNull();
});
it('decodes percent-encoded values', () => {
expect(readTokenCookie(`${TOKEN_COOKIE_NAME}=${encodeURIComponent('a b=c')}`)).toBe('a b=c');
});
});
describe('buildTokenCookie', () => {
it('sets Path/Expires/SameSite and Secure on https', () => {
const cookie = buildTokenCookie('tok', { expiresAt: 1_900_000_000, secure: true });
expect(cookie).toContain(`${TOKEN_COOKIE_NAME}=tok`);
expect(cookie).toContain('Path=/');
expect(cookie).toContain(`Expires=${new Date(1_900_000_000 * 1000).toUTCString()}`);
expect(cookie).toContain('SameSite=Lax');
expect(cookie).toContain('Secure');
});
it('omits Secure on http (localhost dev)', () => {
const cookie = buildTokenCookie('tok', { expiresAt: 1_900_000_000, secure: false });
expect(cookie).not.toContain('Secure');
});
it('encodes the token value', () => {
expect(buildTokenCookie('a b', { expiresAt: 1_900_000_000, secure: true })).toContain(
`${TOKEN_COOKIE_NAME}=a%20b`,
);
});
});
describe('clearTokenCookie', () => {
it('expires the cookie immediately', () => {
const cookie = clearTokenCookie();
expect(cookie).toContain(`${TOKEN_COOKIE_NAME}=;`);
expect(cookie).toContain('Expires=Thu, 01 Jan 1970');
expect(cookie).toContain('Path=/');
});
});
describe('parseRedirectUri', () => {
it('accepts absolute https URLs', () => {
expect(parseRedirectUri('?redirect_uri=https%3A%2F%2Frc.example.com%2Fs%2Fabc')).toBe(
'https://rc.example.com/s/abc',
);
});
it('accepts http URLs (local tunnel dev)', () => {
expect(parseRedirectUri('?redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2F')).toBe(
'http://127.0.0.1:8080/',
);
});
it('rejects non-http schemes', () => {
expect(parseRedirectUri(`?redirect_uri=${encodeURIComponent('javascript:alert(1)')}`)).toBeNull();
});
it('rejects missing or unparsable values', () => {
expect(parseRedirectUri('')).toBeNull();
expect(parseRedirectUri('?redirect_uri=')).toBeNull();
expect(parseRedirectUri('?redirect_uri=%2F%2Fevil.example.com')).toBeNull();
expect(parseRedirectUri(`?redirect_uri=${encodeURIComponent('/relative/path')}`)).toBeNull();
});
});
describe('readLocaleCookie', () => {
it('returns null when the cookie is absent', () => {
expect(readLocaleCookie('')).toBeNull();
expect(readLocaleCookie('other=1')).toBeNull();
});
it('reads zh and en values among several cookies', () => {
expect(readLocaleCookie('KIMI_LOCALE=zh')).toBe('zh');
expect(readLocaleCookie(`a=1; KIMI_LOCALE=en; ${TOKEN_COOKIE_NAME}=x`)).toBe('en');
});
it('accepts regional variants', () => {
expect(readLocaleCookie('KIMI_LOCALE=zh-CN')).toBe('zh');
expect(readLocaleCookie('KIMI_LOCALE=en-US')).toBe('en');
});
it('ignores unknown values', () => {
expect(readLocaleCookie('KIMI_LOCALE=fr')).toBeNull();
});
});

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"types": ["vite/client", "unplugin-icons/types/vue"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "test/**/*.ts"]
}

View file

@ -0,0 +1,40 @@
import { defineConfig } from 'vite';
import { kimiRendererViteConfig } from '@moonshot-ai/vite-preset';
import { fileURLToPath } from 'node:url';
// Dev/preview port for the standalone auth-login page.
const port = Number(process.env.AUTH_LOGIN_PORT) || 5176;
// Shared renderer preset (Vue plugin, unplugin-icons `kimi` collection sourced
// from app-client's icon dir, ES2022 target). This page talks directly to the
// OAuth host (CORS is open), so there is no dev proxy to layer on.
const preset = kimiRendererViteConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
iconsDir: fileURLToPath(
new URL('./src/icons/kimi', import.meta.resolve('@moonshot-ai/app-client/package.json')),
),
defines: {
// This bundle never ships inside the desktop app (shared sources read the
// flag through a `typeof` guard, so this stays `false` everywhere).
__KIMI_WEB_DESKTOP__: JSON.stringify(false),
},
});
export default defineConfig({
...preset,
// Relative asset URLs: the bundle can be mounted at any path on the RC
// tunnel host without a rebuild.
base: './',
build: {
...preset.build,
outDir: 'dist',
emptyOutDir: true,
},
server: {
port,
strictPort: false,
},
preview: {
port: Number(process.env.AUTH_LOGIN_PREVIEW_PORT) || 4176,
},
});

View file

@ -1,4 +1,5 @@
@import '@moonshot-ai/app-ui/style.css';
@import '@moonshot-ai/app-ui/fonts.css';
/* ---- Minimal reset (replaces Tailwind preflight) ----
Just enough normalization so UA defaults don't leak through; the app's own

View file

@ -9,7 +9,7 @@ The browser web UI for Kimi Code — a peer to the TUI in `apps/kimi-code`. It t
## Design system (normative — required when modifying the UI)
- **Before changing any component, style, layout, or theme, read the design system view at `src/views/DesignSystemView.vue` (open it as an overlay: long-press the sidebar logo).** It is the canonical design system and visual spec for this app (tokens §02, primitives §03, chat §04, theme rules §05, style rules §06). It consumes the product tokens from `src/style.css` directly, so it stays in sync with the app. New and modified UI must match it.
- **Use the primitives from `@moonshot-ai/app-ui`** (source in `packages/app-ui`; import as `import { Button, Icon, … } from '@moonshot-ai/app-ui'`, and the app's `src/style.css` already imports `@moonshot-ai/app-ui/style.css` for the tokens). The library covers Button, IconButton, Badge, Pill, Card, Input/Select/Textarea/Field, Dialog, Spinner, Link, Menu/MenuItem, SegmentedControl, Tabs, Switch, Checkbox, Avatar, EmptyState, Divider, Tooltip, Banner, Sheet, Skeleton, CommandBar, TopBar. One semantic = one component — do not hand-roll a bespoke button/badge/dialog/input for a single screen. When a primitive replaces an element, **delete the old scoped CSS** (do not append override blocks).
- **Use the primitives from `@moonshot-ai/app-ui`** (source in `packages/app-ui`; import as `import { Button, Icon, … } from '@moonshot-ai/app-ui'`, and the app's `src/style.css` imports `@moonshot-ai/app-ui/style.css` for the tokens plus `@moonshot-ai/app-ui/fonts.css` for the self-hosted fonts — a page that only needs tokens may import `style.css` alone). The library covers Button, IconButton, Badge, Pill, Card, Input/Select/Textarea/Field, Dialog, Spinner, Link, Menu/MenuItem, SegmentedControl, Tabs, Switch, Checkbox, Avatar, EmptyState, Divider, Tooltip, Banner, Sheet, Skeleton, CommandBar, TopBar. One semantic = one component — do not hand-roll a bespoke button/badge/dialog/input for a single screen. When a primitive replaces an element, **delete the old scoped CSS** (do not append override blocks).
- **Use the tokens, not ad-hoc values.** Colors, fonts, radii, spacing, shadows, z-index, and motion come from the CSS custom properties in `src/style.css` (catalogued in the design-system view §02). Canonical names are `--color-*` / `--radius-*` / `--space-*` / `--text-*` / `--font-*` / `--z-*` / `--shadow-*` / `--ease-*` / `--duration-*` / `--weight-*` / `--leading-*`, plus `--anim-*` for the designer-exported icon animation tracks (§02 Motion; verbatim Rive timings, intentionally outside the `--duration-*` ramp). A small set of layout/focus tokens keep the `--p-` prefix: `--p-focus-ring`, `--p-selection`, `--p-ic-sm/md/lg`, `--p-sidebar-w`, `--p-content-max/-wide`, `--p-table-max/-cell-max`, `--p-findbar-w`, `--p-findring-w`, `--p-hairline`, `--p-bp-sm/-md`.
- **The chat working indicator (小蓝 mascot + phase label) is reserved** for the chat working state after a prompt is sent only, and is rendered solely by `chat/WorkingIndicator.vue`; every other loading state uses the plain `Spinner`.
- **Run `pnpm --filter kimi-code-web check:style`** (`scripts/check-style.mjs`) — it enforces the §06 anti-pattern rules (no-gradient, no-glassmorphism except TopBar `frost` and the whitelisted menu surfaces via the `--p-menu-backdrop` token, no-emoji-icon, no-hardcoded-hex/font, radius/z/weight from scale). Do not add new violations.

View file

@ -1,4 +1,5 @@
@import '@moonshot-ai/app-ui/style.css';
@import '@moonshot-ai/app-ui/fonts.css';
/* ---- Minimal reset (replaces Tailwind preflight) ----
Just enough normalization so UA defaults don't leak through; the app's own

View file

@ -0,0 +1,158 @@
# Remote Control 鉴权登录页(apps/auth-login)实施方案
> 2026-08-13。Remote Control(RC)链路的鉴权中间页:只承载「Kimi Code OAuth 登录 + 重定向」,移动端优先、响应式兼容 PC。已拍板决策:
>
> 1. 页面放 **code-app 仓内**,新 app 目录名 **`apps/auth-login`**。
> 2. 登录逻辑**复用 kimi-code 仓的 `@moonshot-ai/kimi-code-oauth` 包**(device code flow),浏览器直连 `auth.kimi.com`;token 存 cookie。
> 3. cookie 契约:名 **`kimi-auth`**;回跳参数名 **`redirect_uri`**。
> 4. UI 与 web/desktop 同源(`@moonshot-ai/app-ui` tokens + 原语,Kimi 风格)。
> 5. kimi-code 侧所需的 oauth 包改动由本人完成,工作克隆选干净的那个(见下文)。
## 背景:Remote Control 链路
1. 远程机器本地执行 TUI 的 `/rc` 命令 → 启动本地 server + kimi code web 服务。
2. 请求在线服务 `kimi.com/rc` → 把本地 ip:port 转发到公网。
3. 远端用户(手机)访问公网地址 → **先经过一层鉴权**:用 Kimi Code 的 OAuth 登录,通过后才能访问。
本方案只覆盖第 3 步里的**鉴权中间页**:隧道入口发现无有效凭证 → 跳转到本页(带 `redirect_uri`)→ 本页完成 OAuth device flow → 写 cookie → 跳回 `redirect_uri`。隧道服务本身的鉴权校验、`/rc` 命令、`kimi.com/rc` 转发服务均不在本方案范围。
## 已验证的事实(2026-08-13 实测 / 代码引用为准)
- **CORS 全通**:`OPTIONS https://auth.kimi.com/api/oauth/device_authorization``/api/oauth/token` 均返回 `access-control-allow-origin: *`,`content-type` / `x-msh-platform` 头放行。移动端浏览器直连 auth.kimi.com 无障碍。
- **授权确认页已存在,不在本方案范围**:真实调用 `POST /api/oauth/device_authorization`(client_id `17e5f671-d194-4dfb-9706-5516cb48c098`)返回 `verification_uri = https://www.kimi.com/code/authorize_device`。用户在手机浏览器打开该页完成「登录账号 + 确认授权」,我们不做这个页面。实测该接口**不带 `X-Msh-*` 头也成功**,设备头可选。
- **oauth 包的浏览器可用性**(`kimi-code/packages/oauth/src`):
- `oauth.ts``requestDeviceAuthorization` / `pollDeviceToken` / `refreshAccessToken` 是纯 `fetch` 封装;依赖闭包(`api-error.ts` / `errors.ts` / `types.ts` / `utils.ts` / `constants.ts`)均无 Node import——**浏览器安全**。
- 但顶层 `index.ts` barrel 会拖进 `OAuthManager`(`node:fs` / `proper-lockfile`)、`identity.ts`(`node:os` / `node:child_process`)、`toolkit.ts``storage.ts`——**浏览器 import 包根会炸**,必须加浏览器安全的子路径 export。
- `tsdown.config.ts` 单入口 `./src/index.ts`;加子路径需同步加 entry。
- `KIMI_CODE_FLOW_CONFIG`(oauthHost + clientId)由 `constants.ts` 导出,纯模块,直接复用——授权页显示的就是 Kimi Code,语义正确。
- **workspace**:`pnpm-workspace.yaml` 已含 `kimi-code/packages/*`,新 app 按包名 `workspace:*` 依赖即可,符合「只经包名 import」硬约束。
- **状态机复用**:`useOAuthLoginFlow`(`packages/app-client/src/composables/useOAuthLoginFlow.ts`)是回调注入式 device-flow 状态机(starting → device-code → success/expired/error、倒计时、轮询三连败容错、disposed 清理),埋点 `track` 默认 no-op(`contracts.ts``noopProductTracker`)。新 app 直接复用,回调改为直连 oauth 包,零重写。
- **既有约定要守住**:授权完成页读 `from=kimi_code_desktop` 渲染「打开 App」按钮并跳 `kimi-code://auth/success`(`apps/desktop/src/renderer/lib/loginSource.ts``apps/desktop/src/main/deep-link.ts`)。RC 场景**不带** `from` 参数(裸 URL),该按钮保持隐藏。
## kimi-code 侧改动(一个小 PR)
**工作克隆选择**(2026-08-13 核查):
| 克隆 | 分支 | 状态 | 结论 |
|---|---|---|---|
| kimi-code-2 | feat/oauth-region-split | 3 脏文件 | 有任务,不动 |
| kimi-code-3 | fix/sdk-retry-cancel | 干净但在任务分支 | 有任务,不动 |
| kimi-code-5 | auto-title | 4 脏文件 | 有任务,不动 |
| kimi-code-tips | feat/upgrade-reminder-banner | 2 脏文件 | 有任务,不动 |
| kimi-code-ky | main | 干净但不可用(拍板排除) | 不动 |
| kimi-code / kimi-code-4 | main | 干净、无未推送、node_modules 齐 | 候选 |
**选用 `~/Desktop/moonshot/kimi-code-4`**(干净 main;备选 kimi-code),工作分支 `feat/oauth-device-subpath-export`
改动(`packages/oauth`,均为新增导出,无行为变更):
1. 新建 `src/device.ts`:re-export 浏览器安全闭包——`./oauth` 的三函数与 `DevicePollResult` 类型、`./constants``KIMI_CODE_FLOW_CONFIG``./types` 的相关类型(`DeviceAuthorization` / `TokenInfo` / `OAuthFlowConfig` / `OAuthRequestHeaders`)、`./errors` 的错误类。
2. `package.json` `exports` 增加:`"./device": { "types": "./src/device.ts", "default": "./src/device.ts" }`
3. `tsdown.config.ts` `entry` 增加 `./src/device.ts`(dist 双产物)。
4. 验证:`pnpm --filter @moonshot-ai/kimi-code-oauth typecheck``build`,以及 oauth 包既有测试全绿。
5. PR 标题:`feat(oauth): add browser-safe ./device subpath export`(Conventional Commits,无署名)。
## code-app 侧改动
### 新 app `apps/auth-login`
**形态**:Vue 3 + Vite + TS strict,无 router、无 Pinia,vue-i18n v11(Composition 模式,`legacy: false`,fallback `en`,跟随浏览器 locale)。pnpm workspace 成员,private。
**依赖**:
- `@moonshot-ai/app-ui`(tokens + Button / Spinner / Icon 原语;`src/style.css` import `@moonshot-ai/app-ui/style.css`)
- `@moonshot-ai/app-client`(`useOAuthLoginFlow`)
- `@moonshot-ai/kimi-code-oauth`(**只经 `./device` 子路径** import)
- `vue` / `vue-i18n`;dev 依赖 `@moonshot-ai/vite-preset``@vitejs/plugin-vue``vite``typescript``vue-tsc``vitest`
**vite 配置**:复用 `kimiRendererViteConfig`(iconsDir 指向 app-client 的 kimi 图标目录,同 apps/web 的做法);`base: './'`(产物可挂任意路径);**无 dev proxy**(浏览器直连 auth.kimi.com,无需代理);独立端口(如 `5176`,env `AUTH_LOGIN_PORT` 可调)。
**文件结构**:
```
apps/auth-login/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── index.html # viewport-fit=cover、页面标题、noindex
├── src/
│ ├── main.ts # 启动 + i18n 安装
│ ├── App.vue # 单页状态机 UI(复用 useOAuthLoginFlow)
│ ├── flow.ts # 回调适配:useOAuthLoginFlow ← oauth 包直连
│ ├── auth-token.ts # cookie 读写 + redirect_uri 解析校验(纯函数,可测)
│ ├── style.css # import app-ui tokens + 页面级样式
│ └── i18n/
│ ├── index.ts
│ └── locales/{en,zh}.ts
└── test/
└── auth-token.test.ts # cookie / redirect_uri 纯函数测试
```
**页面状态机**(进入页面即跑):
```
1. checking:读 cookie kimi-auth
→ 存在且未过期:有 redirect_uri → 立即 location.replace 回跳;无 → 显示「已登录,可关闭本页」
2. 无/过期 → startFlow():
requestDeviceAuthorization(flowConfig(), { deviceHeaders: 见「请求身份」节 })
3. device-code:
- 主按钮「前往授权」新标签打开 verification_uri_complete(authorize_device 页)
- 展示 user_code + 复制授权链接(二次路径,同 LoginDialog 的 parked 思路)
- 倒计时(expires_in 1800s)+ 轮询(pollDeviceToken,server 建议 interval=5s,三连败转 error)
4. success:无条件写 cookie;有 redirect_uri → location.replace 回跳,无 → 停留显示「登录成功,可关闭本页」
5. expired / error:提示 + 重试按钮(重新 startFlow)
```
**cookie 写入**(`auth-token.ts`):
- `kimi-auth=<access_token>`;`Path=/`;`Secure`;`SameSite=Lax`(顶级导航跳转需携带);`Expires` = token 的 `expiresAt`
- 只存 access token(拍板);过期后隧道侧 401 → 再跳回本页 → 重走 device flow(authorize_device 页有会话时确认一键完成,体验可接受)。
- refresh token 不落 cookie;页面生命周期短(登录即跳走),无需 refresh 逻辑。
**请求身份(拍板)**:
- `X-Msh-Platform: kimi_code_cli`(**拍板沿用 CLI 的值**,不新造 RC 专属 platform);`X-Msh-Version` = auth-login 自身版本;`X-Msh-Device-Id` = localStorage 持久化的 `crypto.randomUUID()`(首次访问生成,语义等价 CLI 的 device_id 文件)。`Device-Name` / `Device-Model` / `Os-Version` 浏览器拿不到准确值,不伪造、不带(实测不带头接口也正常,头是可选统计维度)。
- `User-Agent`:**一定会传,且 JS 无法干预**——`User-Agent` 是浏览器 forbidden header,fetch 既改不了也删不掉,每个请求自动携带用户浏览器的原生 UA(iPhone Safari / Android Chrome 等)。Node 侧 CLI/desktop 经 `createKimiDefaultHeaders` 把产品 UA 注入 `deviceHeaders`(`kimi-code-cli/x.y.z`);浏览器里即使写这个键也会被 fetch 静默丢弃,所以页面不传。最终服务端看到的 RC web 流量 = `X-Msh-Platform: kimi_code_cli` + 浏览器原生 UA,按 UA 仍可区分出浏览器形态。
- 自定义头触发 CORS preflight;`x-msh-platform` 已实测被服务端回显放行(动态回显),其余头实施时顺手验证。
**redirect_uri 处理(可选)**:
- `redirect_uri` **不必填**:隧道场景由隧道跳转时拼在登录地址上带来;直接访问(无参)也正常走登录流程。
- 仅允许 `https:` / `http:`(拒绝 `javascript:` 等);缺失或非法一律按「无跳转目标」宽容降级,不阻塞登录。
- 授权成功后 **cookie 无条件写入**;有 `redirect_uri``location.replace` 回跳,无则停留显示「登录成功,可以关闭本页」。已持有有效 cookie 直接进入时同理:有参跳走,无参显示已登录态。
- 隧道域名动态生成,不做白名单;token 只经 cookie 传递、不上 URL,跳转本身不带敏感信息,开放重定向风险可控。
**移动端 / 响应式**:
- 页面无文本输入框(user_code 是展示),天然规避 iOS 聚焦缩放与键盘遮挡问题。
- `viewport-fit=cover` + `env(safe-area-inset-*)` 上下安全区;触控目标 ≥ 44px。
- 移动端纵向居中卡片全宽;`≥640px` 桌面居中窄卡片(约 400px),内容同构。
- 同设备旅程:「前往授权」开新标签 → 授权 → 切回本标签时轮询已成功 → 自动跳走;授权页标签由用户自行关闭(与 desktop LoginDialog 同模式)。
- 亮/暗双主题:跟随系统(`data-color-scheme` 模式,token 自动适配),亮暗两态都要视觉验证。
**样式纪律**:全部走 `--color-*` / `--space-*` / `--text-*` / `--radius-*` token,零 ad-hoc 值;UI 实现前读 `kimi-design-skill` 移动端规范与 `apps/desktop/src/renderer/views/DesignSystemView.vue`
### 双仓工作流顺序
1. **kimi-code-4**:oauth 包子路径 export 改动 → typecheck/build/test → 推送分支、提 PR。
2. **code-app 联调**:`kimi-code/` submodule `git fetch origin feat/oauth-device-subpath-export && git checkout <commit>`,auth-login 即可经 `./device` 子路径 import 开发。
3. kimi-code PR merge 后:submodule checkout 到 main 的新 commit,`git add kimi-code` bump(单独 commit)。
4. **auth-login 开发**:脚手架 → flow/cookie 纯函数 + 测试 → UI → i18n → 双主题视觉验证。
5. 验证:`pnpm typecheck``pnpm lint`、auth-login 的 `vitest`;`pnpm dev` 起页面真实走一遍 device flow(桌面浏览器 + 手机各一遍),确认 cookie 写入与回跳。
### changeset
auth-login 独立于 desktop(`kimi-code-app`)发版,产物为静态页、不进 release CI。初步判断本改动对 kimi-code-app 无用户可见变化、**不需要 changeset**;提 PR 前按 `changeset` skill 规则最终确认。
## 明确不做
- 不做授权确认页(`www.kimi.com/code/authorize_device` 已存在)。
- 不动隧道服务鉴权逻辑、`/rc` 命令、`kimi.com/rc` 转发服务;cookie 契约以拍板为准。
- 不接 telemetry(`track` 保持 no-op);不存 refresh token;不做登录态管理页。
## 风险与备注
- **CORS 后续收紧**:当前 `*` 且无凭证请求,风险低;若收紧,退路是 RC 服务同域反代 auth.kimi.com 接口(页面只改 baseURL)。
- **authorize_device 页的移动端体验**归其所属团队;若发现该页移动端不适配,会影响整体旅程,需反馈——本方案不绕开它。
- **cookie 域**:cookie 写在页面当前域,要求登录页与隧道入口同域部署(部署侧职责,产物用 `base: './'` 适配)。
- **子路径 export 是新增面**:kimi-code 侧只加 export 不动行为,对 CLI/desktop 零影响。

@ -1 +1 @@
Subproject commit 43c68f58f578c88d9f503afb72f12d343c2aa5c7
Subproject commit 4a93f70aa2cf5f70a88b4f8eeb2e409aab2c8f59

View file

@ -14,7 +14,7 @@
"dev:web": "pnpm --filter kimi-code-web run dev",
"sync:web": "node scripts/sync-web-to-kimi-code.mjs",
"package:macos": "bash apps/desktop/scripts/package-local-macos.sh",
"typecheck": "pnpm --filter kimi-code-app run typecheck && pnpm --filter kimi-code-web run typecheck",
"typecheck": "pnpm --filter kimi-code-app run typecheck && pnpm --filter kimi-code-web run typecheck && pnpm --filter kimi-code-auth-login run typecheck",
"lint": "oxlint --type-aware --config .oxlintrc.json",
"test": "vitest run"
},

View file

@ -30,4 +30,15 @@ export default {
// Send gating (signed-in free account, no usable models)
upgradeRequiredTitle: 'Upgrade required',
upgradeRequiredMessage: 'Your account is on the free plan. Upgrade to a membership to start chatting with Kimi models.',
// Remote Control auth-login page (apps/auth-login)
rcSubtitle: 'Remote Control session',
rcChecking: 'Checking sign-in status…',
rcLead: 'This Remote Control session requires authorization. Sign in with your Kimi account to continue.',
rcAuthorize: 'Open authorization page',
rcUserCodeLabel: 'Authorization code',
rcSuccessHint: 'Redirecting…',
rcExpiredTitle: 'Authorization expired or declined',
rcStartErrorTitle: 'Could not start sign-in',
rcConnectionErrorHint: 'Check your connection and try again.',
rcSuccessNoRedirect: 'You can close this page now.',
} as const;

View file

@ -30,4 +30,15 @@ export default {
// Send gating (signed-in free account, no usable models)
upgradeRequiredTitle: '请升级会员',
upgradeRequiredMessage: '当前为免费账户,升级会员后即可使用 Kimi 模型开始对话。',
// Remote Control auth-login page (apps/auth-login)
rcSubtitle: '远程控制会话',
rcChecking: '正在检查登录状态…',
rcLead: '此远程控制会话需要授权。使用 Kimi 账号登录后即可继续。',
rcAuthorize: '打开授权页',
rcUserCodeLabel: '授权码',
rcSuccessHint: '正在跳转…',
rcExpiredTitle: '授权已过期或被取消',
rcStartErrorTitle: '无法开始登录',
rcConnectionErrorHint: '请检查网络连接后重试。',
rcSuccessNoRedirect: '现在可以关闭本页了。',
} as const;

View file

@ -5,7 +5,8 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./style.css": "./src/style.css"
"./style.css": "./src/style.css",
"./fonts.css": "./src/fonts.css"
},
"dependencies": {
"@moonshot-ai/app-i18n": "workspace:*"

View file

@ -0,0 +1,29 @@
/* Self-hosted variable fonts, generated once into ./assets/fonts by
scripts/prepare-fonts.mjs (gitignored) and fingerprinted by Vite into each
final build. Kept as a separate entry from style.css so font-free consumers
(e.g. apps/auth-login, which falls back to platform fonts) don't ship
~7.5MB of CJK. Consumers that want the Kimi typefaces import this once,
alongside style.css. */
@font-face {
font-family: "Noto Sans SC Variable";
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url("./assets/fonts/NotoSansSC[wght].woff2") format("woff2-variations");
}
@font-face {
font-family: "Schibsted Grotesk Variable";
font-style: normal;
font-display: swap;
font-weight: 400 900;
src: url("./assets/fonts/SchibstedGrotesk[wght].woff2") format("woff2-variations");
}
@font-face {
font-family: "Schibsted Grotesk Variable";
font-style: italic;
font-display: swap;
font-weight: 400 900;
src: url("./assets/fonts/SchibstedGrotesk-Italic[wght].woff2") format("woff2-variations");
}

View file

@ -1,29 +1,6 @@
/* UI variable fonts are generated once in app-ui so both renderer builds
resolve the same local assets. Vite fingerprints them into each final build. */
@font-face {
font-family: "Noto Sans SC Variable";
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url("./assets/fonts/NotoSansSC[wght].woff2") format("woff2-variations");
}
@font-face {
font-family: "Schibsted Grotesk Variable";
font-style: normal;
font-display: swap;
font-weight: 400 900;
src: url("./assets/fonts/SchibstedGrotesk[wght].woff2") format("woff2-variations");
}
@font-face {
font-family: "Schibsted Grotesk Variable";
font-style: italic;
font-display: swap;
font-weight: 400 900;
src: url("./assets/fonts/SchibstedGrotesk-Italic[wght].woff2") format("woff2-variations");
}
/* Design tokens + shared styles. The self-hosted variable fonts live in
./fonts.css, a separate entry so font-free consumers don't ship ~7.5MB of
CJK consumers that want the Kimi typefaces import it alongside this file. */
:root {
--dim: rgba(0, 0, 0, 0.6);
--muted: rgba(0, 0, 0, 0.45);

46
pnpm-lock.yaml generated
View file

@ -53,6 +53,52 @@ importers:
specifier: 2.0.0
version: 2.0.0
apps/auth-login:
dependencies:
'@moonshot-ai/app-client':
specifier: workspace:*
version: link:../../packages/app-client
'@moonshot-ai/app-core':
specifier: workspace:*
version: link:../../packages/app-core
'@moonshot-ai/app-i18n':
specifier: workspace:*
version: link:../../packages/app-i18n
'@moonshot-ai/app-ui':
specifier: workspace:*
version: link:../../packages/app-ui
'@moonshot-ai/kimi-code-oauth':
specifier: workspace:*
version: link:../../kimi-code/packages/oauth
vue:
specifier: ^3.5.35
version: 3.5.39(typescript@6.0.2)
vue-i18n:
specifier: ^11.4.5
version: 11.4.6(vue@3.5.39(typescript@6.0.2))
devDependencies:
'@moonshot-ai/vite-preset':
specifier: workspace:*
version: link:../../packages/vite-preset
'@vitejs/plugin-vue':
specifier: ^5.2.4
version: 5.2.4(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.2))
typescript:
specifier: 6.0.2
version: 6.0.2
unplugin-icons:
specifier: ^23.0.0
version: 23.0.1(@vue/compiler-sfc@3.5.39)
vite:
specifier: ^6.3.3
version: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.0)(yaml@2.9.0)
vitest:
specifier: 4.1.4
version: 4.1.4(@types/node@24.13.3)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.0)(yaml@2.9.0))
vue-tsc:
specifier: ~3.2.0
version: 3.2.9(typescript@6.0.2)
apps/desktop:
dependencies:
'@xterm/addon-web-links':