Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cli/src/lib/credential-redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const INVALID_HEADER_END = /(?<!\\)(\\*["']) is an invalid header value/gi;
// Header validation errors quote the entire value, including invalid newlines.
// The end-of-string alternative also covers messages truncated by the SDK.
const QUOTED_CREDENTIAL =
/(?<!\\)(\\*["'])(Bearer[ \t]+|sntry[su]_)[\s\S]*?(?:\1|$)/gi;
/(?<!\\)(\\*["'])(Bearer[ \t]+|sntry[suc]_)[\s\S]*?(?:\1|$)/gi;
/** Control characters can also appear escaped in serialized diagnostics. */
const ESCAPED_CONTROL = /\\+(?:[nrtbfv]|u00[01][\da-f]|u007f|u202[89])/.source;
const BEARER_PART = String.raw`(?:${ESCAPED_CONTROL}[ \t]*|\\+[^"'\s\\]|[^\s"'\\])`;
Expand All @@ -28,7 +28,7 @@ const BEARER_CREDENTIAL = new RegExp(
"gi",
);
const SENTRY_CREDENTIAL = new RegExp(
String.raw`\bsntry[su]_[A-Za-z0-9._~+/=-]+(?:(?:\r\n|(?! )[\s\x00-\x1f\x7f-\x9f]|${ESCAPED_CONTROL})[ \t]*[A-Za-z0-9._~+/=-]+)*`,
String.raw`\bsntry[suc]_[A-Za-z0-9._~+/=-]+(?:(?:\r\n|(?! )[\s\x00-\x1f\x7f-\x9f]|${ESCAPED_CONTROL})[ \t]*[A-Za-z0-9._~+/=-]+)*`,
"gi",
);

Expand Down
52 changes: 51 additions & 1 deletion packages/cli/src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
} from "./errors.js";
import { logger } from "./logger.js";
import { normalizeHttpOrigin } from "./sentry-urls.js";
import { getActiveTokenHost } from "./token-host.js";
import { getActiveTokenHost, isLoginTrustAnchorFor } from "./token-host.js";
import { getLoginCommand } from "./app-identity.js";

/**
Expand Down Expand Up @@ -612,3 +612,53 @@ export async function refreshAccessToken(

return result.output;
}

/** Exchange a single-use setup credential without retries or redirects. */
export async function exchangeSetupCode(
code: string,
host: string,
): Promise<TokenResponse> {
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;
}
36 changes: 36 additions & 0 deletions packages/cli/src/lib/setup-code.ts
Original file line number Diff line number Diff line change
@@ -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;
}
49 changes: 49 additions & 0 deletions packages/cli/test/lib/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
},
);
});
28 changes: 28 additions & 0 deletions packages/cli/test/lib/setup-code.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
Loading