@vymalo/opencode-oauth2 0.12.0 → 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.
Files changed (57) hide show
  1. package/dist/cache.d.ts +6 -6
  2. package/dist/cache.js +100 -104
  3. package/dist/cache.js.map +1 -1
  4. package/dist/config.d.ts +107 -107
  5. package/dist/config.js +173 -182
  6. package/dist/config.js.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib.js +1 -0
  10. package/dist/lib.js.map +1 -1
  11. package/dist/logging.d.ts +6 -6
  12. package/dist/logging.js +57 -56
  13. package/dist/logging.js.map +1 -1
  14. package/dist/model-discovery.d.ts +3 -3
  15. package/dist/model-discovery.js +61 -66
  16. package/dist/model-discovery.js.map +1 -1
  17. package/dist/model-normalization.js +84 -73
  18. package/dist/model-normalization.js.map +1 -1
  19. package/dist/oauth/browser.js +29 -25
  20. package/dist/oauth/browser.js.map +1 -1
  21. package/dist/oauth/client.d.ts +46 -46
  22. package/dist/oauth/client.js +411 -430
  23. package/dist/oauth/client.js.map +1 -1
  24. package/dist/oauth/device-code.d.ts +23 -23
  25. package/dist/oauth/device-code.js +225 -235
  26. package/dist/oauth/device-code.js.map +1 -1
  27. package/dist/oauth/discovery.d.ts +5 -5
  28. package/dist/oauth/discovery.js +34 -34
  29. package/dist/oauth/discovery.js.map +1 -1
  30. package/dist/oauth/http-utils.d.ts +27 -27
  31. package/dist/oauth/http-utils.js +96 -107
  32. package/dist/oauth/http-utils.js.map +1 -1
  33. package/dist/oauth/local-callback.d.ts +5 -5
  34. package/dist/oauth/local-callback.js +76 -74
  35. package/dist/oauth/local-callback.js.map +1 -1
  36. package/dist/oauth/pkce.d.ts +2 -2
  37. package/dist/oauth/pkce.js +9 -5
  38. package/dist/oauth/pkce.js.map +1 -1
  39. package/dist/oauth/subject-token.d.ts +15 -15
  40. package/dist/oauth/subject-token.js +68 -73
  41. package/dist/oauth/subject-token.js.map +1 -1
  42. package/dist/opencode.d.ts +15 -15
  43. package/dist/opencode.js +477 -497
  44. package/dist/opencode.js.map +1 -1
  45. package/dist/plugin.d.ts +39 -39
  46. package/dist/plugin.js +270 -277
  47. package/dist/plugin.js.map +1 -1
  48. package/dist/responses-repair.d.ts +12 -35
  49. package/dist/responses-repair.js +119 -125
  50. package/dist/responses-repair.js.map +1 -1
  51. package/dist/scheduler.d.ts +5 -5
  52. package/dist/scheduler.js +43 -45
  53. package/dist/scheduler.js.map +1 -1
  54. package/dist/types.d.ts +25 -25
  55. package/dist/types.js +1 -0
  56. package/dist/types.js.map +1 -1
  57. package/package.json +3 -3
@@ -7,437 +7,418 @@ import { startLocalCallbackServer } from "./local-callback.js";
7
7
  import { generatePkcePair, generateStateToken } from "./pkce.js";
8
8
  import { resolveSubjectToken } from "./subject-token.js";
9
9
  export function toTokenSet(payload, options) {
10
- const accessToken = payload.access_token;
11
- if (typeof accessToken !== "string" || accessToken.length === 0) {
12
- throw new Error("OAuth token response is missing access_token");
13
- }
14
- const tokenType = typeof payload.token_type === "string" && payload.token_type.length > 0
15
- ? payload.token_type
16
- : "Bearer";
17
- const expiresIn = typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in)
18
- ? payload.expires_in
19
- : undefined;
20
- const refreshToken = typeof payload.refresh_token === "string" && payload.refresh_token.length > 0
21
- ? payload.refresh_token
22
- : options?.fallbackRefreshToken;
23
- if (options?.requireRefreshToken !== false && !refreshToken) {
24
- throw new Error("OAuth token response is missing refresh_token");
25
- }
26
- return {
27
- accessToken,
28
- tokenType,
29
- refreshToken: refreshToken,
30
- scope: typeof payload.scope === "string" ? payload.scope : undefined,
31
- expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined
32
- };
10
+ const accessToken = payload.access_token;
11
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
12
+ throw new Error("OAuth token response is missing access_token");
13
+ }
14
+ const tokenType = typeof payload.token_type === "string" && payload.token_type.length > 0 ? payload.token_type : "Bearer";
15
+ const expiresIn = typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) ? payload.expires_in : undefined;
16
+ const refreshToken = typeof payload.refresh_token === "string" && payload.refresh_token.length > 0 ? payload.refresh_token : options?.fallbackRefreshToken;
17
+ if (options?.requireRefreshToken !== false && !refreshToken) {
18
+ throw new Error("OAuth token response is missing refresh_token");
19
+ }
20
+ return {
21
+ accessToken,
22
+ tokenType,
23
+ refreshToken,
24
+ scope: typeof payload.scope === "string" ? payload.scope : undefined,
25
+ expiresAt: expiresIn ? Date.now() + expiresIn * 1e3 : undefined
26
+ };
33
27
  }
34
28
  export class OAuthClient {
35
- server;
36
- fetchImpl;
37
- logger;
38
- timeoutMs;
39
- onAuthorizationUrl;
40
- tokenExpirySkewMs;
41
- constructor(server, options) {
42
- this.server = server;
43
- this.fetchImpl = options.fetchImpl ?? fetch;
44
- this.logger = options.logger;
45
- this.timeoutMs = options.timeoutMs;
46
- this.onAuthorizationUrl = options.onAuthorizationUrl;
47
- this.tokenExpirySkewMs =
48
- typeof options.tokenExpirySkewMs === "number" &&
49
- Number.isFinite(options.tokenExpirySkewMs) &&
50
- options.tokenExpirySkewMs > 0
51
- ? options.tokenExpirySkewMs
52
- : DEFAULT_TOKEN_EXPIRY_SKEW_MS;
53
- }
54
- isTokenValid(token) {
55
- if (!token?.accessToken) {
56
- return false;
57
- }
58
- if (!token.expiresAt) {
59
- // For machine-to-machine flows (client_credentials, jwt_bearer,
60
- // token_exchange), re-authentication is cheap (one POST + maybe a
61
- // subject-token fetch) and the spec allows but does not require
62
- // `expires_in`. Without a declared lifetime we cannot tell if the
63
- // server-side token has been revoked, so we re-acquire each time to
64
- // avoid persistent 401s after the server's idea of the token has
65
- // expired. User-interactive flows (authorization_code, device_code)
66
- // keep the old behavior — assume non-expiring when expires_in is
67
- // missing — because the cost of forcing an unnecessary browser dance
68
- // is high.
69
- const machineFlows = [
70
- "client_credentials",
71
- "jwt_bearer",
72
- "token_exchange"
73
- ];
74
- return !machineFlows.includes(this.server.authFlow);
75
- }
76
- return Date.now() + this.tokenExpirySkewMs < token.expiresAt;
77
- }
78
- /**
79
- * POST to a token endpoint with an AbortController-backed timeout. Without
80
- * this, a stalled IdP would block the warmup path indefinitely (the plugin
81
- * runs token requests at config-hook time for cached/client_credentials
82
- * paths).
83
- */
84
- async postWithTimeout(url, body) {
85
- const controller = new AbortController();
86
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
87
- try {
88
- return await this.fetchImpl(url, {
89
- method: "POST",
90
- headers: {
91
- "Content-Type": "application/x-www-form-urlencoded",
92
- Accept: "application/json"
93
- },
94
- body,
95
- signal: controller.signal
96
- });
97
- }
98
- finally {
99
- clearTimeout(timer);
100
- }
101
- }
102
- async ensureToken(current, options = {}) {
103
- if (this.isTokenValid(current)) {
104
- return current;
105
- }
106
- // Machine-to-machine flows never need a refresh token (they re-acquire
107
- // by re-presenting the platform identity / client secret) and are safe
108
- // to run during non-interactive warmup. Dispatch before the refresh
109
- // branch so we don't try to use a stale refresh token that the IdP may
110
- // not even have issued.
111
- if (this.server.authFlow === "client_credentials") {
112
- return this.loginClientCredentials();
113
- }
114
- if (this.server.authFlow === "jwt_bearer") {
115
- return this.loginJwtBearer();
116
- }
117
- if (this.server.authFlow === "token_exchange") {
118
- return this.loginTokenExchange();
119
- }
120
- if (current?.refreshToken) {
121
- try {
122
- const refreshed = await this.refreshToken(current.refreshToken);
123
- this.logger.debug("oauth_refresh_success", { serverId: this.server.id });
124
- return refreshed;
125
- }
126
- catch (error) {
127
- this.logger.warn("oauth_refresh_failed", {
128
- serverId: this.server.id,
129
- error: error instanceof Error ? error.message : String(error)
130
- });
131
- }
132
- }
133
- // When called non-interactively (e.g. plugin warmup at config-hook time),
134
- // refuse to open a browser or block on device-code polling. Callers like
135
- // syncServer catch this and preserve cached state; the provider's models
136
- // stay empty in OpenCode until the user actually attempts a chat (which
137
- // calls ensureToken with the default interactive=true).
138
- if (options.interactive === false) {
139
- throw new Error(`interactive authentication required for server "${this.server.id}" (authFlow=${this.server.authFlow}) but called non-interactively`);
140
- }
141
- if (this.server.authFlow === "device_code") {
142
- return this.loginDeviceCode();
143
- }
144
- return this.loginInteractive();
145
- }
146
- async loginClientCredentials() {
147
- if (!this.server.clientSecret) {
148
- throw new Error("client_credentials flow requires clientSecret");
149
- }
150
- const endpoints = await this.resolveEndpoints();
151
- const body = new URLSearchParams({
152
- grant_type: "client_credentials",
153
- client_id: this.server.clientId,
154
- client_secret: this.server.clientSecret
155
- });
156
- if (this.server.scopes.length > 0) {
157
- body.set("scope", this.server.scopes.join(" "));
158
- }
159
- this.logger.info("oauth_client_credentials_started", {
160
- serverId: this.server.id,
161
- // tokenEndpoint comes from user-supplied config (or OIDC discovery off
162
- // a user-supplied issuer); strip userinfo + query before logging so
163
- // configs like `https://user:pass@.../token` don't leak credentials.
164
- tokenEndpoint: redactUrl(endpoints.tokenEndpoint)
165
- });
166
- const response = await this.postWithTimeout(endpoints.tokenEndpoint, body);
167
- if (!response.ok) {
168
- const bodyPreview = await readResponseBodyPreview(response, 500);
169
- // Log the body separately so the logger's redaction can scrub matching
170
- // keys, and run it through scrubSecrets to also mask token-shaped
171
- // substrings that IdPs sometimes echo back inside arbitrary error text
172
- // (where field-name-based redaction wouldn't help). Never embed the
173
- // body in throw new Error(...) — callers log error.message verbatim.
174
- this.logger.error("oauth_client_credentials_failed", {
175
- serverId: this.server.id,
176
- status: response.status,
177
- bodyPreview: bodyPreview ? scrubSecrets(bodyPreview) : undefined
178
- });
179
- throw new Error(`client_credentials token request failed (${response.status})`);
180
- }
181
- const payload = (await response.json());
182
- const token = toTokenSet(payload, { requireRefreshToken: false });
183
- this.logger.info("oauth_client_credentials_success", {
184
- serverId: this.server.id,
185
- hasExpiry: token.expiresAt !== undefined
186
- });
187
- return token;
188
- }
189
- async loginJwtBearer() {
190
- if (!this.server.subjectTokenSource) {
191
- throw new Error("jwt_bearer flow requires subjectTokenSource");
192
- }
193
- return this.postFederatedGrant({
194
- grantType: "urn:ietf:params:oauth:grant-type:jwt-bearer",
195
- extraFields: (assertion) => ({ assertion }),
196
- eventPrefix: "oauth_jwt_bearer"
197
- });
198
- }
199
- async loginTokenExchange() {
200
- if (!this.server.subjectTokenSource) {
201
- throw new Error("token_exchange flow requires subjectTokenSource");
202
- }
203
- return this.postFederatedGrant({
204
- grantType: "urn:ietf:params:oauth:grant-type:token-exchange",
205
- extraFields: (subjectToken) => {
206
- const fields = {
207
- subject_token: subjectToken,
208
- subject_token_type: "urn:ietf:params:oauth:token-type:jwt"
209
- };
210
- if (this.server.tokenExchangeAudience) {
211
- fields.audience = this.server.tokenExchangeAudience;
212
- }
213
- return fields;
214
- },
215
- eventPrefix: "oauth_token_exchange"
216
- });
217
- }
218
- /**
219
- * Shared driver for jwt_bearer and token_exchange. Both grants:
220
- * - resolve a platform-supplied JWT from `subjectTokenSource`
221
- * - POST it to the token endpoint with grant-specific form fields
222
- * - get back an access token (refresh token is NOT expected for either)
223
- */
224
- async postFederatedGrant(spec) {
225
- const subjectTokenSource = this.server.subjectTokenSource;
226
- if (!subjectTokenSource) {
227
- // Caller already guards this; belt-and-braces for the type narrowing.
228
- throw new Error("federated flow requires subjectTokenSource");
229
- }
230
- const subjectToken = await resolveSubjectToken(subjectTokenSource, {
231
- fetchImpl: this.fetchImpl,
232
- timeoutMs: this.timeoutMs
233
- });
234
- const endpoints = await this.resolveEndpoints();
235
- const body = new URLSearchParams({
236
- grant_type: spec.grantType,
237
- client_id: this.server.clientId,
238
- ...spec.extraFields(subjectToken)
239
- });
240
- if (this.server.scopes.length > 0) {
241
- body.set("scope", this.server.scopes.join(" "));
242
- }
243
- if (this.server.clientSecret) {
244
- // Confidential federated clients are permitted by Keycloak and many
245
- // others; some IdPs require both the assertion AND the client secret.
246
- body.set("client_secret", this.server.clientSecret);
247
- }
248
- this.logger.info(`${spec.eventPrefix}_started`, {
249
- serverId: this.server.id,
250
- tokenEndpoint: redactUrl(endpoints.tokenEndpoint),
251
- subjectTokenSource: subjectTokenSource.type
252
- });
253
- const response = await this.postWithTimeout(endpoints.tokenEndpoint, body);
254
- if (!response.ok) {
255
- const preview = await readResponseBodyPreview(response, 500);
256
- this.logger.error(`${spec.eventPrefix}_failed`, {
257
- serverId: this.server.id,
258
- status: response.status,
259
- bodyPreview: preview ? scrubSecrets(preview) : undefined
260
- });
261
- throw new Error(`${spec.grantType} request failed (${response.status})`);
262
- }
263
- const payload = (await response.json());
264
- const token = toTokenSet(payload, { requireRefreshToken: false });
265
- this.logger.info(`${spec.eventPrefix}_success`, {
266
- serverId: this.server.id,
267
- hasExpiry: token.expiresAt !== undefined
268
- });
269
- return token;
270
- }
271
- /**
272
- * Resolve endpoints for the configured flow. Skips OIDC discovery entirely
273
- * when the explicit endpoints needed for the flow are all present in config
274
- * — discovery is unavailable on RFC 8414-only servers and on Keycloak
275
- * realms where the well-known doc is locked down.
276
- */
277
- async resolveEndpoints() {
278
- const haveAllExplicit = this.hasExplicitEndpointsForFlow();
279
- if (haveAllExplicit) {
280
- return {
281
- authorizationEndpoint: this.server.authorizationEndpoint,
282
- tokenEndpoint: this.server.tokenEndpoint,
283
- deviceAuthorizationEndpoint: this.server.deviceAuthorizationEndpoint
284
- };
285
- }
286
- const metadata = await discoverOidcMetadata(this.server.issuer, this.fetchImpl, this.timeoutMs);
287
- const tokenEndpoint = this.server.tokenEndpoint ?? metadata.token_endpoint;
288
- const authorizationEndpoint = this.server.authorizationEndpoint ?? metadata.authorization_endpoint;
289
- const deviceAuthorizationEndpoint = this.server.deviceAuthorizationEndpoint ?? metadata.device_authorization_endpoint;
290
- if (this.server.authFlow === "authorization_code" && !authorizationEndpoint) {
291
- throw new Error("authorization_code flow requires an authorization_endpoint (either configured or discovered)");
292
- }
293
- if (this.server.authFlow === "device_code" && !deviceAuthorizationEndpoint) {
294
- throw new Error("device_code flow requires a device_authorization_endpoint (either configured or discovered)");
295
- }
296
- return {
297
- authorizationEndpoint,
298
- tokenEndpoint,
299
- deviceAuthorizationEndpoint
300
- };
301
- }
302
- hasExplicitEndpointsForFlow() {
303
- if (!this.server.tokenEndpoint) {
304
- return false;
305
- }
306
- switch (this.server.authFlow) {
307
- case "client_credentials":
308
- case "jwt_bearer":
309
- case "token_exchange":
310
- return true;
311
- case "device_code":
312
- return Boolean(this.server.deviceAuthorizationEndpoint);
313
- default:
314
- return Boolean(this.server.authorizationEndpoint);
315
- }
316
- }
317
- async refreshToken(refreshToken) {
318
- const endpoints = await this.resolveEndpoints();
319
- const body = new URLSearchParams({
320
- grant_type: "refresh_token",
321
- refresh_token: refreshToken,
322
- client_id: this.server.clientId
323
- });
324
- if (this.server.clientSecret) {
325
- body.set("client_secret", this.server.clientSecret);
326
- }
327
- const response = await this.postWithTimeout(endpoints.tokenEndpoint, body);
328
- if (!response.ok) {
329
- throw new Error(`refresh token exchange failed (${response.status})`);
330
- }
331
- const payload = (await response.json());
332
- const nextToken = toTokenSet(payload, {
333
- fallbackRefreshToken: refreshToken,
334
- requireRefreshToken: true
335
- });
336
- return nextToken;
337
- }
338
- async loginDeviceCode() {
339
- const endpoints = await this.resolveEndpoints();
340
- if (!endpoints.deviceAuthorizationEndpoint) {
341
- throw new Error("device_code flow requires a device_authorization_endpoint (either configured or discovered)");
342
- }
343
- return acquireTokenViaDeviceCode({
344
- deviceAuthorizationEndpoint: endpoints.deviceAuthorizationEndpoint,
345
- tokenEndpoint: endpoints.tokenEndpoint,
346
- clientId: this.server.clientId,
347
- clientSecret: this.server.clientSecret,
348
- scopes: this.server.scopes,
349
- serverId: this.server.id,
350
- logger: this.logger,
351
- fetchImpl: this.fetchImpl,
352
- timeoutMs: this.timeoutMs,
353
- pkce: this.server.pkce
354
- });
355
- }
356
- async loginInteractive() {
357
- const endpoints = await this.resolveEndpoints();
358
- if (!endpoints.authorizationEndpoint) {
359
- // resolveEndpoints already guards this for authorization_code, but TS
360
- // sees the type as optional. Belt-and-braces.
361
- throw new Error("authorization_code flow requires an authorization_endpoint (either configured or discovered)");
362
- }
363
- const callbackServer = await startLocalCallbackServer("/oauth2/callback", this.server.redirectPort);
364
- try {
365
- // PKCE on by default; opt out via the `pkce` server option for
366
- // non-compliant IdPs. Compliant servers ignore the extra parameters.
367
- const pkce = this.server.pkce !== false ? generatePkcePair() : undefined;
368
- const state = generateStateToken();
369
- const authorizeUrl = new URL(endpoints.authorizationEndpoint);
370
- authorizeUrl.searchParams.set("response_type", "code");
371
- authorizeUrl.searchParams.set("client_id", this.server.clientId);
372
- authorizeUrl.searchParams.set("redirect_uri", callbackServer.redirectUri);
373
- authorizeUrl.searchParams.set("scope", this.server.scopes.join(" "));
374
- if (pkce) {
375
- authorizeUrl.searchParams.set("code_challenge", pkce.challenge);
376
- authorizeUrl.searchParams.set("code_challenge_method", "S256");
377
- }
378
- authorizeUrl.searchParams.set("state", state);
379
- this.logger.info("oauth_login_started", {
380
- serverId: this.server.id,
381
- issuer: this.server.issuer,
382
- authorizationEndpoint: `${authorizeUrl.origin}${authorizeUrl.pathname}`
383
- });
384
- if (this.onAuthorizationUrl) {
385
- await this.onAuthorizationUrl(authorizeUrl.toString());
386
- }
387
- else {
388
- try {
389
- await openExternalUrl(authorizeUrl.toString());
390
- }
391
- catch (error) {
392
- this.logger.warn("oauth_open_browser_failed", {
393
- serverId: this.server.id,
394
- error: error instanceof Error ? error.message : String(error)
395
- });
396
- // Write the URL to stderr directly so the terminal user can copy-paste
397
- // it. Bypasses the structured logger to avoid leaking the `state`
398
- // nonce (and other query params) into centralized log aggregation,
399
- // which would enable login-CSRF via a forged localhost callback.
400
- process.stderr.write(`\n[opencode-oauth2] open this URL to authenticate (${this.server.id}):\n${authorizeUrl.toString()}\n\n`);
401
- }
402
- }
403
- const callback = await callbackServer.waitForCode();
404
- if (callback.state !== state) {
405
- throw new Error("OAuth callback state mismatch");
406
- }
407
- const tokenBody = new URLSearchParams({
408
- grant_type: "authorization_code",
409
- code: callback.code,
410
- client_id: this.server.clientId,
411
- redirect_uri: callbackServer.redirectUri
412
- });
413
- if (pkce) {
414
- tokenBody.set("code_verifier", pkce.verifier);
415
- }
416
- if (this.server.clientSecret) {
417
- tokenBody.set("client_secret", this.server.clientSecret);
418
- }
419
- const tokenResponse = await this.postWithTimeout(endpoints.tokenEndpoint, tokenBody);
420
- if (!tokenResponse.ok) {
421
- throw new Error(`authorization code exchange failed (${tokenResponse.status})`);
422
- }
423
- const payload = (await tokenResponse.json());
424
- const token = toTokenSet(payload, { requireRefreshToken: true });
425
- this.logger.info("oauth_login_success", {
426
- serverId: this.server.id,
427
- hasRefreshToken: true
428
- });
429
- return token;
430
- }
431
- catch (error) {
432
- this.logger.error("oauth_login_failed", {
433
- serverId: this.server.id,
434
- error: error instanceof Error ? error.message : String(error)
435
- });
436
- throw error;
437
- }
438
- finally {
439
- await callbackServer.close();
440
- }
441
- }
29
+ server;
30
+ fetchImpl;
31
+ logger;
32
+ timeoutMs;
33
+ onAuthorizationUrl;
34
+ tokenExpirySkewMs;
35
+ constructor(server, options) {
36
+ this.server = server;
37
+ this.fetchImpl = options.fetchImpl ?? fetch;
38
+ this.logger = options.logger;
39
+ this.timeoutMs = options.timeoutMs;
40
+ this.onAuthorizationUrl = options.onAuthorizationUrl;
41
+ this.tokenExpirySkewMs = typeof options.tokenExpirySkewMs === "number" && Number.isFinite(options.tokenExpirySkewMs) && options.tokenExpirySkewMs > 0 ? options.tokenExpirySkewMs : DEFAULT_TOKEN_EXPIRY_SKEW_MS;
42
+ }
43
+ isTokenValid(token) {
44
+ if (!token?.accessToken) {
45
+ return false;
46
+ }
47
+ if (!token.expiresAt) {
48
+ // For machine-to-machine flows (client_credentials, jwt_bearer,
49
+ // token_exchange), re-authentication is cheap (one POST + maybe a
50
+ // subject-token fetch) and the spec allows but does not require
51
+ // `expires_in`. Without a declared lifetime we cannot tell if the
52
+ // server-side token has been revoked, so we re-acquire each time to
53
+ // avoid persistent 401s after the server's idea of the token has
54
+ // expired. User-interactive flows (authorization_code, device_code)
55
+ // keep the old behavior assume non-expiring when expires_in is
56
+ // missing because the cost of forcing an unnecessary browser dance
57
+ // is high.
58
+ const machineFlows = [
59
+ "client_credentials",
60
+ "jwt_bearer",
61
+ "token_exchange"
62
+ ];
63
+ return !machineFlows.includes(this.server.authFlow);
64
+ }
65
+ return Date.now() + this.tokenExpirySkewMs < token.expiresAt;
66
+ }
67
+ /**
68
+ * POST to a token endpoint with an AbortController-backed timeout. Without
69
+ * this, a stalled IdP would block the warmup path indefinitely (the plugin
70
+ * runs token requests at config-hook time for cached/client_credentials
71
+ * paths).
72
+ */
73
+ async postWithTimeout(url, body) {
74
+ const controller = new AbortController();
75
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
76
+ try {
77
+ return await this.fetchImpl(url, {
78
+ method: "POST",
79
+ headers: {
80
+ "Content-Type": "application/x-www-form-urlencoded",
81
+ Accept: "application/json"
82
+ },
83
+ body,
84
+ signal: controller.signal
85
+ });
86
+ } finally {
87
+ clearTimeout(timer);
88
+ }
89
+ }
90
+ async ensureToken(current, options = {}) {
91
+ if (this.isTokenValid(current)) {
92
+ return current;
93
+ }
94
+ // Machine-to-machine flows never need a refresh token (they re-acquire
95
+ // by re-presenting the platform identity / client secret) and are safe
96
+ // to run during non-interactive warmup. Dispatch before the refresh
97
+ // branch so we don't try to use a stale refresh token that the IdP may
98
+ // not even have issued.
99
+ if (this.server.authFlow === "client_credentials") {
100
+ return this.loginClientCredentials();
101
+ }
102
+ if (this.server.authFlow === "jwt_bearer") {
103
+ return this.loginJwtBearer();
104
+ }
105
+ if (this.server.authFlow === "token_exchange") {
106
+ return this.loginTokenExchange();
107
+ }
108
+ if (current?.refreshToken) {
109
+ try {
110
+ const refreshed = await this.refreshToken(current.refreshToken);
111
+ this.logger.debug("oauth_refresh_success", { serverId: this.server.id });
112
+ return refreshed;
113
+ } catch (error) {
114
+ this.logger.warn("oauth_refresh_failed", {
115
+ serverId: this.server.id,
116
+ error: error instanceof Error ? error.message : String(error)
117
+ });
118
+ }
119
+ }
120
+ // When called non-interactively (e.g. plugin warmup at config-hook time),
121
+ // refuse to open a browser or block on device-code polling. Callers like
122
+ // syncServer catch this and preserve cached state; the provider's models
123
+ // stay empty in OpenCode until the user actually attempts a chat (which
124
+ // calls ensureToken with the default interactive=true).
125
+ if (options.interactive === false) {
126
+ throw new Error(`interactive authentication required for server "${this.server.id}" (authFlow=${this.server.authFlow}) but called non-interactively`);
127
+ }
128
+ if (this.server.authFlow === "device_code") {
129
+ return this.loginDeviceCode();
130
+ }
131
+ return this.loginInteractive();
132
+ }
133
+ async loginClientCredentials() {
134
+ if (!this.server.clientSecret) {
135
+ throw new Error("client_credentials flow requires clientSecret");
136
+ }
137
+ const endpoints = await this.resolveEndpoints();
138
+ const body = new URLSearchParams({
139
+ grant_type: "client_credentials",
140
+ client_id: this.server.clientId,
141
+ client_secret: this.server.clientSecret
142
+ });
143
+ if (this.server.scopes.length > 0) {
144
+ body.set("scope", this.server.scopes.join(" "));
145
+ }
146
+ this.logger.info("oauth_client_credentials_started", {
147
+ serverId: this.server.id,
148
+ // tokenEndpoint comes from user-supplied config (or OIDC discovery off
149
+ // a user-supplied issuer); strip userinfo + query before logging so
150
+ // configs like `https://user:pass@.../token` don't leak credentials.
151
+ tokenEndpoint: redactUrl(endpoints.tokenEndpoint)
152
+ });
153
+ const response = await this.postWithTimeout(endpoints.tokenEndpoint, body);
154
+ if (!response.ok) {
155
+ const bodyPreview = await readResponseBodyPreview(response, 500);
156
+ // Log the body separately so the logger's redaction can scrub matching
157
+ // keys, and run it through scrubSecrets to also mask token-shaped
158
+ // substrings that IdPs sometimes echo back inside arbitrary error text
159
+ // (where field-name-based redaction wouldn't help). Never embed the
160
+ // body in throw new Error(...) — callers log error.message verbatim.
161
+ this.logger.error("oauth_client_credentials_failed", {
162
+ serverId: this.server.id,
163
+ status: response.status,
164
+ bodyPreview: bodyPreview ? scrubSecrets(bodyPreview) : undefined
165
+ });
166
+ throw new Error(`client_credentials token request failed (${response.status})`);
167
+ }
168
+ const payload = await response.json();
169
+ const token = toTokenSet(payload, { requireRefreshToken: false });
170
+ this.logger.info("oauth_client_credentials_success", {
171
+ serverId: this.server.id,
172
+ hasExpiry: token.expiresAt !== undefined
173
+ });
174
+ return token;
175
+ }
176
+ async loginJwtBearer() {
177
+ if (!this.server.subjectTokenSource) {
178
+ throw new Error("jwt_bearer flow requires subjectTokenSource");
179
+ }
180
+ return this.postFederatedGrant({
181
+ grantType: "urn:ietf:params:oauth:grant-type:jwt-bearer",
182
+ extraFields: (assertion) => ({ assertion }),
183
+ eventPrefix: "oauth_jwt_bearer"
184
+ });
185
+ }
186
+ async loginTokenExchange() {
187
+ if (!this.server.subjectTokenSource) {
188
+ throw new Error("token_exchange flow requires subjectTokenSource");
189
+ }
190
+ return this.postFederatedGrant({
191
+ grantType: "urn:ietf:params:oauth:grant-type:token-exchange",
192
+ extraFields: (subjectToken) => {
193
+ const fields = {
194
+ subject_token: subjectToken,
195
+ subject_token_type: "urn:ietf:params:oauth:token-type:jwt"
196
+ };
197
+ if (this.server.tokenExchangeAudience) {
198
+ fields.audience = this.server.tokenExchangeAudience;
199
+ }
200
+ return fields;
201
+ },
202
+ eventPrefix: "oauth_token_exchange"
203
+ });
204
+ }
205
+ /**
206
+ * Shared driver for jwt_bearer and token_exchange. Both grants:
207
+ * - resolve a platform-supplied JWT from `subjectTokenSource`
208
+ * - POST it to the token endpoint with grant-specific form fields
209
+ * - get back an access token (refresh token is NOT expected for either)
210
+ */
211
+ async postFederatedGrant(spec) {
212
+ const subjectTokenSource = this.server.subjectTokenSource;
213
+ if (!subjectTokenSource) {
214
+ // Caller already guards this; belt-and-braces for the type narrowing.
215
+ throw new Error("federated flow requires subjectTokenSource");
216
+ }
217
+ const subjectToken = await resolveSubjectToken(subjectTokenSource, {
218
+ fetchImpl: this.fetchImpl,
219
+ timeoutMs: this.timeoutMs
220
+ });
221
+ const endpoints = await this.resolveEndpoints();
222
+ const body = new URLSearchParams({
223
+ grant_type: spec.grantType,
224
+ client_id: this.server.clientId,
225
+ ...spec.extraFields(subjectToken)
226
+ });
227
+ if (this.server.scopes.length > 0) {
228
+ body.set("scope", this.server.scopes.join(" "));
229
+ }
230
+ if (this.server.clientSecret) {
231
+ // Confidential federated clients are permitted by Keycloak and many
232
+ // others; some IdPs require both the assertion AND the client secret.
233
+ body.set("client_secret", this.server.clientSecret);
234
+ }
235
+ this.logger.info(`${spec.eventPrefix}_started`, {
236
+ serverId: this.server.id,
237
+ tokenEndpoint: redactUrl(endpoints.tokenEndpoint),
238
+ subjectTokenSource: subjectTokenSource.type
239
+ });
240
+ const response = await this.postWithTimeout(endpoints.tokenEndpoint, body);
241
+ if (!response.ok) {
242
+ const preview = await readResponseBodyPreview(response, 500);
243
+ this.logger.error(`${spec.eventPrefix}_failed`, {
244
+ serverId: this.server.id,
245
+ status: response.status,
246
+ bodyPreview: preview ? scrubSecrets(preview) : undefined
247
+ });
248
+ throw new Error(`${spec.grantType} request failed (${response.status})`);
249
+ }
250
+ const payload = await response.json();
251
+ const token = toTokenSet(payload, { requireRefreshToken: false });
252
+ this.logger.info(`${spec.eventPrefix}_success`, {
253
+ serverId: this.server.id,
254
+ hasExpiry: token.expiresAt !== undefined
255
+ });
256
+ return token;
257
+ }
258
+ /**
259
+ * Resolve endpoints for the configured flow. Skips OIDC discovery entirely
260
+ * when the explicit endpoints needed for the flow are all present in config
261
+ * discovery is unavailable on RFC 8414-only servers and on Keycloak
262
+ * realms where the well-known doc is locked down.
263
+ */
264
+ async resolveEndpoints() {
265
+ const haveAllExplicit = this.hasExplicitEndpointsForFlow();
266
+ if (haveAllExplicit) {
267
+ return {
268
+ authorizationEndpoint: this.server.authorizationEndpoint,
269
+ tokenEndpoint: this.server.tokenEndpoint,
270
+ deviceAuthorizationEndpoint: this.server.deviceAuthorizationEndpoint
271
+ };
272
+ }
273
+ const metadata = await discoverOidcMetadata(this.server.issuer, this.fetchImpl, this.timeoutMs);
274
+ const tokenEndpoint = this.server.tokenEndpoint ?? metadata.token_endpoint;
275
+ const authorizationEndpoint = this.server.authorizationEndpoint ?? metadata.authorization_endpoint;
276
+ const deviceAuthorizationEndpoint = this.server.deviceAuthorizationEndpoint ?? metadata.device_authorization_endpoint;
277
+ if (this.server.authFlow === "authorization_code" && !authorizationEndpoint) {
278
+ throw new Error("authorization_code flow requires an authorization_endpoint (either configured or discovered)");
279
+ }
280
+ if (this.server.authFlow === "device_code" && !deviceAuthorizationEndpoint) {
281
+ throw new Error("device_code flow requires a device_authorization_endpoint (either configured or discovered)");
282
+ }
283
+ return {
284
+ authorizationEndpoint,
285
+ tokenEndpoint,
286
+ deviceAuthorizationEndpoint
287
+ };
288
+ }
289
+ hasExplicitEndpointsForFlow() {
290
+ if (!this.server.tokenEndpoint) {
291
+ return false;
292
+ }
293
+ switch (this.server.authFlow) {
294
+ case "client_credentials":
295
+ case "jwt_bearer":
296
+ case "token_exchange": return true;
297
+ case "device_code": return Boolean(this.server.deviceAuthorizationEndpoint);
298
+ default: return Boolean(this.server.authorizationEndpoint);
299
+ }
300
+ }
301
+ async refreshToken(refreshToken) {
302
+ const endpoints = await this.resolveEndpoints();
303
+ const body = new URLSearchParams({
304
+ grant_type: "refresh_token",
305
+ refresh_token: refreshToken,
306
+ client_id: this.server.clientId
307
+ });
308
+ if (this.server.clientSecret) {
309
+ body.set("client_secret", this.server.clientSecret);
310
+ }
311
+ const response = await this.postWithTimeout(endpoints.tokenEndpoint, body);
312
+ if (!response.ok) {
313
+ throw new Error(`refresh token exchange failed (${response.status})`);
314
+ }
315
+ const payload = await response.json();
316
+ const nextToken = toTokenSet(payload, {
317
+ fallbackRefreshToken: refreshToken,
318
+ requireRefreshToken: true
319
+ });
320
+ return nextToken;
321
+ }
322
+ async loginDeviceCode() {
323
+ const endpoints = await this.resolveEndpoints();
324
+ if (!endpoints.deviceAuthorizationEndpoint) {
325
+ throw new Error("device_code flow requires a device_authorization_endpoint (either configured or discovered)");
326
+ }
327
+ return acquireTokenViaDeviceCode({
328
+ deviceAuthorizationEndpoint: endpoints.deviceAuthorizationEndpoint,
329
+ tokenEndpoint: endpoints.tokenEndpoint,
330
+ clientId: this.server.clientId,
331
+ clientSecret: this.server.clientSecret,
332
+ scopes: this.server.scopes,
333
+ serverId: this.server.id,
334
+ logger: this.logger,
335
+ fetchImpl: this.fetchImpl,
336
+ timeoutMs: this.timeoutMs,
337
+ pkce: this.server.pkce
338
+ });
339
+ }
340
+ async loginInteractive() {
341
+ const endpoints = await this.resolveEndpoints();
342
+ if (!endpoints.authorizationEndpoint) {
343
+ // resolveEndpoints already guards this for authorization_code, but TS
344
+ // sees the type as optional. Belt-and-braces.
345
+ throw new Error("authorization_code flow requires an authorization_endpoint (either configured or discovered)");
346
+ }
347
+ const callbackServer = await startLocalCallbackServer("/oauth2/callback", this.server.redirectPort);
348
+ try {
349
+ // PKCE on by default; opt out via the `pkce` server option for
350
+ // non-compliant IdPs. Compliant servers ignore the extra parameters.
351
+ const pkce = this.server.pkce !== false ? generatePkcePair() : undefined;
352
+ const state = generateStateToken();
353
+ const authorizeUrl = new URL(endpoints.authorizationEndpoint);
354
+ authorizeUrl.searchParams.set("response_type", "code");
355
+ authorizeUrl.searchParams.set("client_id", this.server.clientId);
356
+ authorizeUrl.searchParams.set("redirect_uri", callbackServer.redirectUri);
357
+ authorizeUrl.searchParams.set("scope", this.server.scopes.join(" "));
358
+ if (pkce) {
359
+ authorizeUrl.searchParams.set("code_challenge", pkce.challenge);
360
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
361
+ }
362
+ authorizeUrl.searchParams.set("state", state);
363
+ this.logger.info("oauth_login_started", {
364
+ serverId: this.server.id,
365
+ issuer: this.server.issuer,
366
+ authorizationEndpoint: `${authorizeUrl.origin}${authorizeUrl.pathname}`
367
+ });
368
+ if (this.onAuthorizationUrl) {
369
+ await this.onAuthorizationUrl(authorizeUrl.toString());
370
+ } else {
371
+ try {
372
+ await openExternalUrl(authorizeUrl.toString());
373
+ } catch (error) {
374
+ this.logger.warn("oauth_open_browser_failed", {
375
+ serverId: this.server.id,
376
+ error: error instanceof Error ? error.message : String(error)
377
+ });
378
+ // Write the URL to stderr directly so the terminal user can copy-paste
379
+ // it. Bypasses the structured logger to avoid leaking the `state`
380
+ // nonce (and other query params) into centralized log aggregation,
381
+ // which would enable login-CSRF via a forged localhost callback.
382
+ process.stderr.write(`\n[opencode-oauth2] open this URL to authenticate (${this.server.id}):\n${authorizeUrl.toString()}\n\n`);
383
+ }
384
+ }
385
+ const callback = await callbackServer.waitForCode();
386
+ if (callback.state !== state) {
387
+ throw new Error("OAuth callback state mismatch");
388
+ }
389
+ const tokenBody = new URLSearchParams({
390
+ grant_type: "authorization_code",
391
+ code: callback.code,
392
+ client_id: this.server.clientId,
393
+ redirect_uri: callbackServer.redirectUri
394
+ });
395
+ if (pkce) {
396
+ tokenBody.set("code_verifier", pkce.verifier);
397
+ }
398
+ if (this.server.clientSecret) {
399
+ tokenBody.set("client_secret", this.server.clientSecret);
400
+ }
401
+ const tokenResponse = await this.postWithTimeout(endpoints.tokenEndpoint, tokenBody);
402
+ if (!tokenResponse.ok) {
403
+ throw new Error(`authorization code exchange failed (${tokenResponse.status})`);
404
+ }
405
+ const payload = await tokenResponse.json();
406
+ const token = toTokenSet(payload, { requireRefreshToken: true });
407
+ this.logger.info("oauth_login_success", {
408
+ serverId: this.server.id,
409
+ hasRefreshToken: true
410
+ });
411
+ return token;
412
+ } catch (error) {
413
+ this.logger.error("oauth_login_failed", {
414
+ serverId: this.server.id,
415
+ error: error instanceof Error ? error.message : String(error)
416
+ });
417
+ throw error;
418
+ } finally {
419
+ await callbackServer.close();
420
+ }
421
+ }
442
422
  }
423
+
443
424
  //# sourceMappingURL=client.js.map