Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions .changeset/server-auth-getuser-errors.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions packages/common/auth/src/CrossmintAuth.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
10 changes: 9 additions & 1 deletion packages/common/auth/src/types/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
47 changes: 47 additions & 0 deletions packages/common/auth/src/utils/authErrorFromResponse.test.ts
Original file line number Diff line number Diff line change
@@ -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("<html>nope</html>", { 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);
});
});
31 changes: 31 additions & 0 deletions packages/common/auth/src/utils/authErrorFromResponse.ts
Original file line number Diff line number Diff line change
@@ -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<CrossmintAuthenticationError> {
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<string | undefined> {
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;
}
1 change: 1 addition & 0 deletions packages/common/auth/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./constants";
export * from "./isEmailValid";
export * from "./authErrorFromResponse";
33 changes: 31 additions & 2 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
albertoelias-crossmint marked this conversation as resolved.
Resolved

## Usage

To use the SDK in your application, follow these steps:
Expand Down Expand Up @@ -49,15 +60,33 @@ 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:

```ts
const { jwt, userId } = await crossmintAuth.getSession(request, response);
```

4. Logout
5. Logout

```ts
await crossmintAuth.logout(request);
Expand Down
44 changes: 33 additions & 11 deletions packages/server/src/auth/CrossmintAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -89,16 +92,32 @@ 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.
*/
public async getUser(externalUserId: string): Promise<SDKExternalUser> {
const result = await this.apiClient.get(
`api/${CROSSMINT_API_VERSION}/sdk/auth/user/${encodeURIComponent(externalUserId)}`,
Comment thread
albertoelias-crossmint marked this conversation as resolved.
Resolved
{
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;
}

/**
Expand Down Expand Up @@ -185,7 +204,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) {
Expand Down
72 changes: 69 additions & 3 deletions packages/server/src/auth/crossmint-auth-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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", () => {
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading