Skip to content

Commit

Permalink
Auth Server SDK: Stores new auth material cookies (#817)
Browse files Browse the repository at this point in the history
Stores new auth material cookies
  • Loading branch information
AlbertoElias authored Oct 22, 2024
1 parent 63ea1dc commit fe06a3e
Show file tree
Hide file tree
Showing 20 changed files with 247 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .changeset/pink-lobsters-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@crossmint/common-sdk-auth": patch
---

Improves type naming
5 changes: 5 additions & 0 deletions .changeset/slimy-chairs-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@crossmint/server-sdk": minor
---

Adds storeAuthMaterial to store auth material in cookies and does so in getSession
5 changes: 5 additions & 0 deletions .changeset/thirty-birds-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@crossmint/client-sdk-react-ui": patch
---

Use new type names for auth
4 changes: 2 additions & 2 deletions packages/client/ui/react-ui/src/components/auth/AuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { z } from "zod";
import { IFrameWindow } from "@crossmint/client-sdk-window";
import type { UIConfig } from "@crossmint/common-sdk-base";
import { CrossmintInternalEvents } from "@crossmint/client-sdk-base";
import type { AuthMaterial } from "@crossmint/common-sdk-auth";
import type { AuthMaterialWithUser } from "@crossmint/common-sdk-auth";
import type { LoginMethod } from "@/providers";

import X from "../../icons/x";
Expand All @@ -25,7 +25,7 @@ type IncomingModalIframeEventsType = {
type AuthModalProps = {
setModalOpen: (open: boolean) => void;
apiKey: string;
fetchAuthMaterial: (refreshToken: string) => Promise<AuthMaterial>;
fetchAuthMaterial: (refreshToken: string) => Promise<AuthMaterialWithUser>;
baseUrl: string;
appearance?: UIConfig;
loginMethods?: LoginMethod[];
Expand Down
6 changes: 3 additions & 3 deletions packages/client/ui/react-ui/src/hooks/useRefreshToken.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { AuthMaterial } from "@crossmint/common-sdk-auth";
import type { AuthMaterialWithUser } from "@crossmint/common-sdk-auth";
import { type CrossmintAuthService, getJWTExpiration } from "@crossmint/client-sdk-auth";
import { queueTask } from "@crossmint/client-sdk-base";

Expand Down Expand Up @@ -64,7 +64,7 @@ describe("useRefreshToken", () => {

it("should refresh token if refresh token is present", async () => {
const mockRefreshToken = "mock-refresh-token";
const mockAuthMaterial: AuthMaterial = {
const mockAuthMaterial: AuthMaterialWithUser = {
jwt: "mock-jwt-token",
refreshToken: {
secret: "mock-secret",
Expand Down Expand Up @@ -98,7 +98,7 @@ describe("useRefreshToken", () => {

it("should schedule next refresh before token expiration", async () => {
const mockRefreshToken = "mock-refresh-token";
const mockAuthMaterial: AuthMaterial = {
const mockAuthMaterial: AuthMaterialWithUser = {
jwt: "mock-jwt-token",
refreshToken: {
secret: "mock-secret",
Expand Down
4 changes: 2 additions & 2 deletions packages/client/ui/react-ui/src/hooks/useRefreshToken.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from "react";

import type { AuthMaterial } from "@crossmint/common-sdk-auth";
import type { AuthMaterialWithUser } from "@crossmint/common-sdk-auth";
import type { CrossmintAuthService } from "@crossmint/client-sdk-auth";
import { getJWTExpiration } from "@crossmint/client-sdk-auth";
import { queueTask, type CancellableTask } from "@crossmint/client-sdk-base";
Expand All @@ -13,7 +13,7 @@ const TIME_BEFORE_EXPIRING_JWT_IN_SECONDS = 120;

type UseAuthTokenRefreshProps = {
crossmintAuthService: CrossmintAuthService;
setAuthMaterial: (authMaterial: AuthMaterial) => void;
setAuthMaterial: (authMaterial: AuthMaterialWithUser) => void;
logout: () => void;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { type UIConfig, validateApiKeyAndGetCrossmintBaseUrl } from "@crossmint/
import {
SESSION_PREFIX,
REFRESH_TOKEN_PREFIX,
type AuthMaterial,
type AuthMaterialWithUser,
type SDKExternalUser,
} from "@crossmint/common-sdk-auth";

Expand Down Expand Up @@ -71,7 +71,7 @@ export function CrossmintAuthProvider({
const crossmintBaseUrl = validateApiKeyAndGetCrossmintBaseUrl(crossmint.apiKey);
const [modalOpen, setModalOpen] = useState(false);

const setAuthMaterial = (authMaterial: AuthMaterial) => {
const setAuthMaterial = (authMaterial: AuthMaterialWithUser) => {
setCookie(SESSION_PREFIX, authMaterial.jwt);
setCookie(REFRESH_TOKEN_PREFIX, authMaterial.refreshToken.secret, authMaterial.refreshToken.expiresAt);
setJwt(authMaterial.jwt);
Expand Down Expand Up @@ -123,7 +123,7 @@ export function CrossmintAuthProvider({
return "logged-out";
};

const fetchAuthMaterial = async (refreshToken: string): Promise<AuthMaterial> => {
const fetchAuthMaterial = async (refreshToken: string): Promise<AuthMaterialWithUser> => {
const authMaterial = await crossmintAuthService.refreshAuthMaterial(refreshToken);
setAuthMaterial(authMaterial);
return authMaterial;
Expand Down
4 changes: 2 additions & 2 deletions packages/common/auth/src/services/CrossmintAuthService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { APIErrorService, BaseCrossmintService } from "@crossmint/client-sdk-base";

import { authLogger } from "./logger";
import type { AuthMaterial } from "@/types";
import type { AuthMaterialWithUser } from "@/types";

export class CrossmintAuthService extends BaseCrossmintService {
protected apiErrorService = new APIErrorService<never>({});
Expand All @@ -11,7 +11,7 @@ export class CrossmintAuthService extends BaseCrossmintService {
return `${this.crossmintBaseUrl}/.well-known/jwks.json`;
}

async refreshAuthMaterial(refreshToken: string): Promise<AuthMaterial> {
async refreshAuthMaterial(refreshToken: string): Promise<AuthMaterialWithUser> {
const result = await this.fetchCrossmintAPI(
"2024-09-26/session/sdk/auth/refresh",
{ method: "POST", body: JSON.stringify({ refresh: refreshToken }) },
Expand Down
14 changes: 9 additions & 5 deletions packages/common/auth/src/types/authmaterial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@ export type AuthMaterialBasic = {
refreshToken: string;
};

interface RefreshToken {
secret: string;
expiresAt: string;
}

export type AuthMaterial = {
jwt: string;
refreshToken: RefreshToken;
};

export type AuthMaterialWithUser = AuthMaterial & {
user: SDKExternalUser;
};

Expand All @@ -24,5 +22,11 @@ export type AuthMaterialResponse = {

export type AuthSession = {
jwt: string;
refreshToken: RefreshToken;
userId: string;
};

interface RefreshToken {
secret: string;
expiresAt: string;
}
5 changes: 5 additions & 0 deletions packages/common/auth/src/types/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type CookieOptions = {
name: string;
value: string;
expiresAt?: string;
};
1 change: 1 addition & 0 deletions packages/common/auth/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./user";
export * from "./authmaterial";
export * from "./errors";
export * from "./cookies";
52 changes: 50 additions & 2 deletions packages/server/src/auth/CrossmintAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type Crossmint, CrossmintApiClient } from "@crossmint/common-sdk-base";
import { type AuthMaterialBasic, CrossmintAuthenticationError } from "@crossmint/common-sdk-auth";
import * as cookiesUtils from "./utils/cookies";
import * as jwtUtils from "./utils/jwt";
import type { GenericRequest } from "./types/request";
import type { GenericRequest, GenericResponse } from "./types/request";

vi.mock("@crossmint/common-sdk-base");
vi.mock("./utils/cookies");
Expand All @@ -15,6 +15,7 @@ describe("CrossmintAuth", () => {
const mockCrossmint = { projectId: "test-project-id" };
const mockApiClient = {
baseUrl: "https://api.crossmint.com",
get: vi.fn(),
post: vi.fn(),
};

Expand Down Expand Up @@ -72,6 +73,10 @@ describe("CrossmintAuth", () => {

expect(result).toEqual({
jwt: "mock.jwt.token",
refreshToken: {
secret: "mock-refresh-token",
expiresAt: "",
},
userId: "user123",
});
});
Expand All @@ -83,15 +88,23 @@ describe("CrossmintAuth", () => {
json: () =>
Promise.resolve({
jwt: "new.jwt.token",
refresh: "new-refresh-token",
refresh: {
secret: "new-refresh-token",
expiresAt: "2023-12-31T23:59:59Z",
},
user: { id: "user456" },
}),
ok: true,
});

const result = await crossmintAuth.getSession(mockRequest as GenericRequest);

expect(result).toEqual({
jwt: "new.jwt.token",
refreshToken: {
secret: "new-refresh-token",
expiresAt: "2023-12-31T23:59:59Z",
},
userId: "user456",
});
expect(mockApiClient.post).toHaveBeenCalledWith(
Expand Down Expand Up @@ -124,4 +137,39 @@ describe("CrossmintAuth", () => {
);
});
});

describe("getUser", () => {
it("should fetch user data for a given external user ID", async () => {
const mockExternalUserId = "external-user-123";
const mockUserData = { id: "user456", email: "[email protected]" };
mockApiClient.get.mockResolvedValue({
json: () => Promise.resolve(mockUserData),
});

const result = await crossmintAuth.getUser(mockExternalUserId);

expect(result).toEqual(mockUserData);
expect(mockApiClient.get).toHaveBeenCalledWith(
`api/2024-09-26/sdk/auth/user/${mockExternalUserId}`,
expect.any(Object)
);
});
});

describe("storeAuthMaterial", () => {
it("should call setAuthCookies with the provided response and auth material", () => {
const mockResponse = {} as GenericResponse;
const mockAuthMaterial = {
jwt: "new.jwt.token",
refreshToken: {
secret: "new-refresh-token",
expiresAt: "2023-12-31T23:59:59Z",
},
};

crossmintAuth.storeAuthMaterial(mockResponse, mockAuthMaterial);

expect(cookiesUtils.setAuthCookies).toHaveBeenCalledWith(mockResponse, mockAuthMaterial);
});
});
});
54 changes: 46 additions & 8 deletions packages/server/src/auth/CrossmintAuth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { type Crossmint, CrossmintApiClient } from "@crossmint/common-sdk-base";
import {
CrossmintAuthenticationError,
type AuthSession,
type AuthMaterialBasic,
type AuthMaterial,
type AuthMaterialWithUser,
type AuthMaterialResponse,
type AuthSession,
type AuthMaterial,
} from "@crossmint/common-sdk-auth";
import type { GenericRequest } from "./types/request";
import { getAuthCookies } from "./utils/cookies";
import type { GenericRequest, GenericResponse } from "./types/request";
import { getAuthCookies, setAuthCookies } from "./utils/cookies";
import { verifyCrossmintJwt } from "./utils/jwt";
import { CROSSMINT_API_VERSION, SDK_NAME, SDK_VERSION } from "./utils/constants";

Expand All @@ -31,16 +32,28 @@ export class CrossmintAuth {
return new CrossmintAuth(crossmint);
}

public async getSession(options: GenericRequest | AuthMaterialBasic): Promise<AuthSession> {
public async getSession(
options: GenericRequest | AuthMaterialBasic,
response?: GenericResponse
): Promise<AuthSession> {
const { jwt, refreshToken } = "refreshToken" in options ? options : getAuthCookies(options);

if (!refreshToken) {
throw new CrossmintAuthenticationError("Refresh token not found");
}

try {
return await this.validateOrRefreshSession(jwt, refreshToken);
return await this.validateOrRefreshSession(jwt, refreshToken, response);
} catch (error) {
if (error instanceof CrossmintAuthenticationError && response != null) {
this.storeAuthMaterial(response, {
jwt: "",
refreshToken: {
secret: "",
expiresAt: "",
},
});
}
console.error("Failed to get session", error);
throw new CrossmintAuthenticationError("Failed to get session");
}
Expand All @@ -65,11 +78,22 @@ export class CrossmintAuth {
return `${this.apiClient.baseUrl}/.well-known/jwks.json`;
}

private async refreshAuthMaterial(refreshToken: string): Promise<AuthMaterial> {
public storeAuthMaterial(response: GenericResponse, authMaterial: AuthMaterial) {
setAuthCookies(response, authMaterial);
}

private async refreshAuthMaterial(refreshToken: string): Promise<AuthMaterialWithUser> {
const result = await this.apiClient.post(`api/${CROSSMINT_API_VERSION}/session/sdk/auth/refresh`, {
body: JSON.stringify({ refresh: refreshToken }),
headers: {
"Content-Type": "application/json",
},
});

if (!result.ok) {
throw new CrossmintAuthenticationError(result.statusText);
}

const resultJson = (await result.json()) as AuthMaterialResponse;

return {
Expand All @@ -79,12 +103,20 @@ export class CrossmintAuth {
};
}

private async validateOrRefreshSession(jwt: string | undefined, refreshToken: string): Promise<AuthSession> {
private async validateOrRefreshSession(
jwt: string | undefined,
refreshToken: string,
response?: GenericResponse
): Promise<AuthSession> {
if (jwt) {
try {
const decodedJwt = await this.verifyCrossmintJwt(jwt);
return {
jwt,
refreshToken: {
secret: refreshToken,
expiresAt: "",
},
userId: decodedJwt.sub as string,
};
} catch (_) {
Expand All @@ -93,8 +125,14 @@ export class CrossmintAuth {
}

const refreshedAuthMaterial = await this.refreshAuthMaterial(refreshToken);

if (response != null) {
this.storeAuthMaterial(response, refreshedAuthMaterial);
}

return {
jwt: refreshedAuthMaterial.jwt,
refreshToken: refreshedAuthMaterial.refreshToken,
userId: refreshedAuthMaterial.user.id,
};
}
Expand Down
11 changes: 10 additions & 1 deletion packages/server/src/auth/types/request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { IncomingMessage } from "http";
import type { IncomingMessage, ServerResponse } from "http";

export type GenericRequest = IncomingMessage | Request;
export type GenericResponse = ServerResponse | Response;

export function isNodeRequest(request: GenericRequest): request is IncomingMessage {
return "httpVersion" in request;
Expand All @@ -9,3 +10,11 @@ export function isNodeRequest(request: GenericRequest): request is IncomingMessa
export function isFetchRequest(request: GenericRequest): request is Request {
return "headers" in request && typeof request.headers.get === "function";
}

export function isNodeResponse(response: GenericResponse): response is ServerResponse {
return "setHeader" in response;
}

export function isFetchResponse(response: GenericResponse): response is Response {
return "headers" in response && typeof response.headers.append === "function";
}
Loading

0 comments on commit fe06a3e

Please sign in to comment.