Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 63 additions & 18 deletions workspace-server/src/auth/AuthManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Comment on lines +49 to +53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The validation for the credentials JSON is basic. It only checks for the existence of refresh_token and access_token but doesn't validate their types or ensure they are non-empty. Since the project already uses zod, 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.

Suggested change
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');
}
const credentialsSchema = z.object({
refresh_token: z.string().min(1, { message: 'refresh_token cannot be empty' }),
access_token: z.string().min(1, { message: 'access_token cannot be empty' }),
scope: z.string().optional(),
token_type: z.string().optional(),
expiry_date: z.number().optional(),
});
const tokens = credentialsSchema.parse(JSON.parse(jsonStr));


this.client = new google.auth.OAuth2(CLIENT_ID);
this.client.setCredentials(tokens);
await OAuthCredentialStorage.saveCredentials(tokens);
Comment on lines +55 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The new OAuth2Client created here does not have the 'tokens' event listener attached. This listener, configured in getAuthenticatedClient, is crucial for persisting auto-refreshed tokens. Without it, if an access token expires and is auto-refreshed by the library, the new token won't be saved, leading to authentication failures on subsequent runs.

A better approach is to save the credentials and then invalidate the cached client. This ensures that the next call to getAuthenticatedClient will create a new, properly configured client with the listener attached, using the credentials just saved.

      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;
}
Expand Down Expand Up @@ -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) => {
Expand Down
52 changes: 52 additions & 0 deletions workspace-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The call to authManager.saveCredentialsFromJson is not wrapped in a try...catch block. If the user provides invalid JSON, saveCredentialsFromJson will throw an error, which will likely result in a generic server error response. To improve user experience, it would be better to catch this specific error and return a user-friendly message explaining that the provided JSON was invalid.

      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',
{
Expand Down
4 changes: 2 additions & 2 deletions workspace-server/src/utils/open-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const createMockChildProcess = () => ({
const openWrapper = async (url: string): Promise<any> => {
// Check if we should launch the browser
if (!shouldLaunchBrowser()) {
console.log(
console.error(
`Browser launch not supported. Please open this URL in your browser: ${url}`,
);
return createMockChildProcess();
Expand All @@ -47,7 +47,7 @@ const openWrapper = async (url: string): Promise<any> => {
await openBrowserSecurely(url);
return createMockChildProcess();
} catch {
console.log(
console.error(
`Failed to open browser. Please open this URL in your browser: ${url}`,
);
return createMockChildProcess();
Expand Down