@vymalo/opencode-repo-auth 0.14.1
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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.js +156 -0
- package/dist/config.js.map +1 -0
- package/dist/git.d.ts +55 -0
- package/dist/git.js +231 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +4 -0
- package/dist/lib.js +6 -0
- package/dist/lib.js.map +1 -0
- package/dist/opencode.d.ts +13 -0
- package/dist/opencode.js +331 -0
- package/dist/opencode.js.map +1 -0
- package/dist/plugin.d.ts +100 -0
- package/dist/plugin.js +161 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +67 -0
package/dist/opencode.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { createJsonConsoleLogger, DEFAULT_LOG_LEVEL, LOG_LEVEL_PRIORITY } from "@vymalo/opencode-auth-core/lib";
|
|
2
|
+
import { hasOAuth2Conflict, parseRepoAuthOptions } from "./config.js";
|
|
3
|
+
import { resolveOriginRemote, resolveRepoRoot } from "./git.js";
|
|
4
|
+
import { RepoAuthPlugin } from "./plugin.js";
|
|
5
|
+
const PLUGIN_SERVICE_NAME = "opencode-repo-auth-plugin";
|
|
6
|
+
/**
|
|
7
|
+
* Map OpenCode's host-level `config.logLevel` (uppercase `"DEBUG" | "INFO" |
|
|
8
|
+
* "WARN" | "ERROR"`) to the plugin's internal `LogLevel`. Unknown / missing
|
|
9
|
+
* values fall through to `undefined` so the caller applies its own default —
|
|
10
|
+
* we never throw on an OpenCode-supplied value because the host owns
|
|
11
|
+
* validation of its own field. Host `DEBUG` unlocks the `trace` tier (there is
|
|
12
|
+
* no separate host `TRACE` level), surfacing the `repo_auth_*` trace events.
|
|
13
|
+
*/
|
|
14
|
+
function fromOpenCodeLogLevel(value) {
|
|
15
|
+
if (typeof value !== "string") {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
switch (value.toUpperCase()) {
|
|
19
|
+
case "DEBUG": return "trace";
|
|
20
|
+
case "INFO": return "info";
|
|
21
|
+
case "WARN": return "warn";
|
|
22
|
+
case "ERROR": return "error";
|
|
23
|
+
default: return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function asRecord(value) {
|
|
27
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function createOpenCodeLogger(client, getMinLevel) {
|
|
33
|
+
const fallback = createJsonConsoleLogger("debug");
|
|
34
|
+
const consoleAll = /^(1|true|yes|on)$/i.test(process.env.VYMALO_PLUGIN_CONSOLE_LOG ?? "");
|
|
35
|
+
const write = (level, event, fields) => {
|
|
36
|
+
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[getMinLevel()]) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (consoleAll || level === "warn" || level === "error") {
|
|
40
|
+
fallback[level](event, fields);
|
|
41
|
+
}
|
|
42
|
+
const hostLevel = level === "trace" ? "debug" : level;
|
|
43
|
+
void client.app.log({ body: {
|
|
44
|
+
service: PLUGIN_SERVICE_NAME,
|
|
45
|
+
level: hostLevel,
|
|
46
|
+
message: event,
|
|
47
|
+
extra: fields
|
|
48
|
+
} }).catch(() => {
|
|
49
|
+
// Best-effort forwarding; console logger is the reliable fallback.
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
trace(event, fields) {
|
|
54
|
+
write("trace", event, fields);
|
|
55
|
+
},
|
|
56
|
+
debug(event, fields) {
|
|
57
|
+
write("debug", event, fields);
|
|
58
|
+
},
|
|
59
|
+
info(event, fields) {
|
|
60
|
+
write("info", event, fields);
|
|
61
|
+
},
|
|
62
|
+
warn(event, fields) {
|
|
63
|
+
write("warn", event, fields);
|
|
64
|
+
},
|
|
65
|
+
error(event, fields) {
|
|
66
|
+
write("error", event, fields);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function authSignature(auth) {
|
|
71
|
+
return JSON.stringify({
|
|
72
|
+
issuer: auth.issuer,
|
|
73
|
+
clientId: auth.clientId,
|
|
74
|
+
clientSecret: auth.clientSecret,
|
|
75
|
+
scopes: auth.scopes,
|
|
76
|
+
authorizationEndpoint: auth.authorizationEndpoint,
|
|
77
|
+
tokenEndpoint: auth.tokenEndpoint,
|
|
78
|
+
deviceAuthorizationEndpoint: auth.deviceAuthorizationEndpoint,
|
|
79
|
+
jwksUri: auth.jwksUri,
|
|
80
|
+
redirectPort: auth.redirectPort,
|
|
81
|
+
authFlow: auth.authFlow,
|
|
82
|
+
pkce: auth.pkce,
|
|
83
|
+
subjectTokenSource: auth.subjectTokenSource
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Config-time stamp of the project bearer onto `provider.options.headers
|
|
88
|
+
* .Authorization`, mirroring oauth2's `propagateCachedBearer`. This is the
|
|
89
|
+
* config-object handshake that lets `@vymalo/opencode-models-info` (which runs
|
|
90
|
+
* after repo-auth in `plugin`) fetch an OAuth2-protected `meta.modelsInfoUrl`.
|
|
91
|
+
* A user-set `Authorization` always wins here; and a stale stamped value is
|
|
92
|
+
* harmless because `chat.headers` overwrites it per request with a fresh
|
|
93
|
+
* token. Never opens a browser / device-code prompt: on any failure (no cached
|
|
94
|
+
* human token, exchange error) it logs and skips the stamp.
|
|
95
|
+
*/
|
|
96
|
+
async function propagateCachedBearer(providerConfig, providerId, plugin, logger) {
|
|
97
|
+
const options = providerConfig.options ??= {};
|
|
98
|
+
const headers = options.headers ??= {};
|
|
99
|
+
const hasUserAuth = Object.keys(headers).some((key) => key.toLowerCase() === "authorization");
|
|
100
|
+
if (hasUserAuth) {
|
|
101
|
+
logger.debug("repo_auth_bearer_propagation_skipped_user_set", { providerId });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let token;
|
|
105
|
+
try {
|
|
106
|
+
// Config-time warmup is strictly non-interactive: a first-ever login must
|
|
107
|
+
// not block boot on a browser/device-code prompt. The project token is
|
|
108
|
+
// renewed later by the first `chat.headers` request (interactive) or the
|
|
109
|
+
// user's explicit `auth login`. A missing/expired human token throws here
|
|
110
|
+
// and is caught below — `repo_auth_bearer_propagation_skipped_no_token`.
|
|
111
|
+
token = await plugin.resolveProjectToken({ interactive: false });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logger.debug("repo_auth_bearer_propagation_skipped_no_token", {
|
|
114
|
+
providerId,
|
|
115
|
+
error: error instanceof Error ? error.message : String(error)
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!token.accessToken) {
|
|
120
|
+
logger.debug("repo_auth_bearer_propagation_skipped_empty_token", { providerId });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
|
|
124
|
+
logger.debug("repo_auth_bearer_propagated_to_provider_headers", { providerId });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the repo's git identity once per config hook — worktree-aware, read
|
|
128
|
+
* off disk, normalized (userinfo stripped). Log-only in v1: nothing is derived
|
|
129
|
+
* from the remote; the module exists to make the plugin worktree-correct and
|
|
130
|
+
* to leave a repo-auditable trace in the log stream.
|
|
131
|
+
*/
|
|
132
|
+
async function logGitIdentity(logger, cwd) {
|
|
133
|
+
const repoRoot = await resolveRepoRoot(cwd);
|
|
134
|
+
if (!repoRoot) {
|
|
135
|
+
logger.trace("repo_auth_remote_missing", {});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const remote = await resolveOriginRemote(repoRoot);
|
|
139
|
+
if (!remote) {
|
|
140
|
+
logger.trace("repo_auth_remote_missing", { repoRoot });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
logger.debug("repo_auth_remote_resolved", {
|
|
144
|
+
repoRoot,
|
|
145
|
+
remote
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Walk the provider map, collect every `options.meta.repoAuth` opt-in, and
|
|
150
|
+
* enforce the plugin's guards. Returns the managed providers map (providerId →
|
|
151
|
+
* parsed config), logging `repo_auth_*` events as it goes.
|
|
152
|
+
*/
|
|
153
|
+
function collectManagedProviders(config, logger) {
|
|
154
|
+
const managed = new Map();
|
|
155
|
+
const providers = config.provider ?? {};
|
|
156
|
+
const optedIn = [];
|
|
157
|
+
for (const [providerId, providerConfig] of Object.entries(providers)) {
|
|
158
|
+
const options = asRecord(providerConfig.options);
|
|
159
|
+
// A malformed opt-in on one provider must not take down the whole plugin:
|
|
160
|
+
// warn and skip, mirroring the no-op matrix. (oauth2 throws for its own
|
|
161
|
+
// config keys; here a stray/typo'd `meta.repoAuth` on any provider would
|
|
162
|
+
// otherwise reject the entire config hook for every provider.)
|
|
163
|
+
let parsed;
|
|
164
|
+
try {
|
|
165
|
+
parsed = parseRepoAuthOptions(options);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
logger.warn("repo_auth_skipped_malformed", {
|
|
168
|
+
providerId,
|
|
169
|
+
error: error instanceof Error ? error.message : String(error)
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (parsed.kind === "not_opted_in") {
|
|
174
|
+
logger.trace("repo_auth_provider_skipped", { providerId });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (parsed.kind === "missing_project_id") {
|
|
178
|
+
logger.warn("repo_auth_skipped_no_project_id", { providerId });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (hasOAuth2Conflict(config, options, providerId)) {
|
|
182
|
+
logger.warn("repo_auth_skipped_oauth2_provider", { providerId });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const entry = {
|
|
186
|
+
projectId: parsed.config.projectId,
|
|
187
|
+
auth: parsed.config.auth,
|
|
188
|
+
authSignature: authSignature(parsed.config.auth)
|
|
189
|
+
};
|
|
190
|
+
optedIn.push({
|
|
191
|
+
providerId,
|
|
192
|
+
config: entry
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// v1 is single-IdP: one identity key (the human) per cache namespace. The
|
|
196
|
+
// human root file is shared across every managed provider, so two opted-in
|
|
197
|
+
// providers MUST resolve to the same IdP or the second would clobber the
|
|
198
|
+
// first's human token. Guard: keep the first IdP group, warn + skip the rest.
|
|
199
|
+
const first = optedIn[0];
|
|
200
|
+
if (first) {
|
|
201
|
+
const selected = new Set([first.config.authSignature]);
|
|
202
|
+
for (const { providerId, config } of optedIn) {
|
|
203
|
+
if (!selected.has(config.authSignature)) {
|
|
204
|
+
logger.warn("repo_auth_multiple_idps_unsupported", { providerId });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
logger.trace("repo_auth_provider_opted_in", {
|
|
208
|
+
providerId,
|
|
209
|
+
projectId: config.projectId
|
|
210
|
+
});
|
|
211
|
+
managed.set(providerId, config);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return managed;
|
|
215
|
+
}
|
|
216
|
+
function runtimeSignature(managed) {
|
|
217
|
+
const sorted = [...managed.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
218
|
+
return JSON.stringify(sorted.map(([providerId, entry]) => ({
|
|
219
|
+
providerId,
|
|
220
|
+
projectId: entry.projectId,
|
|
221
|
+
authSignature: entry.authSignature
|
|
222
|
+
})));
|
|
223
|
+
}
|
|
224
|
+
export function createOpencodeRepoAuthPlugin(factoryOptions = {}) {
|
|
225
|
+
return async ({ client }) => {
|
|
226
|
+
let currentLogLevel = DEFAULT_LOG_LEVEL;
|
|
227
|
+
const logger = factoryOptions.logger ?? createOpenCodeLogger(client, () => currentLogLevel);
|
|
228
|
+
const state = {
|
|
229
|
+
pluginByProvider: new Map(),
|
|
230
|
+
signature: undefined
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
config: async (config) => {
|
|
234
|
+
currentLogLevel = fromOpenCodeLogLevel(config.logLevel) ?? DEFAULT_LOG_LEVEL;
|
|
235
|
+
logger.trace("repo_auth_config_hook_start", {
|
|
236
|
+
logLevel: currentLogLevel,
|
|
237
|
+
hostLogLevel: typeof config.logLevel === "string" ? config.logLevel : undefined
|
|
238
|
+
});
|
|
239
|
+
await logGitIdentity(logger, factoryOptions.cwd ?? process.cwd());
|
|
240
|
+
const managed = collectManagedProviders(config, logger);
|
|
241
|
+
logger.trace("repo_auth_config_hook_collected_providers", {
|
|
242
|
+
managedCount: managed.size,
|
|
243
|
+
providerIds: [...managed.keys()]
|
|
244
|
+
});
|
|
245
|
+
if (managed.size === 0) {
|
|
246
|
+
logger.trace("repo_auth_config_hook_no_managed_providers", {});
|
|
247
|
+
state.pluginByProvider.clear();
|
|
248
|
+
state.signature = undefined;
|
|
249
|
+
logger.trace("repo_auth_config_hook_finished", { managedCount: 0 });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const signature = runtimeSignature(managed);
|
|
253
|
+
if (state.signature !== signature || state.pluginByProvider.size === 0) {
|
|
254
|
+
logger.trace("repo_auth_runtime_rebuild", {
|
|
255
|
+
reason: state.pluginByProvider.size > 0 ? "signature_changed" : "first_build",
|
|
256
|
+
providerCount: managed.size
|
|
257
|
+
});
|
|
258
|
+
const rebuilt = new Map();
|
|
259
|
+
for (const [providerId, entry] of managed.entries()) {
|
|
260
|
+
try {
|
|
261
|
+
const plugin = new RepoAuthPlugin({
|
|
262
|
+
projectId: entry.projectId,
|
|
263
|
+
auth: entry.auth
|
|
264
|
+
}, {
|
|
265
|
+
logger,
|
|
266
|
+
fetchImpl: factoryOptions.fetchImpl,
|
|
267
|
+
onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
|
|
268
|
+
cacheDir: factoryOptions.cacheDir
|
|
269
|
+
});
|
|
270
|
+
rebuilt.set(providerId, plugin);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// validateAuthConfig inside the constructor can still reject
|
|
273
|
+
// (e.g. client_credentials without clientSecret). Fail that one
|
|
274
|
+
// provider, keep the rest — never abort the whole config hook.
|
|
275
|
+
logger.warn("repo_auth_skipped_invalid_auth", {
|
|
276
|
+
providerId,
|
|
277
|
+
error: error instanceof Error ? error.message : String(error)
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
state.pluginByProvider = rebuilt;
|
|
282
|
+
state.signature = signature;
|
|
283
|
+
} else {
|
|
284
|
+
logger.trace("repo_auth_runtime_reused", { providerCount: managed.size });
|
|
285
|
+
}
|
|
286
|
+
const providers = config.provider ??= {};
|
|
287
|
+
await Promise.all([...state.pluginByProvider.entries()].map(([providerId, plugin]) => {
|
|
288
|
+
const providerConfig = providers[providerId];
|
|
289
|
+
if (!providerConfig) {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
return propagateCachedBearer(providerConfig, providerId, plugin, logger);
|
|
293
|
+
}));
|
|
294
|
+
logger.trace("repo_auth_config_hook_finished", { managedCount: state.pluginByProvider.size });
|
|
295
|
+
},
|
|
296
|
+
"chat.headers": async (input, output) => {
|
|
297
|
+
const providerId = input.model?.providerID ?? input.provider?.info?.id;
|
|
298
|
+
const plugin = providerId ? state.pluginByProvider.get(providerId) : undefined;
|
|
299
|
+
if (!providerId || !plugin) {
|
|
300
|
+
logger.trace("repo_auth_chat_headers_skipped", {
|
|
301
|
+
providerId,
|
|
302
|
+
managed: providerId ? state.pluginByProvider.has(providerId) : false
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Fail closed: on any exchange failure we inject NO header — the
|
|
307
|
+
// request goes out without a bearer and the gateway 401s, matching the
|
|
308
|
+
// SPI's fail-closed semantics. The failure itself is logged at error
|
|
309
|
+
// level by `resolveProjectToken` (`repo_auth_exchange_failed`); here we
|
|
310
|
+
// must not let it escape the hook or the whole chat request would error
|
|
311
|
+
// instead of degrading to the gateway's 401.
|
|
312
|
+
try {
|
|
313
|
+
const token = await plugin.resolveProjectToken({ interactive: true });
|
|
314
|
+
output.headers.Authorization = `${token.tokenType || "Bearer"} ${token.accessToken}`;
|
|
315
|
+
logger.trace("repo_auth_chat_headers_bearer_injected", {
|
|
316
|
+
providerId,
|
|
317
|
+
present: Boolean(token.accessToken),
|
|
318
|
+
tokenType: token.tokenType || "Bearer"
|
|
319
|
+
});
|
|
320
|
+
} catch {
|
|
321
|
+
logger.trace("repo_auth_chat_headers_no_bearer", { providerId });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
export const OpencodeRepoAuthPlugin = createOpencodeRepoAuthPlugin();
|
|
329
|
+
export default OpencodeRepoAuthPlugin;
|
|
330
|
+
|
|
331
|
+
//# sourceMappingURL=opencode.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"mappings":"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","names":[],"sources":["../src/opencode.ts"],"version":3,"file":"opencode.js","sourceRoot":""}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type Logger, type TokenSet } from "@vymalo/opencode-auth-core/lib";
|
|
2
|
+
import type { RepoAuthConfig } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* The plugin is identity-keyed by the *human* — the single person whose one
|
|
5
|
+
* interactive login (device-code / authorization-code) grounds every project
|
|
6
|
+
* exchange. v1 is single-IdP, so the identity is a constant; if multi-IdP ever
|
|
7
|
+
* lands, derive `issuer|clientId` instead.
|
|
8
|
+
*/
|
|
9
|
+
export declare const HUMAN_IDENTITY = "human";
|
|
10
|
+
export declare const DEFAULT_CACHE_NAMESPACE = "opencode-repo-auth";
|
|
11
|
+
export interface RepoAuthPluginOptions {
|
|
12
|
+
logger?: Logger;
|
|
13
|
+
fetchImpl?: typeof fetch;
|
|
14
|
+
onAuthorizationUrl?: (url: string) => Promise<void> | void;
|
|
15
|
+
/** Override the cache root (defaults to the OS cache dir convention). */
|
|
16
|
+
cacheDir?: string;
|
|
17
|
+
tokenExpirySkewMs?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether a cached project token is usable. Deliberately *not* routed through
|
|
21
|
+
* auth-core's `OAuthClient.isTokenValid`: that check treats a missing
|
|
22
|
+
* `expiresAt` as non-expiring for interactive flows, but the project token has
|
|
23
|
+
* **no refresh token** — an undefined or passed lifetime must trigger a
|
|
24
|
+
* re-exchange (the machine-flow policy), otherwise a 401 after the server
|
|
25
|
+
* revoked it becomes a permanent failure rather than a one round trip.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isProjectTokenUsable(token: TokenSet | undefined, skewMs: number): token is TokenSet;
|
|
28
|
+
/**
|
|
29
|
+
* Repo-auth runtime over `@vymalo/opencode-auth-core`'s `TokenRuntime`.
|
|
30
|
+
* Owns two token kinds, both persisted by auth-core's `FileCacheStore` under
|
|
31
|
+
* the plugin's cache namespace (OS cache dir, `0o600`, atomic rename):
|
|
32
|
+
*
|
|
33
|
+
* - the **human root** (`<cacheDir>/human.json`) — what `ensure` /
|
|
34
|
+
* `refresh` produces; carries the `offline_access` refresh token that makes
|
|
35
|
+
* re-exchange automatic ("model b"); never sent to the gateway.
|
|
36
|
+
* - the **project token** (`<cacheDir>/human-<hash(projectId)>.json`) — the
|
|
37
|
+
* SPI-sealed RFC 8693 exchange result consumed by the gateway; short-lived,
|
|
38
|
+
* no refresh token, so renewal is always a fresh exchange from the human
|
|
39
|
+
* root, never a "refreshed" project token (which would lose the project
|
|
40
|
+
* claims).
|
|
41
|
+
*
|
|
42
|
+
* The plugin keeps only in-memory state; no `cache.ts` of its own.
|
|
43
|
+
*/
|
|
44
|
+
export declare class RepoAuthPlugin {
|
|
45
|
+
readonly config: RepoAuthConfig;
|
|
46
|
+
private readonly runtime;
|
|
47
|
+
private readonly logger;
|
|
48
|
+
private readonly tokenExpirySkewMs;
|
|
49
|
+
private inFlightExchange?;
|
|
50
|
+
constructor(config: RepoAuthConfig, options?: RepoAuthPluginOptions);
|
|
51
|
+
get projectId(): string;
|
|
52
|
+
/**
|
|
53
|
+
* Ensure the human root token. Config-time callers pass `{interactive:false}`
|
|
54
|
+
* so a first-ever login never blocks boot on a browser/device-code prompt;
|
|
55
|
+
* per-request callers allow it (the first chat is the natural moment to log
|
|
56
|
+
* in). A stale-but-refreshable token is refreshed silently via its
|
|
57
|
+
* `offline_access` refresh token.
|
|
58
|
+
*/
|
|
59
|
+
ensureHumanToken(options?: {
|
|
60
|
+
interactive?: boolean;
|
|
61
|
+
}): Promise<TokenSet>;
|
|
62
|
+
/** Non-network read of the cached project token, if any. */
|
|
63
|
+
getCachedProjectToken(): Promise<TokenSet | undefined>;
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a *usable* project token — "model b":
|
|
66
|
+
*
|
|
67
|
+
* cached usable? ──yes──▶ return it (repo_auth_exchange_cache_hit)
|
|
68
|
+
* │ no
|
|
69
|
+
* ▼
|
|
70
|
+
* ensure human root (refresh-only; interactive never used here)
|
|
71
|
+
* ▼
|
|
72
|
+
* exchangeTo(projectId, humanToken, { project_id }) ← ONE POST, no audience
|
|
73
|
+
* ▼
|
|
74
|
+
* cache under human-<hash(projectId)> + return
|
|
75
|
+
*
|
|
76
|
+
* The project token is **never refreshed**; re-exchange is the canonical
|
|
77
|
+
* renewal. Fails closed: an exchange failure (non-member, resolver error,
|
|
78
|
+
* network) throws `repo_auth_exchange_failed` — the caller injects no header
|
|
79
|
+
* and the gateway 401s, which is correct (matches the SPI's fail-closed
|
|
80
|
+
* semantics). The plugin never invents a token.
|
|
81
|
+
*
|
|
82
|
+
* `interactive` controls only the human-root derivation for a *re-exchange*:
|
|
83
|
+
* config-time callers keep it `false` (a first-ever login must not block
|
|
84
|
+
* boot), while `chat.headers` callers default to `true` so the first chat
|
|
85
|
+
* request can open the device-code / browser flow.
|
|
86
|
+
*
|
|
87
|
+
* Concurrent callers (parallel chat headers, config warmup racing a request)
|
|
88
|
+
* share the in-flight exchange: the first cache-missing caller kicks it off
|
|
89
|
+
* and the rest await the same promise, so there is at most one exchange POST
|
|
90
|
+
* and at most one interactive prompt per cache-miss window.
|
|
91
|
+
*/
|
|
92
|
+
resolveProjectToken(options?: {
|
|
93
|
+
interactive?: boolean;
|
|
94
|
+
}): Promise<TokenSet>;
|
|
95
|
+
private performExchange;
|
|
96
|
+
/** Drop the on-disk human + project tokens for this identity. */
|
|
97
|
+
reset(): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
/** Absolute path to the repo-auth cache directory (for diagnostics / tests). */
|
|
100
|
+
export declare function repoAuthCacheDir(cacheRoot: string): string;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { createJsonConsoleLogger, DEFAULT_TOKEN_EXPIRY_SKEW_MS, resolveCacheRoot, TokenRuntime, validateAuthConfig } from "@vymalo/opencode-auth-core/lib";
|
|
3
|
+
/**
|
|
4
|
+
* The plugin is identity-keyed by the *human* — the single person whose one
|
|
5
|
+
* interactive login (device-code / authorization-code) grounds every project
|
|
6
|
+
* exchange. v1 is single-IdP, so the identity is a constant; if multi-IdP ever
|
|
7
|
+
* lands, derive `issuer|clientId` instead.
|
|
8
|
+
*/
|
|
9
|
+
export const HUMAN_IDENTITY = "human";
|
|
10
|
+
export const DEFAULT_CACHE_NAMESPACE = "opencode-repo-auth";
|
|
11
|
+
/**
|
|
12
|
+
* Whether a cached project token is usable. Deliberately *not* routed through
|
|
13
|
+
* auth-core's `OAuthClient.isTokenValid`: that check treats a missing
|
|
14
|
+
* `expiresAt` as non-expiring for interactive flows, but the project token has
|
|
15
|
+
* **no refresh token** — an undefined or passed lifetime must trigger a
|
|
16
|
+
* re-exchange (the machine-flow policy), otherwise a 401 after the server
|
|
17
|
+
* revoked it becomes a permanent failure rather than a one round trip.
|
|
18
|
+
*/
|
|
19
|
+
export function isProjectTokenUsable(token, skewMs) {
|
|
20
|
+
if (!token?.accessToken) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (token.expiresAt === undefined) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return Date.now() + skewMs < token.expiresAt;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Repo-auth runtime over `@vymalo/opencode-auth-core`'s `TokenRuntime`.
|
|
30
|
+
* Owns two token kinds, both persisted by auth-core's `FileCacheStore` under
|
|
31
|
+
* the plugin's cache namespace (OS cache dir, `0o600`, atomic rename):
|
|
32
|
+
*
|
|
33
|
+
* - the **human root** (`<cacheDir>/human.json`) — what `ensure` /
|
|
34
|
+
* `refresh` produces; carries the `offline_access` refresh token that makes
|
|
35
|
+
* re-exchange automatic ("model b"); never sent to the gateway.
|
|
36
|
+
* - the **project token** (`<cacheDir>/human-<hash(projectId)>.json`) — the
|
|
37
|
+
* SPI-sealed RFC 8693 exchange result consumed by the gateway; short-lived,
|
|
38
|
+
* no refresh token, so renewal is always a fresh exchange from the human
|
|
39
|
+
* root, never a "refreshed" project token (which would lose the project
|
|
40
|
+
* claims).
|
|
41
|
+
*
|
|
42
|
+
* The plugin keeps only in-memory state; no `cache.ts` of its own.
|
|
43
|
+
*/
|
|
44
|
+
export class RepoAuthPlugin {
|
|
45
|
+
config;
|
|
46
|
+
runtime;
|
|
47
|
+
logger;
|
|
48
|
+
tokenExpirySkewMs;
|
|
49
|
+
inFlightExchange;
|
|
50
|
+
constructor(config, options = {}) {
|
|
51
|
+
this.config = config;
|
|
52
|
+
this.logger = options.logger ?? createJsonConsoleLogger("info");
|
|
53
|
+
this.tokenExpirySkewMs = typeof options.tokenExpirySkewMs === "number" && Number.isFinite(options.tokenExpirySkewMs) && options.tokenExpirySkewMs > 0 ? options.tokenExpirySkewMs : DEFAULT_TOKEN_EXPIRY_SKEW_MS;
|
|
54
|
+
this.runtime = new TokenRuntime(
|
|
55
|
+
HUMAN_IDENTITY,
|
|
56
|
+
// Validate once at construction so a malformed auth block fails early
|
|
57
|
+
// with auth-core's field-level errors (defaults applied: authFlow →
|
|
58
|
+
// authorization_code, pkce → true).
|
|
59
|
+
validateAuthConfig(config.auth),
|
|
60
|
+
{
|
|
61
|
+
logger: this.logger,
|
|
62
|
+
fetchImpl: options.fetchImpl,
|
|
63
|
+
onAuthorizationUrl: options.onAuthorizationUrl,
|
|
64
|
+
cacheDir: options.cacheDir ?? join(resolveCacheRoot(), DEFAULT_CACHE_NAMESPACE),
|
|
65
|
+
tokenExpirySkewMs: this.tokenExpirySkewMs
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
get projectId() {
|
|
70
|
+
return this.config.projectId;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Ensure the human root token. Config-time callers pass `{interactive:false}`
|
|
74
|
+
* so a first-ever login never blocks boot on a browser/device-code prompt;
|
|
75
|
+
* per-request callers allow it (the first chat is the natural moment to log
|
|
76
|
+
* in). A stale-but-refreshable token is refreshed silently via its
|
|
77
|
+
* `offline_access` refresh token.
|
|
78
|
+
*/
|
|
79
|
+
async ensureHumanToken(options = {}) {
|
|
80
|
+
const token = await this.runtime.ensure({ interactive: options.interactive });
|
|
81
|
+
this.logger.debug("repo_auth_human_token_ensured", { present: Boolean(token.accessToken) });
|
|
82
|
+
return token;
|
|
83
|
+
}
|
|
84
|
+
/** Non-network read of the cached project token, if any. */
|
|
85
|
+
async getCachedProjectToken() {
|
|
86
|
+
return this.runtime.getExchangedByKey(this.projectId);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a *usable* project token — "model b":
|
|
90
|
+
*
|
|
91
|
+
* cached usable? ──yes──▶ return it (repo_auth_exchange_cache_hit)
|
|
92
|
+
* │ no
|
|
93
|
+
* ▼
|
|
94
|
+
* ensure human root (refresh-only; interactive never used here)
|
|
95
|
+
* ▼
|
|
96
|
+
* exchangeTo(projectId, humanToken, { project_id }) ← ONE POST, no audience
|
|
97
|
+
* ▼
|
|
98
|
+
* cache under human-<hash(projectId)> + return
|
|
99
|
+
*
|
|
100
|
+
* The project token is **never refreshed**; re-exchange is the canonical
|
|
101
|
+
* renewal. Fails closed: an exchange failure (non-member, resolver error,
|
|
102
|
+
* network) throws `repo_auth_exchange_failed` — the caller injects no header
|
|
103
|
+
* and the gateway 401s, which is correct (matches the SPI's fail-closed
|
|
104
|
+
* semantics). The plugin never invents a token.
|
|
105
|
+
*
|
|
106
|
+
* `interactive` controls only the human-root derivation for a *re-exchange*:
|
|
107
|
+
* config-time callers keep it `false` (a first-ever login must not block
|
|
108
|
+
* boot), while `chat.headers` callers default to `true` so the first chat
|
|
109
|
+
* request can open the device-code / browser flow.
|
|
110
|
+
*
|
|
111
|
+
* Concurrent callers (parallel chat headers, config warmup racing a request)
|
|
112
|
+
* share the in-flight exchange: the first cache-missing caller kicks it off
|
|
113
|
+
* and the rest await the same promise, so there is at most one exchange POST
|
|
114
|
+
* and at most one interactive prompt per cache-miss window.
|
|
115
|
+
*/
|
|
116
|
+
async resolveProjectToken(options = {}) {
|
|
117
|
+
const cached = await this.getCachedProjectToken();
|
|
118
|
+
if (isProjectTokenUsable(cached, this.tokenExpirySkewMs)) {
|
|
119
|
+
this.logger.trace("repo_auth_exchange_cache_hit", { projectId: this.projectId });
|
|
120
|
+
return cached;
|
|
121
|
+
}
|
|
122
|
+
this.logger.trace("repo_auth_exchange_cache_miss", { projectId: this.projectId });
|
|
123
|
+
if (this.inFlightExchange) {
|
|
124
|
+
return this.inFlightExchange;
|
|
125
|
+
}
|
|
126
|
+
const exchange = this.performExchange(options);
|
|
127
|
+
this.inFlightExchange = exchange;
|
|
128
|
+
try {
|
|
129
|
+
return await exchange;
|
|
130
|
+
} finally {
|
|
131
|
+
if (this.inFlightExchange === exchange) {
|
|
132
|
+
this.inFlightExchange = undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async performExchange(options) {
|
|
137
|
+
const human = await this.ensureHumanToken({ interactive: options.interactive });
|
|
138
|
+
this.logger.info("repo_auth_exchange_started", { projectId: this.projectId });
|
|
139
|
+
try {
|
|
140
|
+
const exchanged = await this.runtime.exchangeTo(this.projectId, human.accessToken, { project_id: this.projectId });
|
|
141
|
+
this.logger.info("repo_auth_exchange_success", { projectId: this.projectId });
|
|
142
|
+
return exchanged;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
this.logger.error("repo_auth_exchange_failed", {
|
|
145
|
+
projectId: this.projectId,
|
|
146
|
+
error: error instanceof Error ? error.message : String(error)
|
|
147
|
+
});
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Drop the on-disk human + project tokens for this identity. */
|
|
152
|
+
async reset() {
|
|
153
|
+
await this.runtime.reset();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Absolute path to the repo-auth cache directory (for diagnostics / tests). */
|
|
157
|
+
export function repoAuthCacheDir(cacheRoot) {
|
|
158
|
+
return join(cacheRoot, DEFAULT_CACHE_NAMESPACE);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
//# sourceMappingURL=plugin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"mappings":"AAAA,SAAS,YAAY;AAErB,SACE,yBACA,8BACA,kBACA,cACA,0BAGK;;;;;;;AAUP,OAAO,MAAM,iBAAiB;AAE9B,OAAO,MAAM,0BAA0B;;;;;;;;;AAmBvC,OAAO,SAAS,qBACd,OACA,QACmB;CACnB,IAAI,CAAC,OAAO,aAAa;EACvB,OAAO;CACT;CACA,IAAI,MAAM,cAAc,WAAW;EACjC,OAAO;CACT;CACA,OAAO,KAAK,IAAI,IAAI,SAAS,MAAM;AACrC;;;;;;;;;;;;;;;;;AAkBA,OAAO,MAAM,eAAe;CAOf;CANX,AAAiB;CACjB,AAAiB;CACjB,AAAiB;CACjB,AAAQ;CAER,YACE,AAAS,QACT,UAAiC,CAAC,GAClC;EAFS;EAGT,KAAK,SAAS,QAAQ,UAAU,wBAAwB,MAAM;EAC9D,KAAK,oBACH,OAAO,QAAQ,sBAAsB,YACrC,OAAO,SAAS,QAAQ,iBAAiB,KACzC,QAAQ,oBAAoB,IACxB,QAAQ,oBACR;EAEN,KAAK,UAAU,IAAI;GACjB;;;;GAIA,mBAAmB,OAAO,IAAI;GAC9B;IACE,QAAQ,KAAK;IACb,WAAW,QAAQ;IACnB,oBAAoB,QAAQ;IAC5B,UAAU,QAAQ,YAAY,KAAK,iBAAiB,GAAG,uBAAuB;IAC9E,mBAAmB,KAAK;GAC1B;EACF;CACF;CAEA,IAAI,YAAoB;EACtB,OAAO,KAAK,OAAO;CACrB;;;;;;;;CASA,MAAM,iBAAiB,UAAqC,CAAC,GAAsB;EACjF,MAAM,QAAQ,MAAM,KAAK,QAAQ,OAAO,EAAE,aAAa,QAAQ,YAAY,CAAC;EAC5E,KAAK,OAAO,MAAM,iCAAiC,EACjD,SAAS,QAAQ,MAAM,WAAW,EACpC,CAAC;EACD,OAAO;CACT;;CAGA,MAAM,wBAAuD;EAC3D,OAAO,KAAK,QAAQ,kBAAkB,KAAK,SAAS;CACtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BA,MAAM,oBAAoB,UAAqC,CAAC,GAAsB;EACpF,MAAM,SAAS,MAAM,KAAK,sBAAsB;EAChD,IAAI,qBAAqB,QAAQ,KAAK,iBAAiB,GAAG;GACxD,KAAK,OAAO,MAAM,gCAAgC,EAAE,WAAW,KAAK,UAAU,CAAC;GAC/E,OAAO;EACT;EACA,KAAK,OAAO,MAAM,iCAAiC,EAAE,WAAW,KAAK,UAAU,CAAC;EAEhF,IAAI,KAAK,kBAAkB;GACzB,OAAO,KAAK;EACd;EAEA,MAAM,WAAW,KAAK,gBAAgB,OAAO;EAC7C,KAAK,mBAAmB;EACxB,IAAI;GACF,OAAO,MAAM;EACf,UAAU;GACR,IAAI,KAAK,qBAAqB,UAAU;IACtC,KAAK,mBAAmB;GAC1B;EACF;CACF;CAEA,MAAc,gBAAgB,SAAuD;EACnF,MAAM,QAAQ,MAAM,KAAK,iBAAiB,EAAE,aAAa,QAAQ,YAAY,CAAC;EAC9E,KAAK,OAAO,KAAK,8BAA8B,EAAE,WAAW,KAAK,UAAU,CAAC;EAC5E,IAAI;GACF,MAAM,YAAY,MAAM,KAAK,QAAQ,WAAW,KAAK,WAAW,MAAM,aAAa,EACjF,YAAY,KAAK,UACnB,CAAC;GACD,KAAK,OAAO,KAAK,8BAA8B,EAAE,WAAW,KAAK,UAAU,CAAC;GAC5E,OAAO;EACT,SAAS,OAAO;GACd,KAAK,OAAO,MAAM,6BAA6B;IAC7C,WAAW,KAAK;IAChB,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GAC9D,CAAC;GACD,MAAM;EACR;CACF;;CAGA,MAAM,QAAuB;EAC3B,MAAM,KAAK,QAAQ,MAAM;CAC3B;AACF;;AAGA,OAAO,SAAS,iBAAiB,WAA2B;CAC1D,OAAO,KAAK,WAAW,uBAAuB;AAChD","names":[],"sources":["../src/plugin.ts"],"version":3,"file":"plugin.js","sourceRoot":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vymalo/opencode-repo-auth",
|
|
3
|
+
"version": "0.14.1",
|
|
4
|
+
"description": "OpenCode plugin that gives local-dev requests repo-as-project attribution: a developer logs in once as themselves and every gateway request from an enrolled repo carries a project-scoped bearer.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "vymalo contributors",
|
|
7
|
+
"homepage": "https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit.git",
|
|
11
|
+
"directory": "packages/opencode-repo-auth"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"opencode-plugin",
|
|
19
|
+
"oauth2",
|
|
20
|
+
"token-exchange",
|
|
21
|
+
"project",
|
|
22
|
+
"gateway",
|
|
23
|
+
"ai-sdk"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "dist/index.js",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./lib": {
|
|
34
|
+
"types": "./dist/lib.d.ts",
|
|
35
|
+
"import": "./dist/lib.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"files": [
|
|
41
|
+
"dist"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@opencode-ai/plugin": "1.15.10",
|
|
51
|
+
"@vymalo/opencode-auth-core": "0.14.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"vite": "^8.2.1",
|
|
55
|
+
"vitest": "^4.1.7"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "node ../../scripts/build-package.mjs",
|
|
59
|
+
"lint": "biome lint .",
|
|
60
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
63
|
+
"coverage": "vitest run --coverage",
|
|
64
|
+
"format": "biome format --write .",
|
|
65
|
+
"format:check": "biome format ."
|
|
66
|
+
}
|
|
67
|
+
}
|