Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/wicked-lemons-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@crossmint/client-sdk-auth": patch
---

Moved JWT cookie scoping to allow for multiple project cookies in the same domain, and prevent issues of cookie mismatch during development.
5 changes: 3 additions & 2 deletions packages/client/auth/src/CrossmintAuthClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,14 @@ describe("CrossmintAuthClient", () => {

await crossmintAuthClient.storeAuthMaterial(mockAuthMaterial);

// Cookie names are scoped by project ID (defaults to "default" when apiKey is missing or invalid)
expect(cookiesUtils.setCookie).toHaveBeenCalledWith(
"crossmint-jwt",
"crossmint-jwt-default",
mockAuthMaterial.jwt,
new Date(getJWTExpiration(validJwt)! * 1000).toISOString()
);
expect(cookiesUtils.setCookie).toHaveBeenCalledWith(
"crossmint-refresh-token",
"crossmint-refresh-token-default",
mockAuthMaterial.refreshToken.secret,
mockAuthMaterial.refreshToken.expiresAt
);
Expand Down
5 changes: 3 additions & 2 deletions packages/client/auth/src/CrossmintAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import type { Crossmint, CrossmintApiClient } from "@crossmint/common-sdk-base";
import { type CancellableTask, queueTask } from "@crossmint/client-sdk-base";
import { getJWTExpiration, TIME_BEFORE_EXPIRING_JWT_IN_SECONDS } from "./utils";
import { type StorageProvider, getDefaultStorageProvider } from "./utils/storage";
import { type StorageProvider, getScopedStorageProvider } from "./utils/storage";

// Global flag to prevent multiple concurrent initial refresh calls across all instances
let globalInitialRefreshInProgress = false;
Expand All @@ -35,7 +35,8 @@ export class CrossmintAuthClient extends CrossmintAuth {
super(crossmint, apiClient, config);
this.callbacks = config.callbacks ?? {};
this.logoutRoute = config.logoutRoute ?? null;
this.storageProvider = config.storageProvider ?? getDefaultStorageProvider();
// Use scoped storage by default to prevent JWT conflicts when switching between projects
this.storageProvider = config.storageProvider ?? getScopedStorageProvider(crossmint.apiKey);
}

public static from(crossmint: Crossmint, config: CrossmintAuthClientConfig = {}): CrossmintAuthClient {
Expand Down
84 changes: 84 additions & 0 deletions packages/client/auth/src/utils/storage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { validateAPIKey } from "@crossmint/common-sdk-base";
import { REFRESH_TOKEN_PREFIX } from "@crossmint/common-sdk-auth";
import { deleteCookie, getCookie, setCookie } from "./cookies";

export interface StorageProvider {
Expand Down Expand Up @@ -29,6 +31,88 @@ export class CookieStorage implements StorageProvider {
}
}

/**
* Extracts the project ID from an API key for cookie scoping.
* This ensures each project has its own JWT storage to prevent audience mismatch issues.
* Falls back to "default" if the API key is invalid or missing.
*/
export function getProjectIdFromApiKey(apiKey: string | undefined | null): string {
if (apiKey == null) {
return "default";
}
try {
const result = validateAPIKey(apiKey);
if (result.isValid) {
return result.projectId;
}
return "default";
} catch {
return "default";
}
}

/**
* Cookie storage that scopes cookies by project ID.
* This prevents JWT conflicts when switching between different projects.
*
* For JWTs: Only reads from scoped cookies to prevent using wrong project's JWT.
* For refresh tokens: Falls back to legacy cookies to allow migration.
* If a legacy refresh token is used with the wrong project, the server will reject it
* and the SDK will call logout(), cleaning up the legacy cookie.
*/
export class ScopedCookieStorage implements StorageProvider {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would change cookiestorage to use this implementation instead of having 2

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion! I kept both implementations for now to minimize the scope of this change, but consolidating them would be a good follow-up. The main difference is that ScopedCookieStorage adds project ID scoping and legacy cookie fallback for refresh tokens. Would you like me to consolidate them in this PR or as a separate follow-up?

private projectId: string;

constructor(apiKey: string) {
this.projectId = getProjectIdFromApiKey(apiKey);
}

private getScopedKey(key: string): string {
return `${key}-${this.projectId}`;
}

async get(key: string): Promise<string | undefined> {
if (typeof document === "undefined") {
console.debug(`[ScopedCookieStorage] Skipping cookie read for "${key}" - document is undefined (SSR)`);
return undefined;
}
// First try the scoped cookie
const scopedValue = await getCookie(this.getScopedKey(key));
if (scopedValue != null) {
return scopedValue;
}
// Only fall back to legacy cookies for refresh tokens, not JWTs.
// This prevents using a JWT from the wrong project (which causes audience mismatch warnings).
// For refresh tokens, if the legacy token is for a different project, the server will reject
// the refresh attempt and the SDK will call logout(), cleaning up the legacy cookie.
if (key === REFRESH_TOKEN_PREFIX) {
return await getCookie(key);
}
return undefined;
}

async set(key: string, value: string, expiresAt?: string): Promise<void> {
if (typeof document === "undefined") {
return;
}
// Always write to the scoped cookie
return await setCookie(this.getScopedKey(key), value, expiresAt);
}

async remove(key: string): Promise<void> {
if (typeof document === "undefined") {
return;
}
// Remove scoped cookie (also remove legacy cookie for cleanup during logout)
await deleteCookie(this.getScopedKey(key));
await deleteCookie(key);
}
}

export function getDefaultStorageProvider(): StorageProvider {
return new CookieStorage();
}

export function getScopedStorageProvider(apiKey: string): StorageProvider {
return new ScopedCookieStorage(apiKey);
}