diff --git a/package-lock.json b/package-lock.json index 5a9c6573..92a1ffa1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@formatjs/intl": "^3.1.6", "@modelcontextprotocol/sdk": "^1.12.1", + "@napi-rs/keyring": "^1.2.0", "chalk": "^5.4.1", "chokidar": "^4.0.3", "cli-truncate": "^4.0.0", @@ -875,6 +876,240 @@ "node": ">=18" } }, + "node_modules/@napi-rs/keyring": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.2.0.tgz", + "integrity": "sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.2.0", + "@napi-rs/keyring-darwin-x64": "1.2.0", + "@napi-rs/keyring-freebsd-x64": "1.2.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.2.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.2.0", + "@napi-rs/keyring-linux-arm64-musl": "1.2.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.2.0", + "@napi-rs/keyring-linux-x64-gnu": "1.2.0", + "@napi-rs/keyring-linux-x64-musl": "1.2.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.2.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.2.0", + "@napi-rs/keyring-win32-x64-msvc": "1.2.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.2.0.tgz", + "integrity": "sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.2.0.tgz", + "integrity": "sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.2.0.tgz", + "integrity": "sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.2.0.tgz", + "integrity": "sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@open-draft/deferred-promise": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", diff --git a/package.json b/package.json index 42c8f3e4..3284171c 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "dependencies": { "@formatjs/intl": "^3.1.6", "@modelcontextprotocol/sdk": "^1.12.1", + "@napi-rs/keyring": "^1.2.0", "chalk": "^5.4.1", "chokidar": "^4.0.3", "cli-truncate": "^4.0.0", diff --git a/src/auth/auth.ts b/src/auth/auth.ts index c806729b..d0a7ef31 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -24,6 +24,7 @@ import {google} from 'googleapis'; import {AuthorizationCodeFlow} from './auth_code_flow.js'; import {CredentialStore} from './credential_store.js'; import {FileCredentialStore} from './file_credential_store.js'; +import {KeyringCredentialStore} from './keyring_credential_store.js'; import {LocalServerAuthorizationCodeFlow} from './localhost_auth_code_flow.js'; import {DEFAULT_CLASP_OAUTH_CLIENT_ID} from './oauth_client.js'; import {ServerlessAuthorizationCodeFlow} from './serverless_auth_code_flow.js'; @@ -34,6 +35,7 @@ type InitOptions = { authFilePath?: string; userKey?: string; useApplicationDefaultCredentials?: boolean; + useKeyring?: boolean; }; /** @@ -41,11 +43,13 @@ type InitOptions = { * @property {OAuth2Client} [credentials] - The authorized OAuth2 client, if logged in. * @property {CredentialStore} [credentialStore] - The store used for loading/saving credentials. * @property {string} user - The identifier for the current user (e.g., 'default' or a custom key). + * @property {string} [authFilePath] - The path to the file store. */ export type AuthInfo = { credentials?: OAuth2Client; credentialStore?: CredentialStore; user: string; + authFilePath?: string; }; /** @@ -58,7 +62,19 @@ export type AuthInfo = { */ export async function initAuth(options: InitOptions): Promise { const authFilePath = options.authFilePath ?? path.join(os.homedir(), '.clasprc.json'); - const credentialStore = new FileCredentialStore(authFilePath); + const fileStore = new FileCredentialStore(authFilePath); + let credentialStore: CredentialStore = fileStore; + + const userKey = options.userKey ?? 'default'; + + const fileCreds = await fileStore.load(userKey); + + if (options.useKeyring || fileCreds?.is_keyring) { + debug('Using keyring store for user %s', userKey); + credentialStore = new KeyringCredentialStore(); + // If they specified --use-keyring but the stub doesn't exist yet, we don't write it here. + // It will be written in the login/import process. + } debug('Initializing auth from %s', options.authFilePath); if (options.useApplicationDefaultCredentials) { @@ -66,15 +82,17 @@ export async function initAuth(options: InitOptions): Promise { return { credentials, credentialStore, - user: options.userKey ?? 'default', + user: userKey, + authFilePath, }; } - const credentials = await getAuthorizedOAuth2Client(credentialStore, options.userKey); + const credentials = await getAuthorizedOAuth2Client(credentialStore, userKey); return { credentials, credentialStore, - user: options.userKey ?? 'default', + user: userKey, + authFilePath, }; } diff --git a/src/auth/credential_store.ts b/src/auth/credential_store.ts index b75392ca..bc247916 100644 --- a/src/auth/credential_store.ts +++ b/src/auth/credential_store.ts @@ -28,8 +28,9 @@ import {Credentials, JWTInput} from 'google-auth-library'; * @property {number} [expiry_date] - The expiry date of the access token in milliseconds. * @property {string} [type] - The type of credential, e.g., 'authorized_user'. * @property {string} [id_token] - The ID token (often same as access_token for clasp's use). + * @property {boolean} [is_keyring] - Flag indicating whether the credentials are in the keyring. */ -export type StoredCredential = JWTInput & Credentials; +export type StoredCredential = JWTInput & Credentials & {is_keyring?: boolean}; /** * Defines the contract for a credential storage mechanism. @@ -63,4 +64,10 @@ export interface CredentialStore { * @returns {Promise} The stored credentials, or null if not found. */ load(user: string): Promise; + + /** + * Lists all users that have credentials in the store. + * @returns {Promise} A list of user identifiers. + */ + listUsers(): Promise; } diff --git a/src/auth/file_credential_store.ts b/src/auth/file_credential_store.ts index 5c0f1425..34c7c506 100644 --- a/src/auth/file_credential_store.ts +++ b/src/auth/file_credential_store.ts @@ -131,6 +131,18 @@ export class FileCredentialStore implements CredentialStore { * @param {string} user - The identifier for the user. * @returns {Promise} The stored credentials if found, otherwise null. */ + async listUsers(): Promise { + const store: FileContents = this.readFile(); + const users = new Set(Object.keys(store.tokens || {})); + + // Check for V1 legacy credentials under the default user + if (!users.has('default') && (hasLegacyLocalCredentials(store) || hasLegacyGlobalCredentials(store))) { + users.add('default'); + } + + return Array.from(users); + } + async load(user: string): Promise { const store: FileContents = this.readFile(); const credentials = store.tokens?.[user] as StoredCredential; diff --git a/src/auth/keyring_credential_store.ts b/src/auth/keyring_credential_store.ts new file mode 100644 index 00000000..6a863aec --- /dev/null +++ b/src/auth/keyring_credential_store.ts @@ -0,0 +1,102 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {AsyncEntry, findCredentialsAsync} from '@napi-rs/keyring'; +import {CredentialStore, StoredCredential} from './credential_store.js'; + +const SERVICE_NAME = 'clasp'; + +/** + * Implements the `CredentialStore` interface using the system keyring. + * This class handles saving, loading, and deleting OAuth 2.0 credentials + * using the `@napi-rs/keyring` package. + */ +export class KeyringCredentialStore implements CredentialStore { + /** + * Saves credentials for a given user in the system keyring. + * If credentials are provided as undefined, it effectively removes the user's credentials. + * @param {string} user - The identifier for the user. + * @param {StoredCredential | undefined} credentials - The credentials to save, or undefined to clear. + * @returns {Promise} + */ + async save(user: string, credentials?: StoredCredential): Promise { + const entry = new AsyncEntry(SERVICE_NAME, user); + if (credentials) { + await entry.setPassword(JSON.stringify(credentials)); + } else { + try { + await entry.deletePassword(); + } catch (e: any) { + // Ignore NoEntry errors when deleting + } + } + } + + /** + * Deletes credentials for a specific user from the system keyring. + * @param {string} user - The identifier for the user whose credentials are to be deleted. + * @returns {Promise} + */ + async delete(user: string): Promise { + const entry = new AsyncEntry(SERVICE_NAME, user); + try { + await entry.deletePassword(); + } catch (e: any) { + // Ignore NoEntry errors when deleting + } + } + + /** + * Deletes all stored credentials for the clasp service from the system keyring. + * @returns {Promise} + */ + async deleteAll(): Promise { + const credentials = await findCredentialsAsync(SERVICE_NAME); + for (const cred of credentials) { + const entry = new AsyncEntry(SERVICE_NAME, cred.account); + try { + await entry.deletePassword(); + } catch (e: any) { + // Ignore NoEntry errors + } + } + } + + /** + * Lists all users that have credentials stored in the system keyring for clasp. + * @returns {Promise} A list of user identifiers. + */ + async listUsers(): Promise { + const credentials = await findCredentialsAsync(SERVICE_NAME); + return credentials.map((cred: any) => cred.account); + } + + /** + * Loads credentials for a given user from the system keyring. + * @param {string} user - The identifier for the user. + * @returns {Promise} The stored credentials if found, otherwise null. + */ + async load(user: string): Promise { + const entry = new AsyncEntry(SERVICE_NAME, user); + try { + const password = await entry.getPassword(); + if (password) { + return JSON.parse(password) as StoredCredential; + } + return null; + } catch (e: any) { + return null; + } + } +} diff --git a/src/commands/export-credentials.ts b/src/commands/export-credentials.ts new file mode 100644 index 00000000..90146c6f --- /dev/null +++ b/src/commands/export-credentials.ts @@ -0,0 +1,55 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Command} from 'commander'; +import {FileCredentialStore} from '../auth/file_credential_store.js'; +import {KeyringCredentialStore} from '../auth/keyring_credential_store.js'; +import {GlobalOptions} from './utils.js'; +import {intl} from '../intl.js'; + +interface CommandOptions extends GlobalOptions {} + +export const command = new Command('export-credentials') + .description('Export credentials from the system keyring into a file') + .action(async function (this: Command): Promise { + const options: CommandOptions = this.optsWithGlobals(); + + const keyringStore = new KeyringCredentialStore(); + const os = await import('os'); + const path = await import('path'); + const authFilePath = options.auth ?? path.join(os.homedir(), '.clasprc.json'); + const fileStore = new FileCredentialStore(authFilePath); + + const users = await keyringStore.listUsers(); + + if (users.length === 0) { + const msg = intl.formatMessage({ + defaultMessage: 'No credentials found in the system keyring to export.', + }); + this.error(msg); + } + + for (const user of users) { + const credentials = await keyringStore.load(user); + if (credentials) { + await fileStore.save(user, credentials); + await keyringStore.delete(user); + + const msg = intl.formatMessage({ + defaultMessage: 'Successfully exported credentials for user "{user}" from the system keyring to a file.', + }, {user}); + console.log(msg); + } + } + }); diff --git a/src/commands/import-credentials.ts b/src/commands/import-credentials.ts new file mode 100644 index 00000000..efc12ac7 --- /dev/null +++ b/src/commands/import-credentials.ts @@ -0,0 +1,55 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Command} from 'commander'; +import {FileCredentialStore} from '../auth/file_credential_store.js'; +import {KeyringCredentialStore} from '../auth/keyring_credential_store.js'; +import {GlobalOptions} from './utils.js'; +import {intl} from '../intl.js'; + +interface CommandOptions extends GlobalOptions {} + +export const command = new Command('import-credentials') + .description('Import credentials from a file into the system keyring') + .action(async function (this: Command): Promise { + const options: CommandOptions = this.optsWithGlobals(); + + const os = await import('os'); + const path = await import('path'); + const authFilePath = options.auth ?? path.join(os.homedir(), '.clasprc.json'); + const fileStore = new FileCredentialStore(authFilePath); + const keyringStore = new KeyringCredentialStore(); + + const users = await fileStore.listUsers(); + + if (users.length === 0) { + const msg = intl.formatMessage({ + defaultMessage: 'No credentials found to import.', + }); + this.error(msg); + } + + for (const user of users) { + const credentials = await fileStore.load(user); + if (credentials && !credentials.is_keyring) { + await keyringStore.save(user, credentials); + await fileStore.save(user, {is_keyring: true} as any); + + const msg = intl.formatMessage({ + defaultMessage: 'Successfully imported credentials for user "{user}" into the system keyring.', + }, {user}); + console.log(msg); + } + } + }); diff --git a/src/commands/login.ts b/src/commands/login.ts index 25dab429..a9438847 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -89,6 +89,7 @@ interface CommandOptions extends GlobalOptions { readonly useProjectScopes?: boolean; readonly includeClaspScopes?: boolean; readonly extraScopes?: string[]; + readonly useKeyring?: boolean; } export const command = new Command('login') @@ -177,6 +178,12 @@ export const command = new Command('login') const user = await getUserInfo(credentials); + if (options.useKeyring) { + const {FileCredentialStore} = await import('../auth/file_credential_store.js'); + const fileStore = new FileCredentialStore(auth.authFilePath!); + await fileStore.save(auth.user, {is_keyring: true} as any); + } + if (options.json) { const output = { email: user?.email, diff --git a/src/commands/program.ts b/src/commands/program.ts index de64d351..60f604ce 100644 --- a/src/commands/program.ts +++ b/src/commands/program.ts @@ -48,6 +48,8 @@ import {command as filesStatusCommand} from './show-file-status.js'; import {command as mcpCommand} from './start-mcp.js'; import {command as tailLogsCommand} from './tail-logs.js'; import {command as updateDeploymentCommand} from './update-deployment.js'; +import {command as importCredentialsCommand} from './import-credentials.js'; +import {command as exportCredentialsCommand} from './export-credentials.js'; import {dirname} from 'path'; import {fileURLToPath} from 'url'; @@ -103,6 +105,7 @@ export function makeProgram(exitOverride?: (err: CommanderError) => void) { authFilePath: opts.auth, // Path to .clasprc.json userKey: opts.user, // User key for multi-user support useApplicationDefaultCredentials: opts.adc, // Flag for using ADC + useKeyring: opts.useKeyring, }); // Initialize the main Clasp instance with the (potentially) authenticated client @@ -131,6 +134,7 @@ export function makeProgram(exitOverride?: (err: CommanderError) => void) { program.option('-u,--user ', 'Store named credentials. If unspecified, the "default" user is used.', 'default'); program.option('--adc', 'Use the application default credentials from the environment.'); program.option('--json', 'Show output in JSON format'); + program.option('--use-keyring', 'Use the system keyring for storing and retrieving credentials.'); program.addOption( new Option('-I, --ignore ', "path to an ignore file or a folder with a '.claspignore' file.").env( 'clasp_config_ignore', @@ -172,6 +176,8 @@ export function makeProgram(exitOverride?: (err: CommanderError) => void) { createVersionCommand, listVersionsCommand, mcpCommand, + importCredentialsCommand, + exportCredentialsCommand, ]; for (const cmd of commandsToAdd) { diff --git a/test/auth/auth.ts b/test/auth/auth.ts index b3fa4318..50d15079 100644 --- a/test/auth/auth.ts +++ b/test/auth/auth.ts @@ -28,6 +28,7 @@ type StoreStub = { save: sinon.SinonStub; delete: sinon.SinonStub; deleteAll: sinon.SinonStub; + listUsers: sinon.SinonStub; }; function createStoreStub(credentials?: StoredCredential): StoreStub { @@ -36,6 +37,7 @@ function createStoreStub(credentials?: StoredCredential): StoreStub { save: sinon.stub().resolves(), delete: sinon.stub().resolves(), deleteAll: sinon.stub().resolves(), + listUsers: sinon.stub().resolves([]), }; } diff --git a/test/auth/keyring_credential_store.ts b/test/auth/keyring_credential_store.ts new file mode 100644 index 00000000..a5634e49 --- /dev/null +++ b/test/auth/keyring_credential_store.ts @@ -0,0 +1,142 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {expect} from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; +import {StoredCredential} from '../../src/auth/credential_store.js'; + +describe('KeyringCredentialStore', () => { + let KeyringCredentialStore: any; + let setPasswordStub: sinon.SinonStub; + let getPasswordStub: sinon.SinonStub; + let deletePasswordStub: sinon.SinonStub; + let findCredentialsAsyncStub: sinon.SinonStub; + + const mockCreds: StoredCredential = { + refresh_token: 'mock-refresh-token', + access_token: 'mock-access-token', + expiry_date: 1234567890, + }; + + beforeEach(async () => { + setPasswordStub = sinon.stub().resolves(); + getPasswordStub = sinon.stub().resolves(JSON.stringify(mockCreds)); + deletePasswordStub = sinon.stub().resolves(); + findCredentialsAsyncStub = sinon.stub().resolves([ + {account: 'user1', password: 'pwd1'}, + {account: 'user2', password: 'pwd2'}, + ]); + + class MockAsyncEntry { + constructor(public service: string, public user: string) {} + setPassword = setPasswordStub; + getPassword = getPasswordStub; + deletePassword = deletePasswordStub; + } + + KeyringCredentialStore = ( + await esmock('../../src/auth/keyring_credential_store.js', { + '@napi-rs/keyring': { + AsyncEntry: MockAsyncEntry, + findCredentialsAsync: findCredentialsAsyncStub, + }, + }) + ).KeyringCredentialStore; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should save credentials using setPassword', async () => { + const store = new KeyringCredentialStore(); + await store.save('testuser', mockCreds); + + expect(setPasswordStub.calledOnce).to.be.true; + expect(setPasswordStub.firstCall.args[0]).to.equal(JSON.stringify(mockCreds)); + }); + + it('should call deletePassword if saving undefined', async () => { + const store = new KeyringCredentialStore(); + await store.save('testuser', undefined); + + expect(deletePasswordStub.calledOnce).to.be.true; + }); + + it('should ignore NoEntry error on deletePassword when saving undefined', async () => { + deletePasswordStub.rejects(new Error('NoEntry')); + const store = new KeyringCredentialStore(); + await store.save('testuser', undefined); + + expect(deletePasswordStub.calledOnce).to.be.true; + }); + + it('should load credentials using getPassword', async () => { + const store = new KeyringCredentialStore(); + const creds = await store.load('testuser'); + + expect(getPasswordStub.calledOnce).to.be.true; + expect(creds).to.deep.equal(mockCreds); + }); + + it('should return null if no credentials exist (empty string)', async () => { + getPasswordStub.resolves(''); + const store = new KeyringCredentialStore(); + const creds = await store.load('testuser'); + + expect(creds).to.be.null; + }); + + it('should list all users', async () => { + const store = new KeyringCredentialStore(); + const users = await store.listUsers(); + + expect(findCredentialsAsyncStub.calledOnce).to.be.true; + expect(findCredentialsAsyncStub.firstCall.args[0]).to.equal('clasp'); + expect(users).to.deep.equal(['user1', 'user2']); + }); + + it('should return null if getting password throws an error', async () => { + getPasswordStub.rejects(new Error('Some DBus Error')); + const store = new KeyringCredentialStore(); + const creds = await store.load('testuser'); + + expect(creds).to.be.null; + }); + + it('should delete credentials using deletePassword', async () => { + const store = new KeyringCredentialStore(); + await store.delete('testuser'); + + expect(deletePasswordStub.calledOnce).to.be.true; + }); + + it('should ignore NoEntry error when deleting credentials', async () => { + deletePasswordStub.rejects(new Error('NoEntry')); + const store = new KeyringCredentialStore(); + await store.delete('testuser'); + + expect(deletePasswordStub.calledOnce).to.be.true; + }); + + it('should delete all credentials using findCredentialsAsync and deletePassword', async () => { + const store = new KeyringCredentialStore(); + await store.deleteAll(); + + expect(findCredentialsAsyncStub.calledOnce).to.be.true; + expect(findCredentialsAsyncStub.firstCall.args[0]).to.equal('clasp'); + expect(deletePasswordStub.calledTwice).to.be.true; + }); +}); diff --git a/test/commands/program.ts b/test/commands/program.ts index 818d6e01..8acb15d8 100644 --- a/test/commands/program.ts +++ b/test/commands/program.ts @@ -51,6 +51,8 @@ describe('Consistency between imported and registered commands', () => { 'start-mcp-server', 'tail-logs', 'update-deployment', + 'import-credentials', + 'export-credentials', ]; it('should register all imported commands', () => {