@link-assistant/agent 0.6.0 → 0.6.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/package.json +1 -1
- package/src/auth/plugins.ts +217 -12
package/package.json
CHANGED
package/src/auth/plugins.ts
CHANGED
|
@@ -867,30 +867,125 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
|
867
867
|
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
868
868
|
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
|
|
869
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Get an available port for the OAuth callback server.
|
|
872
|
+
* Supports configurable port via OAUTH_CALLBACK_PORT or GOOGLE_OAUTH_CALLBACK_PORT
|
|
873
|
+
* environment variable. Falls back to automatic port discovery (port 0) if not configured.
|
|
874
|
+
*
|
|
875
|
+
* Based on Gemini CLI implementation:
|
|
876
|
+
* https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
|
|
877
|
+
*/
|
|
878
|
+
async function getGoogleOAuthPort(): Promise<number> {
|
|
879
|
+
// Check for environment variable override (useful for containers/firewalls)
|
|
880
|
+
// Support both OAUTH_CALLBACK_PORT (Gemini CLI style) and GOOGLE_OAUTH_CALLBACK_PORT
|
|
881
|
+
const portStr =
|
|
882
|
+
process.env['OAUTH_CALLBACK_PORT'] ||
|
|
883
|
+
process.env['GOOGLE_OAUTH_CALLBACK_PORT'];
|
|
884
|
+
if (portStr) {
|
|
885
|
+
const port = parseInt(portStr, 10);
|
|
886
|
+
if (!isNaN(port) && port > 0 && port <= 65535) {
|
|
887
|
+
log.info(() => ({
|
|
888
|
+
message: 'using configured oauth callback port',
|
|
889
|
+
port,
|
|
890
|
+
}));
|
|
891
|
+
return port;
|
|
892
|
+
}
|
|
893
|
+
log.warn(() => ({
|
|
894
|
+
message: 'invalid OAUTH_CALLBACK_PORT, using auto discovery',
|
|
895
|
+
value: portStr,
|
|
896
|
+
}));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Discover an available port by binding to port 0
|
|
900
|
+
return new Promise((resolve, reject) => {
|
|
901
|
+
const server = net.createServer();
|
|
902
|
+
server.listen(0, () => {
|
|
903
|
+
const address = server.address() as net.AddressInfo;
|
|
904
|
+
const port = address.port;
|
|
905
|
+
server.close(() => resolve(port));
|
|
906
|
+
});
|
|
907
|
+
server.on('error', reject);
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Check if browser launch should be suppressed.
|
|
913
|
+
* When NO_BROWSER=true, use manual code entry flow instead of localhost redirect.
|
|
914
|
+
*
|
|
915
|
+
* Based on Gemini CLI's config.isBrowserLaunchSuppressed() functionality.
|
|
916
|
+
*/
|
|
917
|
+
function isBrowserSuppressed(): boolean {
|
|
918
|
+
const noBrowser = process.env['NO_BROWSER'];
|
|
919
|
+
return noBrowser === 'true' || noBrowser === '1';
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Get the OAuth callback host for server binding.
|
|
924
|
+
* Defaults to 'localhost' but can be configured via OAUTH_CALLBACK_HOST.
|
|
925
|
+
* Use '0.0.0.0' in Docker containers to allow external connections.
|
|
926
|
+
*/
|
|
927
|
+
function getOAuthCallbackHost(): string {
|
|
928
|
+
return process.env['OAUTH_CALLBACK_HOST'] || 'localhost';
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Google Code Assist redirect URI for manual code entry flow
|
|
933
|
+
* This is used when NO_BROWSER=true or in headless environments
|
|
934
|
+
* Based on Gemini CLI implementation
|
|
935
|
+
*/
|
|
936
|
+
const GOOGLE_CODEASSIST_REDIRECT_URI = 'https://codeassist.google.com/authcode';
|
|
937
|
+
|
|
870
938
|
/**
|
|
871
939
|
* Google OAuth Plugin
|
|
872
940
|
* Supports:
|
|
873
|
-
* - Google AI Pro/Ultra OAuth login
|
|
941
|
+
* - Google AI Pro/Ultra OAuth login (browser mode with localhost redirect)
|
|
942
|
+
* - Google AI Pro/Ultra OAuth login (manual code entry for NO_BROWSER mode)
|
|
874
943
|
* - Manual API key entry
|
|
875
944
|
*
|
|
876
945
|
* Note: This plugin uses OAuth 2.0 with PKCE for Google AI subscription authentication.
|
|
877
946
|
* After authenticating, you can use Gemini models with subscription benefits.
|
|
947
|
+
*
|
|
948
|
+
* The OAuth flow supports two modes:
|
|
949
|
+
* 1. Browser mode (default): Opens browser, uses localhost redirect server
|
|
950
|
+
* 2. Manual code entry (NO_BROWSER=true): Shows URL, user pastes authorization code
|
|
951
|
+
*
|
|
952
|
+
* Based on Gemini CLI implementation:
|
|
953
|
+
* https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
|
|
878
954
|
*/
|
|
879
955
|
const GooglePlugin: AuthPlugin = {
|
|
880
956
|
provider: 'google',
|
|
881
957
|
methods: [
|
|
882
958
|
{
|
|
883
|
-
label: 'Google AI Pro/Ultra (OAuth)',
|
|
959
|
+
label: 'Google AI Pro/Ultra (OAuth - Browser)',
|
|
884
960
|
type: 'oauth',
|
|
885
961
|
async authorize() {
|
|
962
|
+
// Check if browser is suppressed - if so, recommend manual method
|
|
963
|
+
if (isBrowserSuppressed()) {
|
|
964
|
+
log.info(() => ({
|
|
965
|
+
message: 'NO_BROWSER is set, use manual code entry method instead',
|
|
966
|
+
}));
|
|
967
|
+
}
|
|
968
|
+
|
|
886
969
|
const pkce = await generatePKCE();
|
|
887
970
|
const state = generateRandomString(16);
|
|
888
971
|
|
|
889
|
-
//
|
|
972
|
+
// Get an available port BEFORE starting the server
|
|
973
|
+
// This fixes the race condition where port was 0 when building redirect URI
|
|
974
|
+
const serverPort = await getGoogleOAuthPort();
|
|
975
|
+
const host = getOAuthCallbackHost();
|
|
976
|
+
// The redirect URI sent to Google must use localhost (loopback IP)
|
|
977
|
+
// even if we bind to a different host (like 0.0.0.0 in Docker)
|
|
978
|
+
const redirectUri = `http://localhost:${serverPort}/oauth/callback`;
|
|
979
|
+
|
|
980
|
+
log.info(() => ({
|
|
981
|
+
message: 'starting google oauth server',
|
|
982
|
+
port: serverPort,
|
|
983
|
+
host,
|
|
984
|
+
redirectUri,
|
|
985
|
+
}));
|
|
986
|
+
|
|
987
|
+
// Create server to handle OAuth redirect
|
|
890
988
|
const server = http.createServer();
|
|
891
|
-
let serverPort = 0;
|
|
892
|
-
let authCode: string | null = null;
|
|
893
|
-
let authState: string | null = null;
|
|
894
989
|
|
|
895
990
|
const authPromise = new Promise<{ code: string; state: string }>(
|
|
896
991
|
(resolve, reject) => {
|
|
@@ -944,12 +1039,22 @@ const GooglePlugin: AuthPlugin = {
|
|
|
944
1039
|
res.end('Missing code or state parameter');
|
|
945
1040
|
});
|
|
946
1041
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1042
|
+
// Listen on the configured host and pre-determined port
|
|
1043
|
+
server.listen(serverPort, host, () => {
|
|
1044
|
+
log.info(() => ({
|
|
1045
|
+
message: 'google oauth server listening',
|
|
1046
|
+
port: serverPort,
|
|
1047
|
+
host,
|
|
1048
|
+
}));
|
|
950
1049
|
});
|
|
951
1050
|
|
|
952
|
-
server.on('error',
|
|
1051
|
+
server.on('error', (err) => {
|
|
1052
|
+
log.error(() => ({
|
|
1053
|
+
message: 'google oauth server error',
|
|
1054
|
+
error: err,
|
|
1055
|
+
}));
|
|
1056
|
+
reject(err);
|
|
1057
|
+
});
|
|
953
1058
|
|
|
954
1059
|
// Timeout after 5 minutes
|
|
955
1060
|
setTimeout(
|
|
@@ -962,8 +1067,7 @@ const GooglePlugin: AuthPlugin = {
|
|
|
962
1067
|
}
|
|
963
1068
|
);
|
|
964
1069
|
|
|
965
|
-
// Build authorization URL with
|
|
966
|
-
const redirectUri = `http://localhost:${serverPort}/oauth/callback`;
|
|
1070
|
+
// Build authorization URL with the redirect URI
|
|
967
1071
|
const url = new URL(GOOGLE_AUTH_URL);
|
|
968
1072
|
url.searchParams.set('client_id', GOOGLE_OAUTH_CLIENT_ID);
|
|
969
1073
|
url.searchParams.set('redirect_uri', redirectUri);
|
|
@@ -1034,6 +1138,107 @@ const GooglePlugin: AuthPlugin = {
|
|
|
1034
1138
|
};
|
|
1035
1139
|
},
|
|
1036
1140
|
},
|
|
1141
|
+
{
|
|
1142
|
+
label: 'Google AI Pro/Ultra (OAuth - Manual Code Entry)',
|
|
1143
|
+
type: 'oauth',
|
|
1144
|
+
async authorize() {
|
|
1145
|
+
/**
|
|
1146
|
+
* Manual code entry flow for headless environments or when NO_BROWSER=true
|
|
1147
|
+
* Uses Google's Code Assist redirect URI which displays the auth code to the user
|
|
1148
|
+
*
|
|
1149
|
+
* Based on Gemini CLI's authWithUserCode function:
|
|
1150
|
+
* https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
|
|
1151
|
+
*/
|
|
1152
|
+
const pkce = await generatePKCE();
|
|
1153
|
+
const state = generateRandomString(16);
|
|
1154
|
+
const redirectUri = GOOGLE_CODEASSIST_REDIRECT_URI;
|
|
1155
|
+
|
|
1156
|
+
log.info(() => ({
|
|
1157
|
+
message: 'using manual code entry oauth flow',
|
|
1158
|
+
redirectUri,
|
|
1159
|
+
}));
|
|
1160
|
+
|
|
1161
|
+
// Build authorization URL with the Code Assist redirect URI
|
|
1162
|
+
const url = new URL(GOOGLE_AUTH_URL);
|
|
1163
|
+
url.searchParams.set('client_id', GOOGLE_OAUTH_CLIENT_ID);
|
|
1164
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
1165
|
+
url.searchParams.set('response_type', 'code');
|
|
1166
|
+
url.searchParams.set('scope', GOOGLE_OAUTH_SCOPES.join(' '));
|
|
1167
|
+
url.searchParams.set('access_type', 'offline');
|
|
1168
|
+
url.searchParams.set('code_challenge', pkce.challenge);
|
|
1169
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
1170
|
+
url.searchParams.set('state', state);
|
|
1171
|
+
url.searchParams.set('prompt', 'consent');
|
|
1172
|
+
|
|
1173
|
+
return {
|
|
1174
|
+
url: url.toString(),
|
|
1175
|
+
instructions:
|
|
1176
|
+
'Visit the URL above, complete authorization, then paste the authorization code here: ',
|
|
1177
|
+
method: 'code' as const,
|
|
1178
|
+
async callback(code?: string): Promise<AuthResult> {
|
|
1179
|
+
if (!code) {
|
|
1180
|
+
log.error(() => ({
|
|
1181
|
+
message: 'google oauth no code provided',
|
|
1182
|
+
}));
|
|
1183
|
+
return { type: 'failed' };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
try {
|
|
1187
|
+
// Exchange authorization code for tokens
|
|
1188
|
+
const tokenResult = await fetch(GOOGLE_TOKEN_URL, {
|
|
1189
|
+
method: 'POST',
|
|
1190
|
+
headers: {
|
|
1191
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1192
|
+
},
|
|
1193
|
+
body: new URLSearchParams({
|
|
1194
|
+
code: code.trim(),
|
|
1195
|
+
client_id: GOOGLE_OAUTH_CLIENT_ID,
|
|
1196
|
+
client_secret: GOOGLE_OAUTH_CLIENT_SECRET,
|
|
1197
|
+
redirect_uri: redirectUri,
|
|
1198
|
+
grant_type: 'authorization_code',
|
|
1199
|
+
code_verifier: pkce.verifier,
|
|
1200
|
+
}),
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
if (!tokenResult.ok) {
|
|
1204
|
+
const errorText = await tokenResult.text();
|
|
1205
|
+
log.error(() => ({
|
|
1206
|
+
message: 'google oauth token exchange failed',
|
|
1207
|
+
status: tokenResult.status,
|
|
1208
|
+
error: errorText,
|
|
1209
|
+
}));
|
|
1210
|
+
return { type: 'failed' };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const json = await tokenResult.json();
|
|
1214
|
+
if (
|
|
1215
|
+
!json.access_token ||
|
|
1216
|
+
!json.refresh_token ||
|
|
1217
|
+
typeof json.expires_in !== 'number'
|
|
1218
|
+
) {
|
|
1219
|
+
log.error(() => ({
|
|
1220
|
+
message: 'google oauth token response missing fields',
|
|
1221
|
+
}));
|
|
1222
|
+
return { type: 'failed' };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
return {
|
|
1226
|
+
type: 'success',
|
|
1227
|
+
refresh: json.refresh_token,
|
|
1228
|
+
access: json.access_token,
|
|
1229
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
1230
|
+
};
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
log.error(() => ({
|
|
1233
|
+
message: 'google oauth manual code entry failed',
|
|
1234
|
+
error,
|
|
1235
|
+
}));
|
|
1236
|
+
return { type: 'failed' };
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
};
|
|
1240
|
+
},
|
|
1241
|
+
},
|
|
1037
1242
|
{
|
|
1038
1243
|
label: 'Manually enter API Key',
|
|
1039
1244
|
type: 'api',
|