byterover-cli 2.2.0 → 2.3.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/dist/agent/infra/llm/providers/openai.d.ts +12 -0
- package/dist/agent/infra/llm/providers/openai.js +52 -1
- package/dist/oclif/commands/curate/index.js +2 -2
- package/dist/oclif/commands/model/switch.js +14 -3
- package/dist/oclif/commands/providers/connect.d.ts +9 -0
- package/dist/oclif/commands/providers/connect.js +110 -14
- package/dist/oclif/commands/providers/list.js +3 -5
- package/dist/oclif/commands/query.js +2 -2
- package/dist/oclif/lib/daemon-client.d.ts +4 -0
- package/dist/oclif/lib/daemon-client.js +13 -3
- package/dist/server/core/domain/entities/provider-config.d.ts +6 -0
- package/dist/server/core/domain/entities/provider-config.js +4 -3
- package/dist/server/core/domain/entities/provider-registry.d.ts +41 -1
- package/dist/server/core/domain/entities/provider-registry.js +24 -1
- package/dist/server/core/domain/errors/task-error.d.ts +2 -0
- package/dist/server/core/domain/errors/task-error.js +6 -1
- package/dist/server/core/domain/transport/schemas.d.ts +2 -0
- package/dist/server/core/interfaces/i-provider-config-store.d.ts +2 -0
- package/dist/server/core/interfaces/i-provider-model-fetcher.d.ts +12 -3
- package/dist/server/core/interfaces/i-provider-oauth-token-store.d.ts +24 -0
- package/dist/server/core/interfaces/i-provider-oauth-token-store.js +1 -0
- package/dist/server/core/interfaces/i-token-refresh-manager.d.ts +7 -0
- package/dist/server/core/interfaces/i-token-refresh-manager.js +1 -0
- package/dist/server/infra/daemon/agent-process.js +22 -4
- package/dist/server/infra/daemon/brv-server.js +13 -2
- package/dist/server/infra/http/models-dev-client.d.ts +29 -0
- package/dist/server/infra/http/models-dev-client.js +133 -0
- package/dist/server/infra/http/provider-model-fetcher-registry.d.ts +2 -1
- package/dist/server/infra/http/provider-model-fetcher-registry.js +6 -1
- package/dist/server/infra/http/provider-model-fetchers.d.ts +33 -8
- package/dist/server/infra/http/provider-model-fetchers.js +88 -10
- package/dist/server/infra/process/feature-handlers.d.ts +3 -1
- package/dist/server/infra/process/feature-handlers.js +3 -1
- package/dist/server/infra/provider/provider-config-resolver.d.ts +4 -2
- package/dist/server/infra/provider/provider-config-resolver.js +59 -4
- package/dist/server/infra/provider-oauth/callback-server.d.ts +24 -0
- package/dist/server/infra/provider-oauth/callback-server.js +203 -0
- package/dist/server/infra/provider-oauth/errors.d.ts +39 -0
- package/dist/server/infra/provider-oauth/errors.js +76 -0
- package/dist/server/infra/provider-oauth/index.d.ts +9 -0
- package/dist/server/infra/provider-oauth/index.js +9 -0
- package/dist/server/infra/provider-oauth/jwt-utils.d.ts +17 -0
- package/dist/server/infra/provider-oauth/jwt-utils.js +51 -0
- package/dist/server/infra/provider-oauth/pkce-service.d.ts +22 -0
- package/dist/server/infra/provider-oauth/pkce-service.js +33 -0
- package/dist/server/infra/provider-oauth/provider-oauth-token-store.d.ts +48 -0
- package/dist/server/infra/provider-oauth/provider-oauth-token-store.js +155 -0
- package/dist/server/infra/provider-oauth/refresh-token-exchange.d.ts +8 -0
- package/dist/server/infra/provider-oauth/refresh-token-exchange.js +39 -0
- package/dist/server/infra/provider-oauth/token-exchange.d.ts +8 -0
- package/dist/server/infra/provider-oauth/token-exchange.js +44 -0
- package/dist/server/infra/provider-oauth/token-refresh-manager.d.ts +32 -0
- package/dist/server/infra/provider-oauth/token-refresh-manager.js +96 -0
- package/dist/server/infra/provider-oauth/types.d.ts +55 -0
- package/dist/server/infra/provider-oauth/types.js +22 -0
- package/dist/server/infra/storage/file-provider-config-store.d.ts +2 -0
- package/dist/server/infra/storage/file-provider-config-store.js +1 -3
- package/dist/server/infra/transport/handlers/model-handler.d.ts +3 -0
- package/dist/server/infra/transport/handlers/model-handler.js +53 -11
- package/dist/server/infra/transport/handlers/provider-handler.d.ts +26 -0
- package/dist/server/infra/transport/handlers/provider-handler.js +215 -13
- package/dist/shared/constants/oauth.d.ts +14 -0
- package/dist/shared/constants/oauth.js +14 -0
- package/dist/shared/transport/events/index.d.ts +5 -0
- package/dist/shared/transport/events/model-events.d.ts +2 -0
- package/dist/shared/transport/events/provider-events.d.ts +36 -0
- package/dist/shared/transport/events/provider-events.js +5 -0
- package/dist/shared/transport/types/dto.d.ts +4 -0
- package/dist/tui/features/model/api/set-active-model.d.ts +1 -1
- package/dist/tui/features/model/api/set-active-model.js +12 -4
- package/dist/tui/features/provider/api/await-oauth-callback.d.ts +11 -0
- package/dist/tui/features/provider/api/await-oauth-callback.js +25 -0
- package/dist/tui/features/provider/api/cancel-oauth.d.ts +5 -0
- package/dist/tui/features/provider/api/cancel-oauth.js +10 -0
- package/dist/tui/features/provider/api/start-oauth.d.ts +11 -0
- package/dist/tui/features/provider/api/start-oauth.js +15 -0
- package/dist/tui/features/provider/components/auth-method-dialog.d.ts +9 -0
- package/dist/tui/features/provider/components/auth-method-dialog.js +20 -0
- package/dist/tui/features/provider/components/oauth-dialog.d.ts +9 -0
- package/dist/tui/features/provider/components/oauth-dialog.js +96 -0
- package/dist/tui/features/provider/components/provider-dialog.js +1 -1
- package/dist/tui/features/provider/components/provider-flow.js +54 -4
- package/dist/tui/features/provider/components/provider-subscription-initializer.d.ts +1 -0
- package/dist/tui/features/provider/components/provider-subscription-initializer.js +5 -0
- package/dist/tui/features/provider/hooks/use-provider-subscriptions.d.ts +6 -0
- package/dist/tui/features/provider/hooks/use-provider-subscriptions.js +24 -0
- package/dist/tui/providers/app-providers.js +2 -1
- package/dist/tui/utils/error-messages.js +6 -1
- package/oclif.manifest.json +132 -116
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { IProviderConfigStore } from '../../core/interfaces/i-provider-config-store.js';
|
|
8
8
|
import type { IProviderKeychainStore } from '../../core/interfaces/i-provider-keychain-store.js';
|
|
9
|
+
import type { IProviderOAuthTokenStore } from '../../core/interfaces/i-provider-oauth-token-store.js';
|
|
9
10
|
import type { IProjectRegistry } from '../../core/interfaces/project/i-project-registry.js';
|
|
10
11
|
import type { IAuthStateStore } from '../../core/interfaces/state/i-auth-state-store.js';
|
|
11
12
|
import type { ITransportServer } from '../../core/interfaces/transport/i-transport-server.js';
|
|
@@ -18,6 +19,7 @@ export interface FeatureHandlersOptions {
|
|
|
18
19
|
projectRegistry: IProjectRegistry;
|
|
19
20
|
providerConfigStore: IProviderConfigStore;
|
|
20
21
|
providerKeychainStore: IProviderKeychainStore;
|
|
22
|
+
providerOAuthTokenStore: IProviderOAuthTokenStore;
|
|
21
23
|
resolveProjectPath: ProjectPathResolver;
|
|
22
24
|
transport: ITransportServer;
|
|
23
25
|
}
|
|
@@ -25,4 +27,4 @@ export interface FeatureHandlersOptions {
|
|
|
25
27
|
* Setup all feature handlers on the transport server.
|
|
26
28
|
* These handlers implement the TUI ↔ Server event contract (auth:*, config:*, status:*, etc.).
|
|
27
29
|
*/
|
|
28
|
-
export declare function setupFeatureHandlers({ authStateStore, broadcastToProject, getActiveProjectPaths, log, projectRegistry, providerConfigStore, providerKeychainStore, resolveProjectPath, transport, }: FeatureHandlersOptions): Promise<void>;
|
|
30
|
+
export declare function setupFeatureHandlers({ authStateStore, broadcastToProject, getActiveProjectPaths, log, projectRegistry, providerConfigStore, providerKeychainStore, providerOAuthTokenStore, resolveProjectPath, transport, }: FeatureHandlersOptions): Promise<void>;
|
|
@@ -35,7 +35,7 @@ import { HttpUserService } from '../user/http-user-service.js';
|
|
|
35
35
|
* Setup all feature handlers on the transport server.
|
|
36
36
|
* These handlers implement the TUI ↔ Server event contract (auth:*, config:*, status:*, etc.).
|
|
37
37
|
*/
|
|
38
|
-
export async function setupFeatureHandlers({ authStateStore, broadcastToProject, getActiveProjectPaths, log, projectRegistry, providerConfigStore, providerKeychainStore, resolveProjectPath, transport, }) {
|
|
38
|
+
export async function setupFeatureHandlers({ authStateStore, broadcastToProject, getActiveProjectPaths, log, projectRegistry, providerConfigStore, providerKeychainStore, providerOAuthTokenStore, resolveProjectPath, transport, }) {
|
|
39
39
|
const envConfig = getCurrentConfig();
|
|
40
40
|
const tokenStore = createTokenStore();
|
|
41
41
|
const projectConfigStore = new ProjectConfigStore();
|
|
@@ -59,8 +59,10 @@ export async function setupFeatureHandlers({ authStateStore, broadcastToProject,
|
|
|
59
59
|
userService,
|
|
60
60
|
}).setup();
|
|
61
61
|
new ProviderHandler({
|
|
62
|
+
browserLauncher: new SystemBrowserLauncher(),
|
|
62
63
|
providerConfigStore,
|
|
63
64
|
providerKeychainStore,
|
|
65
|
+
providerOAuthTokenStore,
|
|
64
66
|
transport,
|
|
65
67
|
}).setup();
|
|
66
68
|
new ModelHandler({
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { IProviderConfigStore } from '../../core/interfaces/i-provider-config-store.js';
|
|
9
9
|
import type { IProviderKeychainStore } from '../../core/interfaces/i-provider-keychain-store.js';
|
|
10
|
+
import type { IProviderOAuthTokenStore } from '../../core/interfaces/i-provider-oauth-token-store.js';
|
|
11
|
+
import type { ITokenRefreshManager } from '../../core/interfaces/i-token-refresh-manager.js';
|
|
10
12
|
import { type ProviderConfigResponse } from '../../core/domain/transport/schemas.js';
|
|
11
13
|
/**
|
|
12
14
|
* Validate provider config integrity at startup.
|
|
@@ -19,7 +21,7 @@ import { type ProviderConfigResponse } from '../../core/domain/transport/schemas
|
|
|
19
21
|
* empty string so the TUI routes to the provider setup flow — bypassing the
|
|
20
22
|
* 'byterover' fallback that withProviderDisconnected() would otherwise apply.
|
|
21
23
|
*/
|
|
22
|
-
export declare function clearStaleProviderConfig(providerConfigStore: IProviderConfigStore, providerKeychainStore: IProviderKeychainStore): Promise<void>;
|
|
24
|
+
export declare function clearStaleProviderConfig(providerConfigStore: IProviderConfigStore, providerKeychainStore: IProviderKeychainStore, providerOAuthTokenStore?: IProviderOAuthTokenStore): Promise<void>;
|
|
23
25
|
/**
|
|
24
26
|
* Resolve the active provider's full configuration.
|
|
25
27
|
*
|
|
@@ -27,4 +29,4 @@ export declare function clearStaleProviderConfig(providerConfigStore: IProviderC
|
|
|
27
29
|
* the API key (keychain → env fallback), and maps provider-specific
|
|
28
30
|
* fields (base URL, headers, location, etc.).
|
|
29
31
|
*/
|
|
30
|
-
export declare function resolveProviderConfig(providerConfigStore: IProviderConfigStore, providerKeychainStore: IProviderKeychainStore): Promise<ProviderConfigResponse>;
|
|
32
|
+
export declare function resolveProviderConfig(providerConfigStore: IProviderConfigStore, providerKeychainStore: IProviderKeychainStore, tokenRefreshManager?: ITokenRefreshManager): Promise<ProviderConfigResponse>;
|
|
@@ -5,8 +5,18 @@
|
|
|
5
5
|
* for the currently active provider. Used by the daemon state server to serve
|
|
6
6
|
* agent child processes on startup and after provider hot-swap.
|
|
7
7
|
*/
|
|
8
|
+
import { CHATGPT_OAUTH_BASE_URL, CHATGPT_OAUTH_ORIGINATOR } from '../../../shared/constants/oauth.js';
|
|
8
9
|
import { getProviderById, providerRequiresApiKey } from '../../core/domain/entities/provider-registry.js';
|
|
9
10
|
import { getProviderApiKeyFromEnv } from './env-provider-detector.js';
|
|
11
|
+
/**
|
|
12
|
+
* Check if a provider's credential (API key or OAuth access token) is accessible.
|
|
13
|
+
*
|
|
14
|
+
* Note: authMethod is intentionally NOT passed to providerRequiresApiKey() here.
|
|
15
|
+
* OAuth access tokens are stored in the keychain (as the provider's "API key"),
|
|
16
|
+
* so the keychain check on the next line correctly handles both auth methods.
|
|
17
|
+
* If an OAuth token expires and the refresh manager (Issue 5) deletes it from
|
|
18
|
+
* keychain, this function returns false — correctly marking the provider as stale.
|
|
19
|
+
*/
|
|
10
20
|
async function isProviderCredentialAccessible(providerId, providerKeychainStore) {
|
|
11
21
|
if (!providerRequiresApiKey(providerId))
|
|
12
22
|
return true;
|
|
@@ -23,7 +33,7 @@ async function isProviderCredentialAccessible(providerId, providerKeychainStore)
|
|
|
23
33
|
* empty string so the TUI routes to the provider setup flow — bypassing the
|
|
24
34
|
* 'byterover' fallback that withProviderDisconnected() would otherwise apply.
|
|
25
35
|
*/
|
|
26
|
-
export async function clearStaleProviderConfig(providerConfigStore, providerKeychainStore) {
|
|
36
|
+
export async function clearStaleProviderConfig(providerConfigStore, providerKeychainStore, providerOAuthTokenStore) {
|
|
27
37
|
try {
|
|
28
38
|
const config = await providerConfigStore.read();
|
|
29
39
|
const results = await Promise.all(Object.keys(config.providers).map(async (providerId) => ({
|
|
@@ -46,6 +56,11 @@ export async function clearStaleProviderConfig(providerConfigStore, providerKeyc
|
|
|
46
56
|
newConfig = newConfig.withActiveProvider('');
|
|
47
57
|
}
|
|
48
58
|
await providerConfigStore.write(newConfig);
|
|
59
|
+
// Clean up orphaned OAuth tokens for stale providers (consistent with
|
|
60
|
+
// the 3-store cleanup in TokenRefreshManager and ProviderHandler.setupDisconnect)
|
|
61
|
+
if (providerOAuthTokenStore) {
|
|
62
|
+
await Promise.all(staleProviderIds.map((id) => providerOAuthTokenStore.delete(id).catch(() => { })));
|
|
63
|
+
}
|
|
49
64
|
}
|
|
50
65
|
catch {
|
|
51
66
|
// Non-critical: if validation fails, daemon continues normally.
|
|
@@ -59,7 +74,7 @@ export async function clearStaleProviderConfig(providerConfigStore, providerKeyc
|
|
|
59
74
|
* the API key (keychain → env fallback), and maps provider-specific
|
|
60
75
|
* fields (base URL, headers, location, etc.).
|
|
61
76
|
*/
|
|
62
|
-
export async function resolveProviderConfig(providerConfigStore, providerKeychainStore) {
|
|
77
|
+
export async function resolveProviderConfig(providerConfigStore, providerKeychainStore, tokenRefreshManager) {
|
|
63
78
|
const config = await providerConfigStore.read();
|
|
64
79
|
const { activeProvider } = config;
|
|
65
80
|
const activeModel = config.getActiveModel(activeProvider);
|
|
@@ -96,16 +111,56 @@ export async function resolveProviderConfig(providerConfigStore, providerKeychai
|
|
|
96
111
|
}
|
|
97
112
|
default: {
|
|
98
113
|
const providerDef = getProviderById(activeProvider);
|
|
114
|
+
const providerConfig = config.providers[activeProvider];
|
|
115
|
+
if (!providerConfig) {
|
|
116
|
+
return { activeModel, activeProvider, maxInputTokens };
|
|
117
|
+
}
|
|
118
|
+
const { authMethod } = providerConfig;
|
|
119
|
+
// Attempt OAuth token refresh if provider is OAuth-connected
|
|
120
|
+
if (authMethod === 'oauth' && tokenRefreshManager) {
|
|
121
|
+
try {
|
|
122
|
+
const refreshed = await tokenRefreshManager.refreshIfNeeded(activeProvider);
|
|
123
|
+
if (!refreshed) {
|
|
124
|
+
return { activeModel, activeProvider, authMethod, maxInputTokens, providerKeyMissing: true };
|
|
125
|
+
}
|
|
126
|
+
// Re-read API key after potential refresh
|
|
127
|
+
apiKey = (await providerKeychainStore.getApiKey(activeProvider)) ?? apiKey;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { activeModel, activeProvider, authMethod, maxInputTokens, providerKeyMissing: true };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// OAuth-connected OpenAI: use Codex endpoint + required headers
|
|
134
|
+
if (activeProvider === 'openai' && authMethod === 'oauth') {
|
|
135
|
+
const codexHeaders = {
|
|
136
|
+
originator: CHATGPT_OAUTH_ORIGINATOR,
|
|
137
|
+
};
|
|
138
|
+
if (providerConfig.oauthAccountId) {
|
|
139
|
+
codexHeaders['ChatGPT-Account-Id'] = providerConfig.oauthAccountId;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
activeModel,
|
|
143
|
+
activeProvider,
|
|
144
|
+
authMethod,
|
|
145
|
+
maxInputTokens,
|
|
146
|
+
provider: activeProvider,
|
|
147
|
+
providerApiKey: apiKey || undefined,
|
|
148
|
+
providerBaseUrl: CHATGPT_OAUTH_BASE_URL,
|
|
149
|
+
providerHeaders: codexHeaders,
|
|
150
|
+
providerKeyMissing: providerRequiresApiKey(activeProvider, authMethod) && !apiKey,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
99
153
|
const headers = providerDef?.headers;
|
|
100
154
|
return {
|
|
101
155
|
activeModel,
|
|
102
156
|
activeProvider,
|
|
157
|
+
authMethod,
|
|
103
158
|
maxInputTokens,
|
|
104
159
|
provider: activeProvider,
|
|
105
160
|
providerApiKey: apiKey || undefined,
|
|
106
|
-
providerBaseUrl: providerDef?.baseUrl || undefined,
|
|
161
|
+
providerBaseUrl: config.getBaseUrl(activeProvider) || providerDef?.baseUrl || undefined,
|
|
107
162
|
providerHeaders: headers && Object.keys(headers).length > 0 ? { ...headers } : undefined,
|
|
108
|
-
providerKeyMissing: providerRequiresApiKey(activeProvider) && !apiKey,
|
|
163
|
+
providerKeyMissing: providerRequiresApiKey(activeProvider, authMethod) && !apiKey,
|
|
109
164
|
};
|
|
110
165
|
}
|
|
111
166
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ProviderCallbackResult } from './types.js';
|
|
2
|
+
type ProviderCallbackServerOptions = {
|
|
3
|
+
callbackPath?: string;
|
|
4
|
+
port: number;
|
|
5
|
+
};
|
|
6
|
+
export declare class ProviderCallbackServer {
|
|
7
|
+
private readonly callbackPath;
|
|
8
|
+
private readonly connections;
|
|
9
|
+
private isStopping;
|
|
10
|
+
private onCallback;
|
|
11
|
+
private onError;
|
|
12
|
+
private pendingTimeout;
|
|
13
|
+
private readonly port;
|
|
14
|
+
private server;
|
|
15
|
+
constructor(options: ProviderCallbackServerOptions);
|
|
16
|
+
getAddress(): undefined | {
|
|
17
|
+
port: number;
|
|
18
|
+
};
|
|
19
|
+
start(): Promise<number>;
|
|
20
|
+
stop(): Promise<void>;
|
|
21
|
+
waitForCallback(expectedState: string, timeoutMs?: number): Promise<ProviderCallbackResult>;
|
|
22
|
+
private handleRequest;
|
|
23
|
+
}
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { OAUTH_CALLBACK_TIMEOUT_MS } from '../../../shared/constants/oauth.js';
|
|
3
|
+
import { ProviderCallbackOAuthError, ProviderCallbackStateError, ProviderCallbackTimeoutError, ProviderOAuthError, } from './errors.js';
|
|
4
|
+
const DEFAULT_CALLBACK_PATH = '/auth/callback';
|
|
5
|
+
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
6
|
+
<html>
|
|
7
|
+
<head>
|
|
8
|
+
<title>ByteRover - Authorization Successful</title>
|
|
9
|
+
<style>
|
|
10
|
+
body { font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0f0f0f; color: #e5e5e5; }
|
|
11
|
+
.container { text-align: center; padding: 2rem; }
|
|
12
|
+
h1 { color: #17b26a; margin-bottom: 1rem; }
|
|
13
|
+
p { color: #a3a3a3; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<div class="container">
|
|
18
|
+
<h1>Authorization Successful</h1>
|
|
19
|
+
<p>You can close this window and return to ByteRover.</p>
|
|
20
|
+
</div>
|
|
21
|
+
<script>setTimeout(() => window.close(), 2000);</script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>`;
|
|
24
|
+
function errorHtml(message) {
|
|
25
|
+
return `<!DOCTYPE html>
|
|
26
|
+
<html>
|
|
27
|
+
<head>
|
|
28
|
+
<title>ByteRover - Authorization Failed</title>
|
|
29
|
+
<style>
|
|
30
|
+
body { font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0f0f0f; color: #e5e5e5; }
|
|
31
|
+
.container { text-align: center; padding: 2rem; }
|
|
32
|
+
h1 { color: #f87171; margin-bottom: 1rem; }
|
|
33
|
+
p { color: #a3a3a3; }
|
|
34
|
+
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<div class="container">
|
|
39
|
+
<h1>Authorization Failed</h1>
|
|
40
|
+
<p>An error occurred during authorization.</p>
|
|
41
|
+
<div class="error">${escapeHtml(message)}</div>
|
|
42
|
+
</div>
|
|
43
|
+
</body>
|
|
44
|
+
</html>`;
|
|
45
|
+
}
|
|
46
|
+
function escapeHtml(text) {
|
|
47
|
+
return text
|
|
48
|
+
.replaceAll('&', '&')
|
|
49
|
+
.replaceAll('<', '<')
|
|
50
|
+
.replaceAll('>', '>')
|
|
51
|
+
.replaceAll('"', '"')
|
|
52
|
+
.replaceAll("'", ''');
|
|
53
|
+
}
|
|
54
|
+
export class ProviderCallbackServer {
|
|
55
|
+
callbackPath;
|
|
56
|
+
connections = new Set();
|
|
57
|
+
isStopping = false;
|
|
58
|
+
onCallback;
|
|
59
|
+
onError;
|
|
60
|
+
pendingTimeout;
|
|
61
|
+
port;
|
|
62
|
+
server = undefined;
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.port = options.port;
|
|
65
|
+
this.callbackPath = options.callbackPath ?? DEFAULT_CALLBACK_PATH;
|
|
66
|
+
}
|
|
67
|
+
getAddress() {
|
|
68
|
+
const address = this.server?.address();
|
|
69
|
+
if (address !== null && address !== undefined && typeof address !== 'string') {
|
|
70
|
+
return { port: address.port };
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
async start() {
|
|
75
|
+
const address = this.getAddress();
|
|
76
|
+
if (address !== undefined) {
|
|
77
|
+
return address.port;
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
this.server = http.createServer((req, res) => {
|
|
81
|
+
this.handleRequest(req, res);
|
|
82
|
+
});
|
|
83
|
+
this.server.on('connection', (conn) => {
|
|
84
|
+
this.connections.add(conn);
|
|
85
|
+
conn.on('close', () => {
|
|
86
|
+
this.connections.delete(conn);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
this.server.on('error', reject);
|
|
90
|
+
this.server.listen(this.port, '127.0.0.1', () => {
|
|
91
|
+
const address = this.server?.address();
|
|
92
|
+
if (address !== null && address !== undefined && typeof address !== 'string') {
|
|
93
|
+
resolve(address.port);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
reject(new Error('Failed to start provider callback server'));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async stop() {
|
|
102
|
+
if (this.isStopping)
|
|
103
|
+
return;
|
|
104
|
+
this.isStopping = true;
|
|
105
|
+
// Reject any pending waitForCallback promise so consumers don't hang
|
|
106
|
+
this.onError?.(new ProviderOAuthError('Callback server was stopped'));
|
|
107
|
+
this.onCallback = undefined;
|
|
108
|
+
this.onError = undefined;
|
|
109
|
+
if (this.pendingTimeout !== undefined) {
|
|
110
|
+
clearTimeout(this.pendingTimeout);
|
|
111
|
+
this.pendingTimeout = undefined;
|
|
112
|
+
}
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
if (this.server === undefined) {
|
|
115
|
+
this.isStopping = false;
|
|
116
|
+
resolve();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (const conn of this.connections) {
|
|
120
|
+
conn.destroy();
|
|
121
|
+
}
|
|
122
|
+
this.connections.clear();
|
|
123
|
+
this.server.close(() => {
|
|
124
|
+
this.server = undefined;
|
|
125
|
+
this.isStopping = false;
|
|
126
|
+
resolve();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
waitForCallback(expectedState, timeoutMs = OAUTH_CALLBACK_TIMEOUT_MS) {
|
|
131
|
+
if (this.onCallback !== undefined) {
|
|
132
|
+
return Promise.reject(new ProviderOAuthError('A callback is already pending'));
|
|
133
|
+
}
|
|
134
|
+
const promise = new Promise((resolve, reject) => {
|
|
135
|
+
let settled = false;
|
|
136
|
+
this.pendingTimeout = setTimeout(() => {
|
|
137
|
+
if (!settled) {
|
|
138
|
+
settled = true;
|
|
139
|
+
this.pendingTimeout = undefined;
|
|
140
|
+
reject(new ProviderCallbackTimeoutError(timeoutMs));
|
|
141
|
+
}
|
|
142
|
+
}, timeoutMs);
|
|
143
|
+
this.onCallback = (code, state) => {
|
|
144
|
+
if (settled)
|
|
145
|
+
return;
|
|
146
|
+
clearTimeout(this.pendingTimeout);
|
|
147
|
+
this.pendingTimeout = undefined;
|
|
148
|
+
if (state !== expectedState) {
|
|
149
|
+
settled = true;
|
|
150
|
+
reject(new ProviderCallbackStateError());
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
settled = true;
|
|
154
|
+
resolve({ code, state });
|
|
155
|
+
};
|
|
156
|
+
this.onError = (error) => {
|
|
157
|
+
if (settled)
|
|
158
|
+
return;
|
|
159
|
+
clearTimeout(this.pendingTimeout);
|
|
160
|
+
this.pendingTimeout = undefined;
|
|
161
|
+
settled = true;
|
|
162
|
+
reject(error);
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
// Auto-close server after callback or timeout (ticket requirement)
|
|
166
|
+
const autoClose = () => this.stop();
|
|
167
|
+
promise.then(autoClose, autoClose);
|
|
168
|
+
return promise;
|
|
169
|
+
}
|
|
170
|
+
handleRequest(req, res) {
|
|
171
|
+
const url = new URL(req.url ?? '/', `http://localhost:${this.port}`);
|
|
172
|
+
if (url.pathname !== this.callbackPath) {
|
|
173
|
+
res.writeHead(404);
|
|
174
|
+
res.end('Not found');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const error = url.searchParams.get('error');
|
|
178
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
179
|
+
const code = url.searchParams.get('code');
|
|
180
|
+
const state = url.searchParams.get('state');
|
|
181
|
+
if (error !== null) {
|
|
182
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
183
|
+
res.end(errorHtml(errorDescription ?? error));
|
|
184
|
+
this.onError?.(new ProviderCallbackOAuthError(error, errorDescription ?? undefined));
|
|
185
|
+
this.onCallback = undefined;
|
|
186
|
+
this.onError = undefined;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (code === null || state === null) {
|
|
190
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
191
|
+
res.end(errorHtml('Missing code or state parameter'));
|
|
192
|
+
this.onError?.(new ProviderOAuthError('Missing code or state parameter'));
|
|
193
|
+
this.onCallback = undefined;
|
|
194
|
+
this.onError = undefined;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
198
|
+
res.end(SUCCESS_HTML);
|
|
199
|
+
this.onCallback?.(code, state);
|
|
200
|
+
this.onCallback = undefined;
|
|
201
|
+
this.onError = undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare class ProviderOAuthError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare class ProviderCallbackTimeoutError extends ProviderOAuthError {
|
|
5
|
+
readonly timeoutMs: number;
|
|
6
|
+
constructor(timeoutMs: number);
|
|
7
|
+
}
|
|
8
|
+
export declare class ProviderCallbackStateError extends ProviderOAuthError {
|
|
9
|
+
constructor();
|
|
10
|
+
}
|
|
11
|
+
export declare class ProviderCallbackOAuthError extends ProviderOAuthError {
|
|
12
|
+
readonly errorCode: string;
|
|
13
|
+
constructor(errorCode: string, errorDescription?: string);
|
|
14
|
+
}
|
|
15
|
+
export declare class ProviderTokenExchangeError extends ProviderOAuthError {
|
|
16
|
+
readonly errorCode?: string;
|
|
17
|
+
readonly statusCode?: number;
|
|
18
|
+
constructor(params: {
|
|
19
|
+
errorCode?: string;
|
|
20
|
+
message: string;
|
|
21
|
+
statusCode?: number;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extracts OAuth error fields from an unknown error response body.
|
|
26
|
+
* Shared by token-exchange and refresh-token-exchange.
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractOAuthErrorFields(data: unknown): {
|
|
29
|
+
error?: string;
|
|
30
|
+
error_description?: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Checks whether an OAuth token refresh error is permanent (token revoked, client invalid)
|
|
34
|
+
* vs. transient (network timeout, server error).
|
|
35
|
+
*
|
|
36
|
+
* Permanent errors require disconnecting the provider and re-authenticating.
|
|
37
|
+
* Transient errors should preserve credentials so the existing access token can still be used.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isPermanentOAuthError(error: unknown): boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export class ProviderOAuthError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'ProviderOAuthError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class ProviderCallbackTimeoutError extends ProviderOAuthError {
|
|
8
|
+
timeoutMs;
|
|
9
|
+
constructor(timeoutMs) {
|
|
10
|
+
super(`OAuth callback timed out after ${timeoutMs}ms`);
|
|
11
|
+
this.name = 'ProviderCallbackTimeoutError';
|
|
12
|
+
this.timeoutMs = timeoutMs;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class ProviderCallbackStateError extends ProviderOAuthError {
|
|
16
|
+
constructor() {
|
|
17
|
+
super('OAuth callback state mismatch — possible CSRF attack');
|
|
18
|
+
this.name = 'ProviderCallbackStateError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class ProviderCallbackOAuthError extends ProviderOAuthError {
|
|
22
|
+
errorCode;
|
|
23
|
+
constructor(errorCode, errorDescription) {
|
|
24
|
+
super(errorDescription ?? `OAuth provider returned error: ${errorCode}`);
|
|
25
|
+
this.name = 'ProviderCallbackOAuthError';
|
|
26
|
+
this.errorCode = errorCode;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class ProviderTokenExchangeError extends ProviderOAuthError {
|
|
30
|
+
errorCode;
|
|
31
|
+
statusCode;
|
|
32
|
+
constructor(params) {
|
|
33
|
+
super(params.message);
|
|
34
|
+
this.name = 'ProviderTokenExchangeError';
|
|
35
|
+
this.errorCode = params.errorCode;
|
|
36
|
+
this.statusCode = params.statusCode;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Extracts OAuth error fields from an unknown error response body.
|
|
41
|
+
* Shared by token-exchange and refresh-token-exchange.
|
|
42
|
+
*/
|
|
43
|
+
export function extractOAuthErrorFields(data) {
|
|
44
|
+
if (typeof data !== 'object' || data === null) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
error: 'error' in data && typeof data.error === 'string' ? data.error : undefined,
|
|
49
|
+
// eslint-disable-next-line camelcase
|
|
50
|
+
error_description: 'error_description' in data && typeof data.error_description === 'string' ? data.error_description : undefined,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Checks whether an OAuth token refresh error is permanent (token revoked, client invalid)
|
|
55
|
+
* vs. transient (network timeout, server error).
|
|
56
|
+
*
|
|
57
|
+
* Permanent errors require disconnecting the provider and re-authenticating.
|
|
58
|
+
* Transient errors should preserve credentials so the existing access token can still be used.
|
|
59
|
+
*/
|
|
60
|
+
export function isPermanentOAuthError(error) {
|
|
61
|
+
if (!(error instanceof ProviderTokenExchangeError)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
// 401/403 are unconditionally permanent (credentials rejected)
|
|
65
|
+
if (error.statusCode && [401, 403].includes(error.statusCode)) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
// 400 is only permanent when the OAuth error code explicitly indicates it.
|
|
69
|
+
// A 400 with an unknown or transient error code (e.g. temporarily_unavailable)
|
|
70
|
+
// should preserve credentials so the existing access token can still be used.
|
|
71
|
+
const permanentErrorCodes = new Set(['invalid_client', 'invalid_grant', 'unauthorized_client']);
|
|
72
|
+
if (error.errorCode && permanentErrorCodes.has(error.errorCode)) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './callback-server.js';
|
|
2
|
+
export * from './errors.js';
|
|
3
|
+
export * from './jwt-utils.js';
|
|
4
|
+
export * from './pkce-service.js';
|
|
5
|
+
export * from './provider-oauth-token-store.js';
|
|
6
|
+
export * from './refresh-token-exchange.js';
|
|
7
|
+
export * from './token-exchange.js';
|
|
8
|
+
export * from './token-refresh-manager.js';
|
|
9
|
+
export * from './types.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './callback-server.js';
|
|
2
|
+
export * from './errors.js';
|
|
3
|
+
export * from './jwt-utils.js';
|
|
4
|
+
export * from './pkce-service.js';
|
|
5
|
+
export * from './provider-oauth-token-store.js';
|
|
6
|
+
export * from './refresh-token-exchange.js';
|
|
7
|
+
export * from './token-exchange.js';
|
|
8
|
+
export * from './token-refresh-manager.js';
|
|
9
|
+
export * from './types.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT Utilities for Provider OAuth
|
|
3
|
+
*
|
|
4
|
+
* Parses provider-specific claims from OAuth id_tokens.
|
|
5
|
+
* Uses manual base64url decoding — no external JWT library needed.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Extracts the ChatGPT Account ID from an OpenAI id_token.
|
|
9
|
+
*
|
|
10
|
+
* Checks claims in priority order:
|
|
11
|
+
* 1. `chatgpt_account_id` (top-level claim)
|
|
12
|
+
* 2. `["https://api.openai.com/auth"].chatgpt_account_id` (nested claim)
|
|
13
|
+
* 3. `organizations[0].id` (fallback)
|
|
14
|
+
*
|
|
15
|
+
* @returns The account ID string, or undefined if not found or token is malformed
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseAccountIdFromIdToken(idToken: string): string | undefined;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT Utilities for Provider OAuth
|
|
3
|
+
*
|
|
4
|
+
* Parses provider-specific claims from OAuth id_tokens.
|
|
5
|
+
* Uses manual base64url decoding — no external JWT library needed.
|
|
6
|
+
*/
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Extracts the ChatGPT Account ID from an OpenAI id_token.
|
|
12
|
+
*
|
|
13
|
+
* Checks claims in priority order:
|
|
14
|
+
* 1. `chatgpt_account_id` (top-level claim)
|
|
15
|
+
* 2. `["https://api.openai.com/auth"].chatgpt_account_id` (nested claim)
|
|
16
|
+
* 3. `organizations[0].id` (fallback)
|
|
17
|
+
*
|
|
18
|
+
* @returns The account ID string, or undefined if not found or token is malformed
|
|
19
|
+
*/
|
|
20
|
+
export function parseAccountIdFromIdToken(idToken) {
|
|
21
|
+
try {
|
|
22
|
+
const parts = idToken.split('.');
|
|
23
|
+
if (parts.length < 2 || !parts[1])
|
|
24
|
+
return undefined;
|
|
25
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
26
|
+
if (!isRecord(payload))
|
|
27
|
+
return undefined;
|
|
28
|
+
// 1. Top-level claim
|
|
29
|
+
if (typeof payload.chatgpt_account_id === 'string' && payload.chatgpt_account_id) {
|
|
30
|
+
return payload.chatgpt_account_id;
|
|
31
|
+
}
|
|
32
|
+
// 2. Nested claim under OpenAI auth namespace
|
|
33
|
+
const authNamespace = payload['https://api.openai.com/auth'];
|
|
34
|
+
if (isRecord(authNamespace) &&
|
|
35
|
+
typeof authNamespace.chatgpt_account_id === 'string' &&
|
|
36
|
+
authNamespace.chatgpt_account_id) {
|
|
37
|
+
return authNamespace.chatgpt_account_id;
|
|
38
|
+
}
|
|
39
|
+
// 3. Organizations fallback
|
|
40
|
+
if (Array.isArray(payload.organizations) && payload.organizations.length > 0) {
|
|
41
|
+
const org = payload.organizations[0];
|
|
42
|
+
if (isRecord(org) && typeof org.id === 'string' && org.id) {
|
|
43
|
+
return org.id;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PkceParameters } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generates a cryptographically secure PKCE code verifier.
|
|
4
|
+
* Output is 43 characters (base64url encoding of 32 random bytes).
|
|
5
|
+
* Meets the OAuth 2.0 PKCE spec requirement of 43-128 characters.
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateCodeVerifier(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Generates S256 code challenge from a code verifier.
|
|
10
|
+
* SHA-256 hash of the verifier, base64url-encoded.
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateCodeChallenge(codeVerifier: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Generates a cryptographically secure state parameter for CSRF protection.
|
|
15
|
+
* Output is 22 characters (base64url encoding of 16 random bytes).
|
|
16
|
+
*/
|
|
17
|
+
export declare function generateState(): string;
|
|
18
|
+
/**
|
|
19
|
+
* Generates a complete set of PKCE parameters for an authorization request.
|
|
20
|
+
* Convenience function combining verifier, challenge, and state generation.
|
|
21
|
+
*/
|
|
22
|
+
export declare function generatePkce(): PkceParameters;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Generates a cryptographically secure PKCE code verifier.
|
|
4
|
+
* Output is 43 characters (base64url encoding of 32 random bytes).
|
|
5
|
+
* Meets the OAuth 2.0 PKCE spec requirement of 43-128 characters.
|
|
6
|
+
*/
|
|
7
|
+
export function generateCodeVerifier() {
|
|
8
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Generates S256 code challenge from a code verifier.
|
|
12
|
+
* SHA-256 hash of the verifier, base64url-encoded.
|
|
13
|
+
*/
|
|
14
|
+
export function generateCodeChallenge(codeVerifier) {
|
|
15
|
+
return crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Generates a cryptographically secure state parameter for CSRF protection.
|
|
19
|
+
* Output is 22 characters (base64url encoding of 16 random bytes).
|
|
20
|
+
*/
|
|
21
|
+
export function generateState() {
|
|
22
|
+
return crypto.randomBytes(16).toString('base64url');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Generates a complete set of PKCE parameters for an authorization request.
|
|
26
|
+
* Convenience function combining verifier, challenge, and state generation.
|
|
27
|
+
*/
|
|
28
|
+
export function generatePkce() {
|
|
29
|
+
const codeVerifier = generateCodeVerifier();
|
|
30
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
31
|
+
const state = generateState();
|
|
32
|
+
return { codeChallenge, codeVerifier, state };
|
|
33
|
+
}
|