All files / packages/gemini-core/src/mcp/token-storage keychain-token-storage.ts

0% Statements 0/114
0% Branches 0/26
0% Functions 0/18
0% Lines 0/112

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     
/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
 
import * as crypto from 'node:crypto';
import { BaseTokenStorage } from './base-token-storage';
import type { OAuthCredentials } from './types';
 
interface Keytar {
  getPassword(service: string, account: string): Promise<string | null>;
  setPassword(
    service: string,
    account: string,
    password: string,
  ): Promise<void>;
  deletePassword(service: string, account: string): Promise<boolean>;
  findCredentials(
    service: string,
  ): Promise<Array<{ account: string; password: string }>>;
}
 
const KEYCHAIN_TEST_PREFIX = '__keychain_test__';
 
export class KeychainTokenStorage extends BaseTokenStorage {
  private keychainAvailable: boolean | null = null;
  private keytarModule: Keytar | null = null;
  private keytarLoadAttempted = false;
 
  async getKeytar(): Promise<Keytar | null> {
    // If we've already tried loading (successfully or not), return the result
    Iif (this.keytarLoadAttempted) {
      return this.keytarModule;
    }
 
    this.keytarLoadAttempted = true;
 
    try {
      // Try to import keytar without any timeout - let the OS handle it
      const moduleName = 'keytar';
      const module = await import(moduleName);
      this.keytarModule = module.default || module;
    } catch (error) {
      console.error(error);
    }
    return this.keytarModule;
  }
 
  async getCredentials(serverName: string): Promise<OAuthCredentials | null> {
    Iif (!(await this.checkKeychainAvailability())) {
      throw new Error('Keychain is not available');
    }
 
    const keytar = await this.getKeytar();
    Iif (!keytar) {
      throw new Error('Keytar module not available');
    }
 
    try {
      const sanitizedName = this.sanitizeServerName(serverName);
      const data = await keytar.getPassword(this.serviceName, sanitizedName);
 
      Iif (!data) {
        return null;
      }
 
      const credentials = JSON.parse(data) as OAuthCredentials;
 
      Iif (this.isTokenExpired(credentials)) {
        return null;
      }
 
      return credentials;
    } catch (error) {
      Iif (error instanceof SyntaxError) {
        throw new Error(`Failed to parse stored credentials for ${serverName}`);
      }
      throw error;
    }
  }
 
  async setCredentials(credentials: OAuthCredentials): Promise<void> {
    Iif (!(await this.checkKeychainAvailability())) {
      throw new Error('Keychain is not available');
    }
 
    const keytar = await this.getKeytar();
    Iif (!keytar) {
      throw new Error('Keytar module not available');
    }
 
    this.validateCredentials(credentials);
 
    const sanitizedName = this.sanitizeServerName(credentials.serverName);
    const updatedCredentials: OAuthCredentials = {
      ...credentials,
      updatedAt: Date.now(),
    };
 
    const data = JSON.stringify(updatedCredentials);
    await keytar.setPassword(this.serviceName, sanitizedName, data);
  }
 
  async deleteCredentials(serverName: string): Promise<void> {
    Iif (!(await this.checkKeychainAvailability())) {
      throw new Error('Keychain is not available');
    }
 
    const keytar = await this.getKeytar();
    Iif (!keytar) {
      throw new Error('Keytar module not available');
    }
 
    const sanitizedName = this.sanitizeServerName(serverName);
    const deleted = await keytar.deletePassword(
      this.serviceName,
      sanitizedName,
    );
 
    Iif (!deleted) {
      throw new Error(`No credentials found for ${serverName}`);
    }
  }
 
  async listServers(): Promise<string[]> {
    Iif (!(await this.checkKeychainAvailability())) {
      throw new Error('Keychain is not available');
    }
 
    const keytar = await this.getKeytar();
    Iif (!keytar) {
      throw new Error('Keytar module not available');
    }
 
    try {
      const credentials = await keytar.findCredentials(this.serviceName);
      return credentials
        .filter((cred) => !cred.account.startsWith(KEYCHAIN_TEST_PREFIX))
        .map((cred: { account: string }) => cred.account);
    } catch (error) {
      console.error('Failed to list servers from keychain:', error);
      return [];
    }
  }
 
  async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {
    Iif (!(await this.checkKeychainAvailability())) {
      throw new Error('Keychain is not available');
    }
 
    const keytar = await this.getKeytar();
    Iif (!keytar) {
      throw new Error('Keytar module not available');
    }
 
    const result = new Map<string, OAuthCredentials>();
    try {
      const credentials = (
        await keytar.findCredentials(this.serviceName)
      ).filter((c) => !c.account.startsWith(KEYCHAIN_TEST_PREFIX));
 
      for (const cred of credentials) {
        try {
          const data = JSON.parse(cred.password) as OAuthCredentials;
          Iif (!this.isTokenExpired(data)) {
            result.set(cred.account, data);
          }
        } catch (error) {
          console.error(
            `Failed to parse credentials for ${cred.account}:`,
            error,
          );
        }
      }
    } catch (error) {
      console.error('Failed to get all credentials from keychain:', error);
    }
 
    return result;
  }
 
  async clearAll(): Promise<void> {
    Iif (!(await this.checkKeychainAvailability())) {
      throw new Error('Keychain is not available');
    }
 
    const servers = this.keytarModule
      ? await this.keytarModule
          .findCredentials(this.serviceName)
          .then((creds) => creds.map((c) => c.account))
          .catch((error: Error) => {
            throw new Error(
              `Failed to list servers for clearing: ${error.message}`,
            );
          })
      : [];
    const errors: Error[] = [];
 
    for (const server of servers) {
      try {
        await this.deleteCredentials(server);
      } catch (error) {
        errors.push(error as Error);
      }
    }
 
    Iif (errors.length > 0) {
      throw new Error(
        `Failed to clear some credentials: ${errors.map((e) => e.message).join(', ')}`,
      );
    }
  }
 
  // Checks whether or not a set-get-delete cycle with the keychain works.
  // Returns false if any operation fails.
  async checkKeychainAvailability(): Promise<boolean> {
    Iif (this.keychainAvailable !== null) {
      return this.keychainAvailable;
    }
 
    try {
      const keytar = await this.getKeytar();
      Iif (!keytar) {
        this.keychainAvailable = false;
        return false;
      }
 
      const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;
      const testPassword = 'test';
 
      await keytar.setPassword(this.serviceName, testAccount, testPassword);
      const retrieved = await keytar.getPassword(this.serviceName, testAccount);
      const deleted = await keytar.deletePassword(
        this.serviceName,
        testAccount,
      );
 
      const success = deleted && retrieved === testPassword;
      this.keychainAvailable = success;
      return success;
    } catch (_error) {
      this.keychainAvailable = false;
      return false;
    }
  }
 
  async isAvailable(): Promise<boolean> {
    return this.checkKeychainAvailability();
  }
}