diff --git a/packages/cli/src/lib/credential-redaction.ts b/packages/cli/src/lib/credential-redaction.ts index 2b35010d6..d119e080b 100644 --- a/packages/cli/src/lib/credential-redaction.ts +++ b/packages/cli/src/lib/credential-redaction.ts @@ -17,7 +17,7 @@ const INVALID_HEADER_END = /(? { + const response = await fetchWithConnectionError( + host + "/oauth/token/", + { + method: "POST", + redirect: "error", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:sentry:params:oauth:grant-type:setup_code", + client_id: getClientId(), + setup_code: code, + }), + }, + isLoginTrustAnchorFor(host) || undefined, + ); + + if (!response.ok) { + throw new AuthError( + "invalid", + "Setup-code authentication failed. Generate a new setup code in Sentry and try again.", + ); + } + + let data: unknown; + + try { + data = await response.json(); + } catch { + throw new AuthError( + "invalid", + "The setup endpoint returned an invalid token response. Generate a new setup code in Sentry.", + ); + } + + const result = safeParse(TokenResponseSchema, data); + + if (!result.success) { + throw new AuthError( + "invalid", + "The setup endpoint returned an invalid token response. Generate a new setup code in Sentry.", + ); + } + + return result.output; +} diff --git a/packages/cli/src/lib/setup-code.ts b/packages/cli/src/lib/setup-code.ts new file mode 100644 index 000000000..3ffb90532 --- /dev/null +++ b/packages/cli/src/lib/setup-code.ts @@ -0,0 +1,36 @@ +/** Read the issuing Sentry instance from a private setup code. */ + +import { looseObject, safeParse, string } from "valibot"; +import { ValidationError } from "./errors.js"; +import { normalizeHttpOrigin } from "./sentry-urls.js"; + +const setupCodePattern = /^sntryc_([A-Za-z0-9+/=]+)_([A-Za-z0-9+/]{43})$/; +const claimSchema = looseObject({ url: string() }); + +/** Resolve the issuing instance from the code's unsigned routing claim. */ +export function getSetupCodeHost(code: string): string { + const encodedClaim = setupCodePattern.exec(code)?.[1]; + + if (!encodedClaim) { + throw new ValidationError("Invalid Sentry setup code.", "setup-code"); + } + + let claim: unknown; + + try { + claim = JSON.parse(Buffer.from(encodedClaim, "base64").toString("utf8")); + } catch { + throw new ValidationError("Invalid Sentry setup code.", "setup-code"); + } + + const parsed = safeParse(claimSchema, claim); + const host = parsed.success + ? normalizeHttpOrigin(parsed.output.url) + : undefined; + + if (!host) { + throw new ValidationError("Invalid setup-code instance URL.", "setup-code"); + } + + return host; +} diff --git a/packages/cli/test/lib/oauth.test.ts b/packages/cli/test/lib/oauth.test.ts index fb80650c3..07adc86de 100644 --- a/packages/cli/test/lib/oauth.test.ts +++ b/packages/cli/test/lib/oauth.test.ts @@ -19,6 +19,7 @@ import { SENTRY_SCOPES } from "../../src/lib/api-scope.js"; import { ValidationError } from "../../src/lib/errors.js"; import { OAUTH_SCOPES, + exchangeSetupCode, performDeviceFlow, resolveOAuthScopeString, } from "../../src/lib/oauth.js"; @@ -215,3 +216,51 @@ describe("property: resolveOAuthScopeString", () => { ); }); }); + +describe("setup-code exchange", () => { + test("posts the setup grant once and refuses redirects", async () => { + const token = { + access_token: "access-token", + token_type: "bearer", + expires_in: 3600, + refresh_token: "refresh-token", + }; + const fetch = vi.fn().mockResolvedValue(Response.json(token)); + vi.stubGlobal("fetch", fetch); + expect( + await exchangeSetupCode("private-code", "https://sentry.example.com"), + ).toEqual(token); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = fetch.mock.calls[0]!; + expect(url).toBe("https://sentry.example.com/oauth/token/"); + expect(init.redirect).toBe("error"); + expect(init.body.get("grant_type")).toBe( + "urn:sentry:params:oauth:grant-type:setup_code", + ); + expect(init.body.get("setup_code")).toBe("private-code"); + expect(init.body.get("client_id")).toBeTruthy(); + }); + + test.each([ + Response.json({ error: "private-code" }, { status: 400 }), + Response.json({ access_token: 123 }), + new Response("private-code"), + ])( + "rejects errors and malformed responses without echoing secrets", + async (response) => { + const fetch = vi.fn().mockResolvedValue(response); + vi.stubGlobal("fetch", fetch); + await exchangeSetupCode( + "private-code", + "https://sentry.example.com", + ).then( + () => expect.unreachable("A rejected exchange must fail"), + (error: unknown) => { + expect(String(error)).toContain("Generate a new setup code"); + expect(String(error)).not.toContain("private-code"); + }, + ); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/packages/cli/test/lib/setup-code.test.ts b/packages/cli/test/lib/setup-code.test.ts new file mode 100644 index 000000000..5bd12d7dc --- /dev/null +++ b/packages/cli/test/lib/setup-code.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; +import { redactCredentialText } from "../../src/lib/credential-redaction.js"; +import { getSetupCodeHost } from "../../src/lib/setup-code.js"; + +const codeFor = (url: string) => + `sntryc_${Buffer.from(JSON.stringify({ url, iat: 1 })).toString("base64")}_${"a".repeat(43)}`; + +describe("setup-code routing claims", () => { + test("resolves the issuing instance and redacts the full code", () => { + const code = codeFor("https://sentry.example.com/"); + expect(getSetupCodeHost(code)).toBe("https://sentry.example.com"); + expect(redactCredentialText(`code: ${code}`)).toBe("code: [REDACTED]"); + }); + + test.each([ + "", + "sntryc_invalid", + codeFor("file:///etc/passwd"), + codeFor("https://user:password@sentry.example.com"), + ])("rejects malformed codes with a safe error", (code) => { + expect(() => getSetupCodeHost(code)).toThrow(/Invalid/); + try { + getSetupCodeHost(code); + } catch (error) { + expect(String(error)).not.toContain("password"); + } + }); +});