From 4dcc04bd62b0c5af00cef5ecb3c41e44bbaa4127 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:44:57 +0000 Subject: [PATCH 1/2] feat: implement cross-platform keyring support with @napi-rs/keyring Added an optional `--use-keyring` flag to the `login` command to store credentials securely in the system keyring. Implemented two new commands, `import-credentials` and `export-credentials` to migrate existing file-based credentials into the system keyring, or export them back. Re-routing reads and writes correctly defaults to the KeyringCredentialStore when valid credentials exist in the system keyring for the user, while still supporting the legacy `.clasprc.json` system by default. Co-authored-by: sqrrrl <346343+sqrrrl@users.noreply.github.com> --- package-lock.json | 235 ++++++++++++++++++++++++++ package.json | 1 + src/auth/auth.ts | 18 +- src/auth/keyring_credential_store.ts | 93 ++++++++++ src/commands/export-credentials.ts | 51 ++++++ src/commands/import-credentials.ts | 51 ++++++ src/commands/login.ts | 7 + src/commands/program.ts | 4 + test/auth/keyring_credential_store.ts | 133 +++++++++++++++ test/commands/program.ts | 2 + 10 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 src/auth/keyring_credential_store.ts create mode 100644 src/commands/export-credentials.ts create mode 100644 src/commands/import-credentials.ts create mode 100644 test/auth/keyring_credential_store.ts 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..a636868c 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'; @@ -58,7 +59,16 @@ 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); + let credentialStore: CredentialStore = new FileCredentialStore(authFilePath); + + const userKey = options.userKey ?? 'default'; + + const keyringStore = new KeyringCredentialStore(); + const keyringCreds = await keyringStore.load(userKey); + if (keyringCreds) { + debug('Found credentials in keyring for user %s', userKey); + credentialStore = keyringStore; + } debug('Initializing auth from %s', options.authFilePath); if (options.useApplicationDefaultCredentials) { @@ -66,15 +76,15 @@ export async function initAuth(options: InitOptions): Promise { return { credentials, credentialStore, - user: options.userKey ?? 'default', + user: userKey, }; } - const credentials = await getAuthorizedOAuth2Client(credentialStore, options.userKey); + const credentials = await getAuthorizedOAuth2Client(credentialStore, userKey); return { credentials, credentialStore, - user: options.userKey ?? 'default', + user: userKey, }; } diff --git a/src/auth/keyring_credential_store.ts b/src/auth/keyring_credential_store.ts new file mode 100644 index 00000000..6b72c10c --- /dev/null +++ b/src/auth/keyring_credential_store.ts @@ -0,0 +1,93 @@ +// 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 + } + } + } + + /** + * 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..cd8f7a20 --- /dev/null +++ b/src/commands/export-credentials.ts @@ -0,0 +1,51 @@ +// 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 {AuthInfo} from '../auth/auth.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 auth: AuthInfo = options.authInfo; + + const keyringStore = new KeyringCredentialStore(); + const credentials = await keyringStore.load(auth.user); + + if (!credentials) { + const msg = intl.formatMessage({ + defaultMessage: 'No credentials found in the system keyring for user "{user}".', + }, {user: auth.user}); + this.error(msg); + } + + 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); + + await fileStore.save(auth.user, credentials); + + const msg = intl.formatMessage({ + defaultMessage: 'Successfully exported credentials for user "{user}" from the system keyring to a file.', + }, {user: auth.user}); + console.log(msg); + }); diff --git a/src/commands/import-credentials.ts b/src/commands/import-credentials.ts new file mode 100644 index 00000000..801339bf --- /dev/null +++ b/src/commands/import-credentials.ts @@ -0,0 +1,51 @@ +// 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 {AuthInfo} from '../auth/auth.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 auth: AuthInfo = options.authInfo; + + 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); + + // Check if the user credential exists in the file store + const credentials = await fileStore.load(auth.user); + if (!credentials) { + const msg = intl.formatMessage({ + defaultMessage: 'No credentials found to import for user "{user}".', + }, {user: auth.user}); + this.error(msg); + } + + const keyringStore = new KeyringCredentialStore(); + await keyringStore.save(auth.user, credentials); + + const msg = intl.formatMessage({ + defaultMessage: 'Successfully imported credentials for user "{user}" into the system keyring.', + }, {user: auth.user}); + console.log(msg); + }); diff --git a/src/commands/login.ts b/src/commands/login.ts index 25dab429..e0c9f077 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -20,6 +20,7 @@ import {Command, InvalidOptionArgumentError} from 'commander'; import {AuthInfo, authorize, getUnauthorizedOuth2Client, getUserInfo} from '../auth/auth.js'; +import {KeyringCredentialStore} from '../auth/keyring_credential_store.js'; import {Clasp} from '../core/clasp.js'; import {intl} from '../intl.js'; import {GlobalOptions} from './utils.js'; @@ -89,12 +90,14 @@ interface CommandOptions extends GlobalOptions { readonly useProjectScopes?: boolean; readonly includeClaspScopes?: boolean; readonly extraScopes?: string[]; + readonly useKeyring?: boolean; } export const command = new Command('login') .description('Log in to script.google.com') .option('--no-localhost', 'Do not run a local server, manually enter code instead') .option('--creds ', 'Relative path to OAuth client secret file (from GCP).') + .option('--use-keyring', 'Store credentials in the system keyring instead of a file.') .option( '--use-project-scopes', 'Use the scopes from the current project manifest. Used only when authorizing access for the run command.', @@ -112,6 +115,10 @@ export const command = new Command('login') const auth: AuthInfo = options.authInfo; const clasp: Clasp = options.clasp; + if (options.useKeyring) { + auth.credentialStore = new KeyringCredentialStore(); + } + if (!auth.credentialStore) { const msg = intl.formatMessage({ defaultMessage: 'No credential store found, unable to login.', diff --git a/src/commands/program.ts b/src/commands/program.ts index de64d351..d21d9a25 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'; @@ -172,6 +174,8 @@ export function makeProgram(exitOverride?: (err: CommanderError) => void) { createVersionCommand, listVersionsCommand, mcpCommand, + importCredentialsCommand, + exportCredentialsCommand, ]; for (const cmd of commandsToAdd) { diff --git a/test/auth/keyring_credential_store.ts b/test/auth/keyring_credential_store.ts new file mode 100644 index 00000000..58b4e007 --- /dev/null +++ b/test/auth/keyring_credential_store.ts @@ -0,0 +1,133 @@ +// 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 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', () => { From a212229f23475b86536c1bff03809b8c54c955b3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:54:30 +0000 Subject: [PATCH 2/2] feat: implement cross-platform keyring support with @napi-rs/keyring Added an optional `--use-keyring` global flag to store and retrieve credentials securely in the system keyring. Implemented two new commands, `import-credentials` and `export-credentials` to migrate all existing file-based credentials into the system keyring, or export them back. To avoid unnecessary system keyring permission prompts, an `is_keyring` stub is saved to the file store (`.clasprc.json`). This ensures that the `KeyringCredentialStore` is only accessed if the user has opted in. Co-authored-by: sqrrrl <346343+sqrrrl@users.noreply.github.com> --- src/auth/auth.ts | 20 ++++++++++----- src/auth/credential_store.ts | 9 ++++++- src/auth/file_credential_store.ts | 12 +++++++++ src/auth/keyring_credential_store.ts | 9 +++++++ src/commands/export-credentials.ts | 36 +++++++++++++++------------ src/commands/import-credentials.ts | 30 ++++++++++++---------- src/commands/login.ts | 12 ++++----- src/commands/program.ts | 2 ++ test/auth/auth.ts | 2 ++ test/auth/keyring_credential_store.ts | 9 +++++++ 10 files changed, 99 insertions(+), 42 deletions(-) diff --git a/src/auth/auth.ts b/src/auth/auth.ts index a636868c..d0a7ef31 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -35,6 +35,7 @@ type InitOptions = { authFilePath?: string; userKey?: string; useApplicationDefaultCredentials?: boolean; + useKeyring?: boolean; }; /** @@ -42,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; }; /** @@ -59,15 +62,18 @@ export type AuthInfo = { */ export async function initAuth(options: InitOptions): Promise { const authFilePath = options.authFilePath ?? path.join(os.homedir(), '.clasprc.json'); - let credentialStore: CredentialStore = new FileCredentialStore(authFilePath); + const fileStore = new FileCredentialStore(authFilePath); + let credentialStore: CredentialStore = fileStore; const userKey = options.userKey ?? 'default'; - const keyringStore = new KeyringCredentialStore(); - const keyringCreds = await keyringStore.load(userKey); - if (keyringCreds) { - debug('Found credentials in keyring for user %s', userKey); - credentialStore = keyringStore; + 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); @@ -77,6 +83,7 @@ export async function initAuth(options: InitOptions): Promise { credentials, credentialStore, user: userKey, + authFilePath, }; } @@ -85,6 +92,7 @@ export async function initAuth(options: InitOptions): Promise { credentials, credentialStore, 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 index 6b72c10c..6a863aec 100644 --- a/src/auth/keyring_credential_store.ts +++ b/src/auth/keyring_credential_store.ts @@ -73,6 +73,15 @@ export class KeyringCredentialStore implements CredentialStore { } } + /** + * 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. diff --git a/src/commands/export-credentials.ts b/src/commands/export-credentials.ts index cd8f7a20..90146c6f 100644 --- a/src/commands/export-credentials.ts +++ b/src/commands/export-credentials.ts @@ -15,7 +15,6 @@ import {Command} from 'commander'; import {FileCredentialStore} from '../auth/file_credential_store.js'; import {KeyringCredentialStore} from '../auth/keyring_credential_store.js'; -import {AuthInfo} from '../auth/auth.js'; import {GlobalOptions} from './utils.js'; import {intl} from '../intl.js'; @@ -25,27 +24,32 @@ 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 auth: AuthInfo = options.authInfo; const keyringStore = new KeyringCredentialStore(); - const credentials = await keyringStore.load(auth.user); - - if (!credentials) { - const msg = intl.formatMessage({ - defaultMessage: 'No credentials found in the system keyring for user "{user}".', - }, {user: auth.user}); - this.error(msg); - } - 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); - await fileStore.save(auth.user, credentials); + const users = await keyringStore.listUsers(); - const msg = intl.formatMessage({ - defaultMessage: 'Successfully exported credentials for user "{user}" from the system keyring to a file.', - }, {user: auth.user}); - console.log(msg); + 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 index 801339bf..efc12ac7 100644 --- a/src/commands/import-credentials.ts +++ b/src/commands/import-credentials.ts @@ -15,7 +15,6 @@ import {Command} from 'commander'; import {FileCredentialStore} from '../auth/file_credential_store.js'; import {KeyringCredentialStore} from '../auth/keyring_credential_store.js'; -import {AuthInfo} from '../auth/auth.js'; import {GlobalOptions} from './utils.js'; import {intl} from '../intl.js'; @@ -25,27 +24,32 @@ 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 auth: AuthInfo = options.authInfo; 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(); - // Check if the user credential exists in the file store - const credentials = await fileStore.load(auth.user); - if (!credentials) { + if (users.length === 0) { const msg = intl.formatMessage({ - defaultMessage: 'No credentials found to import for user "{user}".', - }, {user: auth.user}); + defaultMessage: 'No credentials found to import.', + }); this.error(msg); } - const keyringStore = new KeyringCredentialStore(); - await keyringStore.save(auth.user, credentials); + 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: auth.user}); - console.log(msg); + 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 e0c9f077..a9438847 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -20,7 +20,6 @@ import {Command, InvalidOptionArgumentError} from 'commander'; import {AuthInfo, authorize, getUnauthorizedOuth2Client, getUserInfo} from '../auth/auth.js'; -import {KeyringCredentialStore} from '../auth/keyring_credential_store.js'; import {Clasp} from '../core/clasp.js'; import {intl} from '../intl.js'; import {GlobalOptions} from './utils.js'; @@ -97,7 +96,6 @@ export const command = new Command('login') .description('Log in to script.google.com') .option('--no-localhost', 'Do not run a local server, manually enter code instead') .option('--creds ', 'Relative path to OAuth client secret file (from GCP).') - .option('--use-keyring', 'Store credentials in the system keyring instead of a file.') .option( '--use-project-scopes', 'Use the scopes from the current project manifest. Used only when authorizing access for the run command.', @@ -115,10 +113,6 @@ export const command = new Command('login') const auth: AuthInfo = options.authInfo; const clasp: Clasp = options.clasp; - if (options.useKeyring) { - auth.credentialStore = new KeyringCredentialStore(); - } - if (!auth.credentialStore) { const msg = intl.formatMessage({ defaultMessage: 'No credential store found, unable to login.', @@ -184,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 d21d9a25..60f604ce 100644 --- a/src/commands/program.ts +++ b/src/commands/program.ts @@ -105,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 @@ -133,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', 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 index 58b4e007..a5634e49 100644 --- a/test/auth/keyring_credential_store.ts +++ b/test/auth/keyring_credential_store.ts @@ -99,6 +99,15 @@ describe('KeyringCredentialStore', () => { 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();