diff --git a/.changeset/server-auth-getuser-errors.md b/.changeset/server-auth-getuser-errors.md new file mode 100644 index 0000000000..308a3ee0b1 --- /dev/null +++ b/.changeset/server-auth-getuser-errors.md @@ -0,0 +1,6 @@ +--- +"@crossmint/server-sdk": patch +"@crossmint/common-sdk-auth": patch +--- + +`CrossmintAuth.getUser` now throws a `CrossmintAuthenticationError` when Crossmint rejects the request, instead of returning the API's error body as if it were the user. The error carries the HTTP `status` and the API's message, and on a 401 or 403 it says that `getUser` needs a server API key with the `users.read` scope. `getUser` now returns `SDKExternalUser` instead of `any`, and the server SDK exports `CrossmintAuthenticationError` and `SDKExternalUser`. Failed session refreshes and logouts also report the status and the API's message. diff --git a/packages/common/auth/src/CrossmintAuth.ts b/packages/common/auth/src/CrossmintAuth.ts index 2a8abc74ad..2ab4a2da57 100644 --- a/packages/common/auth/src/CrossmintAuth.ts +++ b/packages/common/auth/src/CrossmintAuth.ts @@ -1,5 +1,6 @@ import { type Crossmint, CrossmintApiClient } from "@crossmint/common-sdk-base"; import { AUTH_SDK_ROOT_ENDPOINT, SDK_NAME, SDK_VERSION } from "./utils/constants"; +import { authErrorFromResponse } from "./utils/authErrorFromResponse"; import { type AuthMaterialWithUser, CrossmintAuthenticationError, type AuthMaterialResponse } from "./types"; export type CrossmintAuthOptions = { @@ -51,7 +52,7 @@ export class CrossmintAuth { }); if (!result.ok) { - throw new CrossmintAuthenticationError(result.statusText); + throw await authErrorFromResponse(result, "Failed to refresh the session"); } const resultJson = (await result.json()) as AuthMaterialResponse; @@ -76,12 +77,11 @@ export class CrossmintAuth { }, }); - const resultJson = await result.json(); if (!result.ok) { - throw new CrossmintAuthenticationError(resultJson.message); + throw await authErrorFromResponse(result, "Failed to refresh the session from the custom refresh route"); } - return resultJson; + return await result.json(); } protected async logoutFromDefaultRoute(refreshToken: string) { diff --git a/packages/common/auth/src/types/errors.ts b/packages/common/auth/src/types/errors.ts index f1ad99e991..cfef90bb23 100644 --- a/packages/common/auth/src/types/errors.ts +++ b/packages/common/auth/src/types/errors.ts @@ -1 +1,9 @@ -export class CrossmintAuthenticationError extends Error {} +export class CrossmintAuthenticationError extends Error { + /** HTTP status of the Crossmint API response that caused the error, when there was one. */ + public readonly status?: number; + + constructor(message?: string, options?: { status?: number }) { + super(message); + this.status = options?.status; + } +} diff --git a/packages/common/auth/src/utils/auth-error-from-response.test.ts b/packages/common/auth/src/utils/auth-error-from-response.test.ts new file mode 100644 index 0000000000..2f4e06294e --- /dev/null +++ b/packages/common/auth/src/utils/auth-error-from-response.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { authErrorFromResponse } from "./authErrorFromResponse"; + +describe("authErrorFromResponse", () => { + const cases: { name: string; response: Response; hint?: string; message: string }[] = [ + { + name: "uses the API's message", + response: new Response(JSON.stringify({ message: "User not found" }), { status: 404 }), + message: "Failed to get user (404: User not found)", + }, + { + name: "joins a list of validation messages", + response: new Response(JSON.stringify({ message: ["refresh must be a string", "refresh is required"] }), { + status: 400, + }), + message: "Failed to get user (400: refresh must be a string, refresh is required)", + }, + { + name: "falls back to the error field", + response: new Response(JSON.stringify({ error: "Bad Gateway" }), { status: 502 }), + message: "Failed to get user (502: Bad Gateway)", + }, + { + name: "falls back to the status text when the body is not JSON", + response: new Response("nope", { status: 503, statusText: "Service Unavailable" }), + message: "Failed to get user (503: Service Unavailable)", + }, + { + name: "reports only the status when there is no body and no status text", + response: new Response(null, { status: 500 }), + message: "Failed to get user (500)", + }, + { + name: "appends the hint", + response: new Response(JSON.stringify({ message: "Forbidden" }), { status: 403 }), + hint: "needs users.read", + message: "Failed to get user (403: Forbidden). needs users.read", + }, + ]; + + it.each(cases)("$name", async ({ response, hint, message }) => { + const error = await authErrorFromResponse(response, "Failed to get user", hint); + + expect(error.message).toBe(message); + expect(error.status).toBe(response.status); + }); +}); diff --git a/packages/common/auth/src/utils/authErrorFromResponse.ts b/packages/common/auth/src/utils/authErrorFromResponse.ts new file mode 100644 index 0000000000..c5a45200a7 --- /dev/null +++ b/packages/common/auth/src/utils/authErrorFromResponse.ts @@ -0,0 +1,31 @@ +import { CrossmintAuthenticationError } from "../types/errors"; + +/** + * Build a `CrossmintAuthenticationError` from a failed Crossmint API response. The error keeps the HTTP status + * and the API's own message, so the caller sees why the request failed. `hint` is appended to the message. + */ +export async function authErrorFromResponse( + response: Response, + action: string, + hint?: string +): Promise { + const details = (await readApiErrorMessage(response)) ?? response.statusText; + const message = `${action} (${response.status}${details ? `: ${details}` : ""})${hint ? `. ${hint}` : ""}`; + return new CrossmintAuthenticationError(message, { status: response.status }); +} + +async function readApiErrorMessage(response: Response): Promise { + try { + const body = JSON.parse(await response.text()); + const message = body?.message ?? body?.error; + if (typeof message === "string") { + return message; + } + if (Array.isArray(message)) { + return message.join(", "); + } + } catch (_) { + // The body is missing or not JSON: fall back to the status text. + } + return undefined; +} diff --git a/packages/common/auth/src/utils/index.ts b/packages/common/auth/src/utils/index.ts index a23c59b3ba..c7266d2d1f 100644 --- a/packages/common/auth/src/utils/index.ts +++ b/packages/common/auth/src/utils/index.ts @@ -1,2 +1,3 @@ export * from "./constants"; export * from "./isEmailValid"; +export * from "./authErrorFromResponse"; diff --git a/packages/server/README.md b/packages/server/README.md index 3ee7354ed8..ffc34eb69e 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -10,6 +10,17 @@ To install the SDK, you can use npm or yarn: npm install @crossmint/server-sdk ``` +## API key and scopes + +Use a server-side API key (`sk_...`) from the Crossmint Console. Which scopes it needs depends on the methods you call: + +| Method | Scope | +| ------------------------------------------------ | ------------ | +| `getSession`, `handleCustomRefresh`, `logout` | None | +| `getUser` | `users.read` | + +`getSession` verifies the JWT with Crossmint's public keys and refreshes the session through endpoints that do not check scopes. `getUser` reads the user's profile, so the key needs `users.read`. Without it, `getUser` throws a `CrossmintAuthenticationError` with `status` 403. + ## Usage To use the SDK in your application, follow these steps: @@ -49,7 +60,25 @@ const { jwt, userId } = await crossmintAuth.getSession({ }); ``` -3. Store the authentication material in cookies +3. Get the user's profile + +`getSession` returns the user's ID. To get their email, phone number or linked X account, pass the ID to `getUser`. It needs the `users.read` scope on your API key: + +```ts +import { CrossmintAuthenticationError } from "@crossmint/server-sdk"; + +try { + const user = await crossmintAuth.getUser(userId); // { id, email?, phoneNumber?, twitter? } +} catch (error) { + if (error instanceof CrossmintAuthenticationError) { + // error.status is 403 when the API key lacks users.read, 404 when the user has not signed in to this project + console.error(error.status, error.message); + } + throw error; +} +``` + +4. Store the authentication material in cookies If you are using a framework with access to the response object, you can store the authentication material in cookies by passing the response object to the `getSession` method: @@ -57,7 +86,7 @@ If you are using a framework with access to the response object, you can store t const { jwt, userId } = await crossmintAuth.getSession(request, response); ``` -4. Logout +5. Logout ```ts await crossmintAuth.logout(request); diff --git a/packages/server/src/auth/CrossmintAuthServer.ts b/packages/server/src/auth/CrossmintAuthServer.ts index 7e9477316c..ceecee9607 100644 --- a/packages/server/src/auth/CrossmintAuthServer.ts +++ b/packages/server/src/auth/CrossmintAuthServer.ts @@ -8,6 +8,8 @@ import { type AuthMaterial, CROSSMINT_API_VERSION, type CookieOptions, + type SDKExternalUser, + authErrorFromResponse, } from "@crossmint/common-sdk-auth"; import { validateAPIKey, type Crossmint, type CrossmintApiClient } from "@crossmint/common-sdk-base"; import { @@ -34,7 +36,8 @@ export type CrossmintAuthServerOptions = CrossmintAuthOptions & { * fetch users, and manage auth cookies. Works with both Node.js (`IncomingMessage`/`ServerResponse`) * and Fetch API (`Request`/`Response`) request/response objects. * - * Create an instance with {@link CrossmintAuthServer.from}. + * Create an instance with {@link CrossmintAuthServer.from} and a server-side API key. Sessions, refreshes and + * logout need no API key scope; {@link CrossmintAuthServer.getUser} needs `users.read`. */ export class CrossmintAuthServer extends CrossmintAuth { private cookieOptions: CookieOptions; @@ -89,16 +92,33 @@ export class CrossmintAuthServer extends CrossmintAuth { } } - /** Fetch the Crossmint user associated with the given external user ID. */ - public async getUser(externalUserId: string) { - const result = await this.apiClient.get(`api/${CROSSMINT_API_VERSION}/sdk/auth/user/${externalUserId}`, { - headers: { - "Content-Type": "application/json", - }, - }); + /** + * Fetch the user with the given ID (the `userId` from {@link CrossmintAuthServer.getSession}, the JWT's `sub`). + * Needs a server API key with the `users.read` scope. + * + * @throws CrossmintAuthenticationError with the HTTP `status` if Crossmint rejects the request, for example + * 403 when the API key lacks the `users.read` scope or 404 when the user has not signed in to this project. + * @throws ApiClientError on a 5xx response, as every Crossmint API call does. + */ + public async getUser(externalUserId: string): Promise { + const result = await this.apiClient.get( + `api/${CROSSMINT_API_VERSION}/sdk/auth/user/${encodeURIComponent(externalUserId)}`, + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (!result.ok) { + const hint = + result.status === 401 || result.status === 403 + ? "getUser needs a server API key with the users.read scope" + : undefined; + throw await authErrorFromResponse(result, "Failed to get user", hint); + } - const user = await result.json(); - return user; + return (await result.json()) as SDKExternalUser; } /** @@ -185,7 +205,10 @@ export class CrossmintAuthServer extends CrossmintAuth { if (request != null) { const authCookies = getAuthCookies(request); if (authCookies != null) { - await this.logoutFromDefaultRoute(authCookies.refreshToken); + const result = await this.logoutFromDefaultRoute(authCookies.refreshToken); + if (!result.ok) { + throw await authErrorFromResponse(result, "Failed to log out"); + } } } } catch (error) { diff --git a/packages/server/src/auth/crossmint-auth-server.test.ts b/packages/server/src/auth/crossmint-auth-server.test.ts index d4e17d7bd4..d930ccd8de 100644 --- a/packages/server/src/auth/crossmint-auth-server.test.ts +++ b/packages/server/src/auth/crossmint-auth-server.test.ts @@ -457,9 +457,7 @@ describe("CrossmintAuthServer", () => { it("fetches user data for a given external user ID", async () => { const mockExternalUserId = "external-user-123"; const mockUserData = { id: "user456", email: "user@example.com" }; - mockApiClient.get.mockResolvedValue({ - json: () => Promise.resolve(mockUserData), - }); + mockApiClient.get.mockResolvedValue(new Response(JSON.stringify(mockUserData), { status: 200 })); const result = await crossmintAuthServer.getUser(mockExternalUserId); @@ -469,6 +467,53 @@ describe("CrossmintAuthServer", () => { expect.any(Object) ); }); + + it("encodes the user ID so it cannot change the request path", async () => { + mockApiClient.get.mockResolvedValue(new Response(JSON.stringify({ id: "x" }), { status: 200 })); + + await crossmintAuthServer.getUser("a/b?c"); + + expect(mockApiClient.get).toHaveBeenCalledWith( + "api/2024-09-26/sdk/auth/user/a%2Fb%3Fc", + expect.any(Object) + ); + }); + + it("throws with the API message and the required scope when the API key lacks users.read", async () => { + const apiMessage = + "The API key provided doesn't have the required scopes to perform this action: users.read. It has the scopes: wallets.read."; + mockApiClient.get.mockResolvedValue( + new Response(JSON.stringify({ statusCode: 403, message: apiMessage, error: "Forbidden" }), { + status: 403, + statusText: "Forbidden", + }) + ); + + const error = await crossmintAuthServer.getUser("external-user-123").catch((e) => e); + + expect(error).toBeInstanceOf(CrossmintAuthenticationError); + expect(error.status).toBe(403); + expect(error.message).toContain(apiMessage); + expect(error.message).toContain("getUser needs a server API key with the users.read scope"); + }); + + it("throws with the status and no scope hint when the user is not found", async () => { + mockApiClient.get.mockResolvedValue( + new Response( + JSON.stringify({ statusCode: 404, message: "User with id external-user-123 not found." }), + { + status: 404, + statusText: "Not Found", + } + ) + ); + + const error = await crossmintAuthServer.getUser("external-user-123").catch((e) => e); + + expect(error).toBeInstanceOf(CrossmintAuthenticationError); + expect(error.status).toBe(404); + expect(error.message).toBe("Failed to get user (404: User with id external-user-123 not found.)"); + }); }); describe("storeAuthMaterial", () => { @@ -755,6 +800,27 @@ describe("CrossmintAuthServer", () => { ); }); + it("returns the API's reason when Crossmint rejects the refresh token", async () => { + const mockRequest = new Request("http://test.com", { + method: "POST", + body: JSON.stringify({ refresh: "mock-refresh-token" }), + }); + mockApiClient.post.mockResolvedValue( + new Response(JSON.stringify({ statusCode: 401, message: "Invalid refresh token" }), { + status: 401, + statusText: "Unauthorized", + }) + ); + + const result = (await crossmintAuthServer.handleCustomRefresh(mockRequest)) as Response; + + expect(result.status).toBe(401); + expect(await result.json()).toEqual({ + error: "Unauthorized", + message: "Failed to refresh the session (401: Invalid refresh token)", + }); + }); + it("throws error when refresh token is missing", async () => { const mockRequest = new Request("http://test.com", { method: "POST", diff --git a/packages/server/src/auth/index.ts b/packages/server/src/auth/index.ts index ef0fd6e94b..9cd2acb97c 100644 --- a/packages/server/src/auth/index.ts +++ b/packages/server/src/auth/index.ts @@ -1,3 +1,4 @@ export { CrossmintAuthServer as CrossmintAuth, type CrossmintAuthServerOptions } from "./CrossmintAuthServer"; export type { GenericRequest, GenericResponse } from "./types/request"; export * from "./utils"; +export { CrossmintAuthenticationError, type SDKExternalUser } from "@crossmint/common-sdk-auth";