offrouter-core 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -0
- package/src/audit.test.ts +302 -0
- package/src/audit.ts +553 -0
- package/src/auth/credential-chain.test.ts +202 -0
- package/src/auth/credential-chain.ts +181 -0
- package/src/auth/index.ts +32 -0
- package/src/auth/keychain.test.ts +75 -0
- package/src/auth/keychain.ts +39 -0
- package/src/auth/oauth-pkce.test.ts +184 -0
- package/src/auth/oauth-pkce.ts +409 -0
- package/src/config.test.ts +415 -0
- package/src/config.ts +484 -0
- package/src/delegation.test.ts +368 -0
- package/src/delegation.ts +605 -0
- package/src/index.ts +261 -0
- package/src/policy.test.ts +634 -0
- package/src/policy.ts +418 -0
- package/src/protocol.ts +7 -0
- package/src/providers/adapter.test.ts +293 -0
- package/src/providers/adapter.ts +324 -0
- package/src/providers/catalog-data.test.ts +258 -0
- package/src/providers/catalog-data.ts +498 -0
- package/src/providers/catalog.test.ts +84 -0
- package/src/providers/catalog.ts +483 -0
- package/src/providers/fake.ts +312 -0
- package/src/providers/openai-compatible.test.ts +366 -0
- package/src/providers/openai-compatible.ts +554 -0
- package/src/proxy/responses-mapper.test.ts +290 -0
- package/src/proxy/responses-mapper.ts +736 -0
- package/src/proxy/responses-server.test.ts +322 -0
- package/src/proxy/responses-server.ts +469 -0
- package/src/router.test.ts +562 -0
- package/src/router.ts +323 -0
- package/src/schemas.test.ts +240 -0
- package/src/schemas.ts +203 -0
- package/src/secrets.test.ts +271 -0
- package/src/secrets.ts +461 -0
- package/src/types.ts +167 -0
- package/src/usage.test.ts +283 -0
- package/src/usage.ts +669 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for credential-chain resolver.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
Redacted,
|
|
7
|
+
resolveCredential,
|
|
8
|
+
type CredentialSource,
|
|
9
|
+
} from "./credential-chain.js";
|
|
10
|
+
import type { SecretStore } from "../secrets.js";
|
|
11
|
+
|
|
12
|
+
function stubStore(entries: Record<string, string>): SecretStore {
|
|
13
|
+
return {
|
|
14
|
+
get: vi.fn(async (id: string) => entries[id]),
|
|
15
|
+
set: vi.fn(),
|
|
16
|
+
delete: vi.fn(),
|
|
17
|
+
listProviders: vi.fn(async () => Object.keys(entries)),
|
|
18
|
+
redact: vi.fn((v: string) => v),
|
|
19
|
+
toJSON: vi.fn(() => ({})),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("Redacted", () => {
|
|
24
|
+
it("wraps a value and hides it in toJSON", () => {
|
|
25
|
+
const r = new Redacted("sk-ant-test123");
|
|
26
|
+
expect(r.rawValue).toBe("sk-ant-test123");
|
|
27
|
+
expect(r.toJSON()).toBe("[REDACTED]");
|
|
28
|
+
expect(JSON.stringify(r)).toBe('"[REDACTED]"');
|
|
29
|
+
expect(String(r)).toBe("[REDACTED]");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns a non-empty rawValue", () => {
|
|
33
|
+
const r = new Redacted("some-token");
|
|
34
|
+
expect(r.rawValue).toBe("some-token");
|
|
35
|
+
expect(r.rawValue.length).toBeGreaterThan(0);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("resolveCredential", () => {
|
|
40
|
+
it("returns null for empty sources array", async () => {
|
|
41
|
+
const result = await resolveCredential([], { store: stubStore({}) });
|
|
42
|
+
expect(result).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("env source", () => {
|
|
46
|
+
it("returns value from process.env", async () => {
|
|
47
|
+
const prev = process.env["OFFROUTER_TEST_KEY"];
|
|
48
|
+
process.env["OFFROUTER_TEST_KEY"] = "env-value";
|
|
49
|
+
try {
|
|
50
|
+
const sources: CredentialSource[] = [
|
|
51
|
+
{ type: "env", name: "OFFROUTER_TEST_KEY" },
|
|
52
|
+
];
|
|
53
|
+
const result = await resolveCredential(sources, { store: stubStore({}) });
|
|
54
|
+
expect(result).not.toBeNull();
|
|
55
|
+
expect(result!.value.rawValue).toBe("env-value");
|
|
56
|
+
expect(result!.source).toBe("env");
|
|
57
|
+
} finally {
|
|
58
|
+
if (prev === undefined) {
|
|
59
|
+
delete process.env["OFFROUTER_TEST_KEY"];
|
|
60
|
+
} else {
|
|
61
|
+
process.env["OFFROUTER_TEST_KEY"] = prev;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("skips env when variable is undefined", async () => {
|
|
67
|
+
const sources: CredentialSource[] = [
|
|
68
|
+
{ type: "env", name: "OFFROUTER_DOES_NOT_EXIST_XYZ" },
|
|
69
|
+
];
|
|
70
|
+
const result = await resolveCredential(sources, { store: stubStore({}) });
|
|
71
|
+
expect(result).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("keychain source", () => {
|
|
76
|
+
it("returns value from secret store", async () => {
|
|
77
|
+
const store = stubStore({ "anthropic": "sk-ant-keychain-test" });
|
|
78
|
+
const sources: CredentialSource[] = [
|
|
79
|
+
{ type: "keychain", providerId: "anthropic" },
|
|
80
|
+
];
|
|
81
|
+
const result = await resolveCredential(sources, { store });
|
|
82
|
+
expect(result).not.toBeNull();
|
|
83
|
+
expect(result!.value.rawValue).toBe("sk-ant-keychain-test");
|
|
84
|
+
expect(result!.source).toBe("keychain");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("skips keychain when store returns undefined", async () => {
|
|
88
|
+
const store = stubStore({});
|
|
89
|
+
const sources: CredentialSource[] = [
|
|
90
|
+
{ type: "keychain", providerId: "unknown" },
|
|
91
|
+
];
|
|
92
|
+
const result = await resolveCredential(sources, { store });
|
|
93
|
+
expect(result).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("chain order", () => {
|
|
98
|
+
it("returns first non-empty source (env beats keychain)", async () => {
|
|
99
|
+
process.env["ANTHROPIC_API_KEY"] = "from-env";
|
|
100
|
+
try {
|
|
101
|
+
const store = stubStore({ "anthropic": "from-keychain" });
|
|
102
|
+
const sources: CredentialSource[] = [
|
|
103
|
+
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
|
104
|
+
{ type: "keychain", providerId: "anthropic" },
|
|
105
|
+
];
|
|
106
|
+
const result = await resolveCredential(sources, { store });
|
|
107
|
+
expect(result).not.toBeNull();
|
|
108
|
+
expect(result!.value.rawValue).toBe("from-env");
|
|
109
|
+
expect(result!.source).toBe("env");
|
|
110
|
+
} finally {
|
|
111
|
+
delete process.env["ANTHROPIC_API_KEY"];
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("falls through to keychain when env is missing", async () => {
|
|
116
|
+
const store = stubStore({ "openai": "sk-from-keychain" });
|
|
117
|
+
const sources: CredentialSource[] = [
|
|
118
|
+
{ type: "env", name: "OPENAI_API_KEY_MISSING" },
|
|
119
|
+
{ type: "keychain", providerId: "openai" },
|
|
120
|
+
];
|
|
121
|
+
const result = await resolveCredential(sources, { store });
|
|
122
|
+
expect(result).not.toBeNull();
|
|
123
|
+
expect(result!.value.rawValue).toBe("sk-from-keychain");
|
|
124
|
+
expect(result!.source).toBe("keychain");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns null when all sources fail", async () => {
|
|
128
|
+
const store = stubStore({});
|
|
129
|
+
const sources: CredentialSource[] = [
|
|
130
|
+
{ type: "env", name: "MISSING_VAR_1" },
|
|
131
|
+
{ type: "env", name: "MISSING_VAR_2" },
|
|
132
|
+
{ type: "keychain", providerId: "nonexistent" },
|
|
133
|
+
];
|
|
134
|
+
const result = await resolveCredential(sources, { store });
|
|
135
|
+
expect(result).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("config source (trusted config)", () => {
|
|
140
|
+
it("returns value from trusted config provider apiKey", async () => {
|
|
141
|
+
const store = stubStore({});
|
|
142
|
+
const sources: CredentialSource[] = [
|
|
143
|
+
{ type: "config", key: "providers.anthropic.apiKey" },
|
|
144
|
+
];
|
|
145
|
+
const result = await resolveCredential(sources, {
|
|
146
|
+
store,
|
|
147
|
+
config: { providers: { anthropic: { apiKey: "sk-ant-config" } } } as Record<string, unknown>,
|
|
148
|
+
});
|
|
149
|
+
expect(result).not.toBeNull();
|
|
150
|
+
expect(result!.value.rawValue).toBe("sk-ant-config");
|
|
151
|
+
expect(result!.source).toBe("config");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns null when config is not provided", async () => {
|
|
155
|
+
const store = stubStore({});
|
|
156
|
+
const sources: CredentialSource[] = [
|
|
157
|
+
{ type: "config", key: "providers.anthropic.apiKey" },
|
|
158
|
+
];
|
|
159
|
+
const result = await resolveCredential(sources, { store });
|
|
160
|
+
expect(result).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("handles dot-notation path", async () => {
|
|
164
|
+
const store = stubStore({});
|
|
165
|
+
const sources: CredentialSource[] = [
|
|
166
|
+
{ type: "config", key: "providers.openai.apiKey" },
|
|
167
|
+
];
|
|
168
|
+
const result = await resolveCredential(sources, {
|
|
169
|
+
store,
|
|
170
|
+
config: {
|
|
171
|
+
providers: {
|
|
172
|
+
openai: { enabled: true },
|
|
173
|
+
anthropic: { apiKey: "wrong-key" },
|
|
174
|
+
},
|
|
175
|
+
} as Record<string, unknown>,
|
|
176
|
+
});
|
|
177
|
+
expect(result).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("credentialSource type narrowing", () => {
|
|
183
|
+
it("env source type is correct literal", () => {
|
|
184
|
+
const s: CredentialSource = { type: "env", name: "API_KEY" };
|
|
185
|
+
expect(s.type).toBe("env");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("keychain source type is correct literal", () => {
|
|
189
|
+
const s: CredentialSource = { type: "keychain", providerId: "anthropic" };
|
|
190
|
+
expect(s.type).toBe("keychain");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("config source type is correct literal", () => {
|
|
194
|
+
const s: CredentialSource = { type: "config", key: "providers.anthropic.apiKey" };
|
|
195
|
+
expect(s.type).toBe("config");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("oauth source type is correct literal", () => {
|
|
199
|
+
const s: CredentialSource = { type: "oauth", provider: "anthropic" };
|
|
200
|
+
expect(s.type).toBe("oauth");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential-chain resolution for OffRouter.
|
|
3
|
+
*
|
|
4
|
+
* Tries each source in order: env -> config -> keychain -> (oauth, opt-in).
|
|
5
|
+
* Never logs secret values. Uses Redacted<T> wrapper for safe output.
|
|
6
|
+
* Never scrapes other CLI credential stores.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SecretStore } from "../secrets.js";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Redacted wrapper
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wrapper that hides secret material from JSON serialization and string
|
|
17
|
+
* conversion. The raw value is accessible via `.rawValue` for use in
|
|
18
|
+
* Authorization headers, never for logging.
|
|
19
|
+
*/
|
|
20
|
+
export class Redacted<T extends string = string> {
|
|
21
|
+
readonly #value: T;
|
|
22
|
+
|
|
23
|
+
constructor(value: T) {
|
|
24
|
+
this.#value = value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Access the raw secret value. Use only for provider API calls. */
|
|
28
|
+
get rawValue(): T {
|
|
29
|
+
return this.#value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Returns a constant redacted string for JSON serialization. */
|
|
33
|
+
toJSON(): string {
|
|
34
|
+
return "[REDACTED]";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns a constant redacted string for debugging. */
|
|
38
|
+
toString(): string {
|
|
39
|
+
return "[REDACTED]";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Credential source types
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A single credential source in the chain.
|
|
49
|
+
* Each source is tried in order; the first non-empty value wins.
|
|
50
|
+
*/
|
|
51
|
+
export type CredentialSource =
|
|
52
|
+
| { type: "env"; name: string }
|
|
53
|
+
| { type: "config"; key: string }
|
|
54
|
+
| { type: "keychain"; providerId: string }
|
|
55
|
+
| { type: "oauth"; provider: string };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Result of a successful credential resolution.
|
|
59
|
+
*/
|
|
60
|
+
export interface CredentialResult {
|
|
61
|
+
/**
|
|
62
|
+
* The resolved credential value, wrapped in Redacted so it cannot be
|
|
63
|
+
* accidentally logged or serialized.
|
|
64
|
+
*/
|
|
65
|
+
value: Redacted<string>;
|
|
66
|
+
/** Which source type produced the value. */
|
|
67
|
+
source: "env" | "config" | "keychain" | "oauth";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Options for resolveCredential.
|
|
72
|
+
*/
|
|
73
|
+
export interface ResolveCredentialOptions {
|
|
74
|
+
/** Secret store for keychain lookups. */
|
|
75
|
+
store: SecretStore;
|
|
76
|
+
/**
|
|
77
|
+
* Optional trusted config object. When provided, config sources can read
|
|
78
|
+
* provider-level apiKey values. Only use with trusted config from the
|
|
79
|
+
* user's OffRouter home directory.
|
|
80
|
+
*/
|
|
81
|
+
config?: Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a credential by trying each source in order.
|
|
86
|
+
* Returns the first non-empty value, or null if all sources are empty.
|
|
87
|
+
*/
|
|
88
|
+
export async function resolveCredential(
|
|
89
|
+
sources: CredentialSource[],
|
|
90
|
+
opts: ResolveCredentialOptions,
|
|
91
|
+
): Promise<CredentialResult | null> {
|
|
92
|
+
for (const source of sources) {
|
|
93
|
+
const value = await trySource(source, opts);
|
|
94
|
+
if (value !== null) {
|
|
95
|
+
return { value: new Redacted(value), source: source.type };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Try a single credential source.
|
|
103
|
+
* Returns the raw secret string or null if not found.
|
|
104
|
+
*/
|
|
105
|
+
async function trySource(
|
|
106
|
+
source: CredentialSource,
|
|
107
|
+
opts: ResolveCredentialOptions,
|
|
108
|
+
): Promise<string | null> {
|
|
109
|
+
switch (source.type) {
|
|
110
|
+
case "env": {
|
|
111
|
+
return tryEnv(source.name);
|
|
112
|
+
}
|
|
113
|
+
case "config": {
|
|
114
|
+
return tryConfig(source.key, opts.config);
|
|
115
|
+
}
|
|
116
|
+
case "keychain": {
|
|
117
|
+
return tryKeychain(source.providerId, opts.store);
|
|
118
|
+
}
|
|
119
|
+
case "oauth": {
|
|
120
|
+
// OAuth is never auto-triggered during credential resolution.
|
|
121
|
+
// It requires explicit configuration and a separate flow.
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read a value from environment variables.
|
|
129
|
+
*/
|
|
130
|
+
function tryEnv(name: string): string | null {
|
|
131
|
+
const value = process.env[name];
|
|
132
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
133
|
+
return value.trim();
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read a value from trusted config using dot-notation path.
|
|
140
|
+
* Only reads from config if provided (trusted source).
|
|
141
|
+
* Path format: "providers.{providerId}.apiKey"
|
|
142
|
+
*/
|
|
143
|
+
function tryConfig(key: string, config?: Record<string, unknown>): string | null {
|
|
144
|
+
if (!config) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const parts = key.split(".");
|
|
148
|
+
let current: unknown = config;
|
|
149
|
+
for (const part of parts) {
|
|
150
|
+
if (typeof current !== "object" || current === null) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const obj = current as Record<string, unknown>;
|
|
154
|
+
if (!(part in obj)) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
current = obj[part];
|
|
158
|
+
}
|
|
159
|
+
if (typeof current === "string" && current.trim().length > 0) {
|
|
160
|
+
return current.trim();
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Read a value from the secret store (keychain-backed).
|
|
167
|
+
*/
|
|
168
|
+
async function tryKeychain(
|
|
169
|
+
providerId: string,
|
|
170
|
+
store: SecretStore,
|
|
171
|
+
): Promise<string | null> {
|
|
172
|
+
try {
|
|
173
|
+
const value = await store.get(providerId);
|
|
174
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
175
|
+
return value.trim();
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// KeychainUnavailableError or other errors: treat as missing.
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth module barrel export for OffRouter.
|
|
3
|
+
*/
|
|
4
|
+
export {
|
|
5
|
+
Redacted,
|
|
6
|
+
resolveCredential,
|
|
7
|
+
} from "./credential-chain.js";
|
|
8
|
+
export type {
|
|
9
|
+
CredentialResult,
|
|
10
|
+
CredentialSource,
|
|
11
|
+
ResolveCredentialOptions,
|
|
12
|
+
} from "./credential-chain.js";
|
|
13
|
+
|
|
14
|
+
export { getProviderToken, setProviderToken } from "./keychain.js";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
exchangeCodeForTokens,
|
|
18
|
+
generateCodeChallenge,
|
|
19
|
+
generateCodeVerifier,
|
|
20
|
+
isOAuthProvider,
|
|
21
|
+
PKCE_CONFIGS,
|
|
22
|
+
refreshTokens,
|
|
23
|
+
startOAuthFlow,
|
|
24
|
+
} from "./oauth-pkce.js";
|
|
25
|
+
export type {
|
|
26
|
+
OAuthFlowResult,
|
|
27
|
+
OAuthFlowState,
|
|
28
|
+
OAuthProviderConfig,
|
|
29
|
+
OAuthStartOptions,
|
|
30
|
+
OAuthTokenOptions,
|
|
31
|
+
OAuthTokenResponse,
|
|
32
|
+
} from "./oauth-pkce.js";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for keychain-backed token helpers.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { getProviderToken, setProviderToken } from "./keychain.js";
|
|
6
|
+
import { FileSecretStore } from "../secrets.js";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
describe("getProviderToken", () => {
|
|
12
|
+
it("returns undefined when no token is stored", async () => {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-keychain-test-"));
|
|
14
|
+
try {
|
|
15
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
16
|
+
const result = await getProviderToken("anthropic", store);
|
|
17
|
+
expect(result).toBeUndefined();
|
|
18
|
+
} finally {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns the token when stored", async () => {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-keychain-test-"));
|
|
25
|
+
try {
|
|
26
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
27
|
+
await store.set("anthropic", "sk-ant-stored-value");
|
|
28
|
+
const result = await getProviderToken("anthropic", store);
|
|
29
|
+
expect(result).not.toBeUndefined();
|
|
30
|
+
expect(result!.rawValue).toBe("sk-ant-stored-value");
|
|
31
|
+
expect(JSON.stringify(result)).toBe('"[REDACTED]"');
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns undefined for nonexistent provider", async () => {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-keychain-test-"));
|
|
39
|
+
try {
|
|
40
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
41
|
+
const result = await getProviderToken("nonexistent", store);
|
|
42
|
+
expect(result).toBeUndefined();
|
|
43
|
+
} finally {
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("setProviderToken", () => {
|
|
50
|
+
it("stores a token and retrieves it", async () => {
|
|
51
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-keychain-test-"));
|
|
52
|
+
try {
|
|
53
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
54
|
+
await setProviderToken("openai", "sk-openai-test", store);
|
|
55
|
+
const token = await getProviderToken("openai", store);
|
|
56
|
+
expect(token).not.toBeUndefined();
|
|
57
|
+
expect(token!.rawValue).toBe("sk-openai-test");
|
|
58
|
+
} finally {
|
|
59
|
+
rmSync(dir, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("overwrites existing token", async () => {
|
|
64
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-keychain-test-"));
|
|
65
|
+
try {
|
|
66
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
67
|
+
await setProviderToken("test", "first-value", store);
|
|
68
|
+
await setProviderToken("test", "second-value", store);
|
|
69
|
+
const token = await getProviderToken("test", store);
|
|
70
|
+
expect(token!.rawValue).toBe("second-value");
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keychain-backed token helpers for OffRouter.
|
|
3
|
+
*
|
|
4
|
+
* Provides getProviderToken/setProviderToken that use the SecretStore
|
|
5
|
+
* abstraction (file or OS keychain). Never scrapes other CLI credential stores.
|
|
6
|
+
*/
|
|
7
|
+
import type { SecretStore } from "../secrets.js";
|
|
8
|
+
import { Redacted } from "./credential-chain.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Retrieve a provider token from the secret store.
|
|
12
|
+
* Returns undefined if no token is stored or the store is unavailable.
|
|
13
|
+
*/
|
|
14
|
+
export async function getProviderToken(
|
|
15
|
+
providerId: string,
|
|
16
|
+
store: SecretStore,
|
|
17
|
+
): Promise<Redacted<string> | undefined> {
|
|
18
|
+
try {
|
|
19
|
+
const value = await store.get(providerId);
|
|
20
|
+
if (typeof value === "string" && value.length > 0) {
|
|
21
|
+
return new Redacted(value);
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// KeychainUnavailableError or other errors: treat as missing.
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Store a provider token in the secret store.
|
|
31
|
+
* Throws if the store is unavailable (e.g., KeychainUnavailableError).
|
|
32
|
+
*/
|
|
33
|
+
export async function setProviderToken(
|
|
34
|
+
providerId: string,
|
|
35
|
+
token: string,
|
|
36
|
+
store: SecretStore,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
await store.set(providerId, token);
|
|
39
|
+
}
|