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
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/auth-error-from-response.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
Comment thread
albertoelias-crossmint marked this conversation as resolved.
Resolved
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
45 changes: 34 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,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<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 +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) {
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