-
Notifications
You must be signed in to change notification settings - Fork 49
Allowing command line authentication with JSON copy-and-paste #210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,34 @@ export class AuthManager { | |
| this.scopes = scopes; | ||
| } | ||
|
|
||
| public async isAuthenticated(): Promise<boolean> { | ||
| const credentials = await OAuthCredentialStorage.loadCredentials(); | ||
| return !!(credentials && credentials.refresh_token); | ||
| } | ||
|
|
||
| public async saveCredentialsFromJson(jsonStr: string): Promise<void> { | ||
| try { | ||
| const tokens = JSON.parse(jsonStr); | ||
| // Validate input has required fields | ||
| if (!tokens.refresh_token || !tokens.access_token) { | ||
| throw new Error('Invalid credentials JSON: missing required fields'); | ||
| } | ||
|
|
||
| this.client = new google.auth.OAuth2(CLIENT_ID); | ||
| this.client.setCredentials(tokens); | ||
| await OAuthCredentialStorage.saveCredentials(tokens); | ||
|
Comment on lines
+55
to
+57
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new A better approach is to save the credentials and then invalidate the cached client. This ensures that the next call to await OAuthCredentialStorage.saveCredentials(tokens);
// Invalidate the cached client. The next call to getAuthenticatedClient()
// will create a new client with the new credentials and the 'tokens' listener.
this.client = null; |
||
| logToFile('Credentials saved manually from JSON'); | ||
| } catch (e) { | ||
| logToFile(`Error saving credentials from JSON: ${e}`); | ||
| throw e; | ||
| } | ||
| } | ||
|
|
||
| public async getAuthUrl(): Promise<string> { | ||
| const client = new google.auth.OAuth2(CLIENT_ID); | ||
| return this.generateAuthUrl(client, true); // true = manual mode | ||
| } | ||
|
|
||
| public setOnStatusUpdate(callback: (message: string) => void) { | ||
| this.onStatusUpdate = callback; | ||
| } | ||
|
|
@@ -294,40 +322,57 @@ export class AuthManager { | |
| }); | ||
| } | ||
|
|
||
| private async authWithWeb(client: Auth.OAuth2Client): Promise<OauthWebLogin> { | ||
| logToFile( | ||
| `Requesting authentication with scopes: ${this.scopes.join(', ')}`, | ||
| ); | ||
|
|
||
| const port = await this.getAvailablePort(); | ||
| const host = process.env['OAUTH_CALLBACK_HOST'] || 'localhost'; | ||
|
|
||
| const localRedirectUri = `http://${host}:${port}/oauth2callback`; | ||
|
|
||
| const isGuiAvailable = shouldLaunchBrowser(); | ||
|
|
||
| // SECURITY: Generate a random token for CSRF protection. | ||
| const csrfToken = crypto.randomBytes(32).toString('hex'); | ||
| private generateAuthUrl( | ||
| client: Auth.OAuth2Client, | ||
| manual: boolean, | ||
| localRedirectUri?: string, | ||
| csrfToken?: string, | ||
| ): string { | ||
| // Generate a new CSRF token if not provided | ||
| const token = csrfToken || crypto.randomBytes(32).toString('hex'); | ||
|
|
||
| // The state now contains a JSON payload indicating the flow mode and CSRF token. | ||
| const statePayload = { | ||
| uri: isGuiAvailable ? localRedirectUri : undefined, | ||
| manual: !isGuiAvailable, | ||
| csrf: csrfToken, | ||
| uri: localRedirectUri, | ||
| manual: manual, | ||
| csrf: token, | ||
| }; | ||
| const state = Buffer.from(JSON.stringify(statePayload)).toString('base64'); | ||
|
|
||
| // The redirect URI for Google's auth server is the cloud function | ||
| const cloudFunctionRedirectUri = | ||
| 'https://google-workspace-extension.geminicli.com'; | ||
|
|
||
| const authUrl = client.generateAuthUrl({ | ||
| return client.generateAuthUrl({ | ||
| redirect_uri: cloudFunctionRedirectUri, // Tell Google to go to the cloud function | ||
| access_type: 'offline', | ||
| scope: this.scopes, | ||
| state: state, // Pass our JSON payload in the state | ||
| prompt: 'consent', // Make sure we get a refresh token | ||
| }); | ||
| } | ||
|
|
||
| private async authWithWeb(client: Auth.OAuth2Client): Promise<OauthWebLogin> { | ||
| logToFile( | ||
| `Requesting authentication with scopes: ${this.scopes.join(', ')}`, | ||
| ); | ||
|
|
||
| const port = await this.getAvailablePort(); | ||
| const host = process.env['OAUTH_CALLBACK_HOST'] || 'localhost'; | ||
|
|
||
| const localRedirectUri = `http://${host}:${port}/oauth2callback`; | ||
|
|
||
| const isGuiAvailable = shouldLaunchBrowser(); | ||
|
|
||
| // SECURITY: Generate a random token for CSRF protection. | ||
| const csrfToken = crypto.randomBytes(32).toString('hex'); | ||
|
|
||
| const authUrl = this.generateAuthUrl( | ||
| client, | ||
| !isGuiAvailable, | ||
| isGuiAvailable ? localRedirectUri : undefined, | ||
| csrfToken, | ||
| ); | ||
|
|
||
| const loginCompletePromise = new Promise<void>((resolve, reject) => { | ||
| const server = http.createServer(async (req, res) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -143,6 +143,58 @@ async function main() { | |
| }, | ||
| ); | ||
|
|
||
| server.registerTool( | ||
| 'auth.login', | ||
| { | ||
| description: 'Initiates or completes the authentication flow.', | ||
| inputSchema: { | ||
| credentialsJson: z | ||
| .string() | ||
| .optional() | ||
| .describe( | ||
| 'The credentials JSON string pasted from the browser authentication page (if completing the flow).', | ||
| ), | ||
| }, | ||
| }, | ||
| async (input) => { | ||
| if (input.credentialsJson) { | ||
| // Complete the flow | ||
| await authManager.saveCredentialsFromJson(input.credentialsJson); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: 'Authentication successful! Credentials saved.', | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
|
Comment on lines
+160
to
+171
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The call to if (input.credentialsJson) {
try {
// Complete the flow
await authManager.saveCredentialsFromJson(input.credentialsJson);
return {
content: [
{
type: 'text',
text: 'Authentication successful! Credentials saved.',
},
],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Invalid JSON provided.';
return {
content: [
{
type: 'text',
text: `Authentication failed: ${errorMessage}`,
},
],
};
}
} |
||
|
|
||
| // Check if already authenticated | ||
| if (await authManager.isAuthenticated()) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: 'Already authenticated.', | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
|
|
||
| // Initiate the flow | ||
| const authUrl = await authManager.getAuthUrl(); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: `To authenticate, please visit this URL:\n${authUrl}\n\nAfter authenticating, copy the JSON credentials and run this tool again with the 'credentialsJson' argument.`, | ||
| }, | ||
| ], | ||
| }; | ||
| }, | ||
| ); | ||
|
|
||
| server.registerTool( | ||
| 'docs.create', | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The validation for the credentials JSON is basic. It only checks for the existence of
refresh_tokenandaccess_tokenbut doesn't validate their types or ensure they are non-empty. Since the project already useszod, it would be more robust and consistent to define a schema for the credentials and parse the JSON against it. This would provide stronger type safety and clearer error messages for invalid input.You'll need to add
import { z } from 'zod';at the top of the file.