From 9e68a1b0220e672be1a430207a4a9d222bc7e5fb Mon Sep 17 00:00:00 2001 From: Oliver Li Date: Tue, 30 Jun 2026 15:34:44 -0400 Subject: [PATCH] Add `reset-oauth` CLI command to clear cached Apps OAuth tokens Adds `yarn cli reset-oauth` to delete the cached Datadog Apps OAuth token(s) from the OS credential store, so the next upload restarts the browser authorization flow. Defaults to clearing every known site; `--site` targets a single one. Reuses the existing keychain helpers in @dd/core/helpers/oauth-request. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/tools/README.md | 1 + .../src/commands/reset-oauth/index.test.ts | 133 ++++++++++++++++++ .../tools/src/commands/reset-oauth/index.ts | 73 ++++++++++ 3 files changed, 207 insertions(+) create mode 100644 packages/tools/src/commands/reset-oauth/index.test.ts create mode 100644 packages/tools/src/commands/reset-oauth/index.ts diff --git a/packages/tools/README.md b/packages/tools/README.md index 198f43a31..a4e33db99 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -18,3 +18,4 @@ yarn cli [args] - `dashboard`: Generate a new dashboard configuration to be imported in Datadog. - `oss`: Make the code compliant with our Open Source rules. - `integrity`: Automate the verification of the repository. (Also runs `oss` command). +- `reset-oauth`: Clear the cached Datadog Apps OAuth token to restart the authorization flow. diff --git a/packages/tools/src/commands/reset-oauth/index.test.ts b/packages/tools/src/commands/reset-oauth/index.test.ts new file mode 100644 index 000000000..b5e6945d6 --- /dev/null +++ b/packages/tools/src/commands/reset-oauth/index.test.ts @@ -0,0 +1,133 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { + getDatadogOAuthConfig, + readOAuthTokenFromKeychain, + writeOAuthTokenToKeychain, +} from '@dd/core/helpers/oauth-request'; +import commands from '@dd/tools/commands/reset-oauth/index'; +import { Cli } from 'clipanion'; +import { Writable } from 'stream'; + +// Marker that makes the mocked keychain reject a delete, to exercise the +// command's error-collection branch. +const FORCE_DELETE_ERROR = 'FORCE_DELETE_ERROR'; + +const mockKeyringStore = new Map(); + +jest.mock('@napi-rs/keyring', () => ({ + AsyncEntry: class { + private readonly key: string; + + constructor(service: string, username: string) { + this.key = `${service}:${username}`; + } + + async deletePassword() { + const stored = mockKeyringStore.get(this.key); + if (stored && stored.includes(FORCE_DELETE_ERROR)) { + throw new Error('Simulated keychain failure.'); + } + mockKeyringStore.delete(this.key); + } + + async getPassword() { + return mockKeyringStore.get(this.key); + } + + async setPassword(password: string) { + mockKeyringStore.set(this.key, password); + } + }, +})); + +const seedToken = async (site: string, accessToken = `token-${site}`) => { + const options = getDatadogOAuthConfig(site); + await writeOAuthTokenToKeychain( + site, + { accessToken, clientId: options.clientId, site }, + options, + ); +}; + +const hasToken = async (site: string) => { + const token = await readOAuthTokenFromKeychain(site, getDatadogOAuthConfig(site)); + return token !== undefined; +}; + +describe('Command reset-oauth', () => { + const cli = new Cli(); + cli.register(commands[0]); + + // Discard clipanion's own output (it writes the thrown-error report to + // stdout) to keep the test logs clean. + const discard = new Writable({ + write(chunk, encoding, callback) { + callback(); + }, + }); + const context = { ...Cli.defaultContext, stdout: discard, stderr: discard }; + const run = (args: string[]) => cli.run(args, context); + + let logSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + mockKeyringStore.clear(); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + test('Should clear the token for every site by default.', async () => { + await seedToken('datadoghq.com'); + await seedToken('datadoghq.eu'); + + const exitCode = await run(['reset-oauth']); + + expect(exitCode).toBe(0); + expect(await hasToken('datadoghq.com')).toBe(false); + expect(await hasToken('datadoghq.eu')).toBe(false); + }); + + test('Should only clear the token for the targeted site.', async () => { + await seedToken('datadoghq.com'); + await seedToken('datadoghq.eu'); + + const exitCode = await run(['reset-oauth', '--site', 'datadoghq.eu']); + + expect(exitCode).toBe(0); + expect(await hasToken('datadoghq.eu')).toBe(false); + expect(await hasToken('datadoghq.com')).toBe(true); + }); + + test('Should reject an unknown site without touching stored tokens.', async () => { + await seedToken('datadoghq.com'); + + const exitCode = await run(['reset-oauth', '--site', 'nope.com']); + + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown site "nope.com"')); + expect(await hasToken('datadoghq.com')).toBe(true); + }); + + test('Should succeed when no token is cached.', async () => { + const exitCode = await run(['reset-oauth', '--site', 'datadoghq.com']); + + expect(exitCode).toBe(0); + }); + + test('Should report a non-zero exit code when a token cannot be cleared.', async () => { + await seedToken('datadoghq.com', FORCE_DELETE_ERROR); + + const exitCode = await run(['reset-oauth', '--site', 'datadoghq.com']); + + expect(exitCode).toBe(1); + }); +}); diff --git a/packages/tools/src/commands/reset-oauth/index.ts b/packages/tools/src/commands/reset-oauth/index.ts new file mode 100644 index 000000000..7156781e4 --- /dev/null +++ b/packages/tools/src/commands/reset-oauth/index.ts @@ -0,0 +1,73 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { Command, Option } from 'clipanion'; + +class ResetOAuth extends Command { + static paths = [['reset-oauth']]; + + static usage = Command.Usage({ + category: `Contribution`, + description: `Clear the cached Datadog Apps OAuth token to restart the authorization flow.`, + details: ` + OAuth tokens obtained by the Apps plugin are persisted in the OS credential store (keychain). + + This command removes those cached tokens so the next upload triggers a fresh browser authorization. + + By default it clears the token for every known Datadog site. Pass --site to target a single site. + `, + examples: [ + [`Reset for all sites`, `$0 reset-oauth`], + [`Reset for a specific site`, `$0 reset-oauth --site datadoghq.eu`], + ], + }); + + site = Option.String('--site', { + description: 'Only clear the token for this Datadog site (e.g. datadoghq.com).', + }); + + async execute() { + const { SITES } = await import('@dd/core/constants'); + const { deleteOAuthTokenFromKeychain, getDatadogOAuthConfig } = await import( + '@dd/core/helpers/oauth-request' + ); + const { green, red, dim } = await import('@dd/tools/helpers'); + + const isKnownSite = this.site && SITES.some((site) => site === this.site); + if (this.site && !isKnownSite) { + console.error( + red(`Unknown site "${this.site}". Supported sites: ${SITES.join(', ')}.`), + ); + return 1; + } + + const sites = this.site ? [this.site] : [...SITES]; + const errors: string[] = []; + + for (const site of sites) { + try { + const options = getDatadogOAuthConfig(site); + await deleteOAuthTokenFromKeychain(site, options); + console.log(` Cleared cached OAuth token for ${green(site)}.`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`[${red('Error')}] ${site} - ${dim(message)}`); + } + } + + if (errors.length > 0) { + console.error(errors.join('\n')); + throw new Error( + `Could not clear ${errors.length} OAuth token${errors.length > 1 ? 's' : ''}.`, + ); + } + + console.log( + green(`\nDone. The next Datadog Apps upload will start a fresh authorization.`), + ); + return 0; + } +} + +export default [ResetOAuth];