File size: 3,965 Bytes
f0634fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | /**
* File-based OAuth token storage.
*
* Tokens are persisted under a directory (default
* `~/.kimi-code/credentials/`) as `<name>.json` with mode 0600 (parent
* dir 0700). Wire format uses snake_case to match the server contract.
*
* Write semantics: write to `<name>.tmp.<pid>.<rand>` → fsync → rename.
* Atomic on POSIX; Windows best-effort.
*
* Load semantics: missing file → undefined. Corrupt JSON / wrong shape →
* undefined (never throws). Callers treat undefined as "no token stored".
*/
import { randomBytes } from 'node:crypto';
import {
chmodSync,
closeSync,
fsyncSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
renameSync,
unlinkSync,
writeSync,
} from 'node:fs';
import { basename, join } from 'node:path';
import type { TokenInfo, TokenInfoWire } from './types';
import { tokenFromWire, tokenToWire } from './types';
import { isRecord } from './utils';
export interface TokenStorage {
load(name: string): Promise<TokenInfo | undefined>;
save(name: string, token: TokenInfo): Promise<void>;
remove(name: string): Promise<void>;
list(): Promise<string[]>;
}
export class FileTokenStorage implements TokenStorage {
private readonly dir: string;
constructor(dir: string) {
this.dir = dir;
}
private ensureDir(): void {
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
// recursive=true with mode only applies on initial create; tighten after
// the fact in case an existing dir had looser permissions.
try {
chmodSync(this.dir, 0o700);
} catch {
// best-effort; Windows / read-only FS may refuse
}
}
private pathFor(name: string): string {
// Guard against path traversal: caller-provided names (from config.toml
// or slash commands) must not escape the credentials dir. `basename`
// strips any `..` or `/` segments; if the sanitized value differs from
// the input we refuse the request entirely rather than silently
// writing to a different file than the caller asked for.
const safe = basename(name);
if (safe.length === 0 || safe !== name || safe.startsWith('.')) {
throw new Error(`Invalid token name: "${name}"`);
}
return join(this.dir, `${safe}.json`);
}
async load(name: string): Promise<TokenInfo | undefined> {
const file = this.pathFor(name);
let raw: string;
try {
raw = readFileSync(file, 'utf-8');
} catch {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return undefined;
}
if (!isRecord(parsed)) return undefined;
return tokenFromWire(parsed as Partial<TokenInfoWire>);
}
async save(name: string, token: TokenInfo): Promise<void> {
this.ensureDir();
const target = this.pathFor(name);
const tmp = `${target}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
const data = Buffer.from(`${JSON.stringify(tokenToWire(token), null, 2)}\n`, 'utf-8');
const fd = openSync(tmp, 'w', 0o600);
try {
let written = 0;
while (written < data.length) {
written += writeSync(fd, data, written, data.length - written);
}
fsyncSync(fd);
} finally {
closeSync(fd);
}
try {
// chmod again in case umask stripped bits during open
chmodSync(tmp, 0o600);
renameSync(tmp, target);
} catch (error) {
try {
unlinkSync(tmp);
} catch {
/* ignore */
}
throw error;
}
}
async remove(name: string): Promise<void> {
try {
unlinkSync(this.pathFor(name));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
async list(): Promise<string[]> {
let entries: string[];
try {
entries = readdirSync(this.dir);
} catch {
return [];
}
return entries.filter((e) => e.endsWith('.json')).map((e) => e.slice(0, -'.json'.length));
}
}
|