History

Structure relay auth parsing errors (#3290)

Julius Marminge committed 4 months ago

Commit 1dc36ce1 parent 833c8ab2 files · +138 −6Browse files

Unified · Split

2 files
  1. packages/shared/src/relayAuth.test.ts +62 −0
  2. packages/shared/src/relayAuth.ts +76 −6
packages/shared/src/relayAuth.test.ts+62−0
@@ -1,17 +1,79 @@
11 import { describe, expect, it } from "vite-plus/test";
22
33 import {
4+ ClerkPublishableKeyDecodeError,
5+ ClerkPublishableKeyFrontendApiError,
46 clerkFrontendApiHostnameFromPublishableKey,
7+ clerkFrontendApiUrlFromPublishableKey,
58 isAllowedClerkFrontendApiHostname,
69 } from "./relayAuth.ts";
710
811 const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`;
912
13+const captureError = (run: () => unknown): unknown => {
14+ try {
15+ run();
16+ } catch (cause) {
17+ return cause;
18+ }
19+ throw new Error("Expected operation to throw");
20+};
21+
1022 describe("Clerk relay auth", () => {
1123 it("derives a custom Frontend API hostname from a Clerk publishable key", () => {
1224 expect(clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe(
1325 "clerk.t3.codes",
1426 );
27+ expect(clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe(
28+ "https://clerk.t3.codes",
29+ );
30+ });
31+
32+ it("preserves Clerk publishable key decoding failures", () => {
33+ const error = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_%"));
34+
35+ expect(error).toBeInstanceOf(ClerkPublishableKeyDecodeError);
36+ expect(error).toMatchObject({ keyPrefix: "pk_test" });
37+ expect((error as ClerkPublishableKeyDecodeError).cause).toBeInstanceOf(Error);
38+ expect((error as Error).message).toBe("Failed to decode Clerk publishable key (pk_test).");
39+ });
40+
41+ it("reports semantic frontend API failures without inventing a cause", () => {
42+ const emptyError = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_"));
43+ const pathFrontendApi = "clerk.t3.codes/path";
44+ const pathError = captureError(() =>
45+ clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey(pathFrontendApi)),
46+ );
47+
48+ expect(emptyError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError);
49+ expect(emptyError).toMatchObject({
50+ keyPrefix: "pk_test",
51+ frontendApi: "",
52+ reason: "empty",
53+ });
54+ expect((emptyError as Error & { cause?: unknown }).cause).toBeUndefined();
55+ expect(pathError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError);
56+ expect(pathError).toMatchObject({
57+ keyPrefix: "pk_test",
58+ frontendApi: pathFrontendApi,
59+ reason: "contains-path",
60+ });
61+ expect((pathError as Error & { cause?: unknown }).cause).toBeUndefined();
62+ });
63+
64+ it("preserves URL parser failures for decoded frontend APIs", () => {
65+ const frontendApi = "[invalid-host";
66+ const error = captureError(() =>
67+ clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey(frontendApi)),
68+ );
69+
70+ expect(error).toBeInstanceOf(ClerkPublishableKeyFrontendApiError);
71+ expect(error).toMatchObject({
72+ keyPrefix: "pk_test",
73+ frontendApi,
74+ reason: "invalid-url",
75+ });
76+ expect((error as ClerkPublishableKeyFrontendApiError).cause).toBeInstanceOf(Error);
1577 });
1678
1779 it("allows standard Clerk hosts and an exact configured custom hostname", () => {
packages/shared/src/relayAuth.ts+76−6
@@ -1,14 +1,84 @@
1−export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string {
1+import * as Schema from "effect/Schema";
2+
3+const ClerkPublishableKeyPrefix = Schema.Literals(["pk_test", "pk_live", "unknown"]);
4+
5+export class ClerkPublishableKeyDecodeError extends Schema.TaggedErrorClass<ClerkPublishableKeyDecodeError>()(
6+ "ClerkPublishableKeyDecodeError",
7+ {
8+ keyPrefix: ClerkPublishableKeyPrefix,
9+ cause: Schema.Defect(),
10+ },
11+) {
12+ override get message(): string {
13+ return `Failed to decode Clerk publishable key (${this.keyPrefix}).`;
14+ }
15+}
16+
17+export class ClerkPublishableKeyFrontendApiError extends Schema.TaggedErrorClass<ClerkPublishableKeyFrontendApiError>()(
18+ "ClerkPublishableKeyFrontendApiError",
19+ {
20+ keyPrefix: ClerkPublishableKeyPrefix,
21+ frontendApi: Schema.String,
22+ reason: Schema.Literals(["empty", "contains-path", "invalid-url"]),
23+ cause: Schema.optional(Schema.Defect()),
24+ },
25+) {
26+ override get message(): string {
27+ return `Invalid Clerk frontend API decoded from publishable key (${this.keyPrefix}; ${this.reason}).`;
28+ }
29+}
30+
31+function parseClerkFrontendApi(publishableKey: string): {
32+ readonly hostname: string;
33+ readonly url: string;
34+} {
35+ const keyPrefix = publishableKey.startsWith("pk_test_")
36+ ? "pk_test"
37+ : publishableKey.startsWith("pk_live_")
38+ ? "pk_live"
39+ : "unknown";
240 const encodedFrontendApi = publishableKey.split("_").slice(2).join("_");
3− const frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, "");
4− if (frontendApi.length === 0 || frontendApi.includes("/")) {
5− throw new Error("Invalid Clerk publishable key.");
41+ let frontendApi: string;
42+ try {
43+ frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, "");
44+ } catch (cause) {
45+ throw new ClerkPublishableKeyDecodeError({ keyPrefix, cause });
646 }
7− return `https://${frontendApi}`;
47+
48+ if (frontendApi.length === 0) {
49+ throw new ClerkPublishableKeyFrontendApiError({
50+ keyPrefix,
51+ frontendApi,
52+ reason: "empty",
53+ });
54+ }
55+ if (frontendApi.includes("/")) {
56+ throw new ClerkPublishableKeyFrontendApiError({
57+ keyPrefix,
58+ frontendApi,
59+ reason: "contains-path",
60+ });
61+ }
62+
63+ const url = `https://${frontendApi}`;
64+ try {
65+ return { hostname: new URL(url).hostname, url };
66+ } catch (cause) {
67+ throw new ClerkPublishableKeyFrontendApiError({
68+ keyPrefix,
69+ frontendApi,
70+ reason: "invalid-url",
71+ cause,
72+ });
73+ }
74+}
75+
76+export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string {
77+ return parseClerkFrontendApi(publishableKey).url;
878 }
979
1080 export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: string): string {
11− return new URL(clerkFrontendApiUrlFromPublishableKey(publishableKey)).hostname;
81+ return parseClerkFrontendApi(publishableKey).hostname;
1282 }
1383
1484 export function isAllowedClerkFrontendApiHostname(