Skip to content
141 changes: 109 additions & 32 deletions packages/api/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { VerifySignatureDto, SwitchWalletDto } from "./auth.dto";
import { HttpException, InternalServerErrorException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NO_WALLET_VIEWER } from "../common/constants";
import { ExplorerSessionVerifier } from "./explorerSession";

jest.mock("@nestjs/common", () => ({
...jest.requireActual("@nestjs/common"),
Logger: jest.fn().mockReturnValue({
error: jest.fn(),
warn: jest.fn(),
}),
}));

Expand All @@ -28,7 +30,7 @@ describe("AuthController", () => {
configServiceMock = mock<ConfigService>({
get: jest.fn().mockImplementation((key: string) => configServiceValues[key]),
});
controller = new AuthController(configServiceMock);
controller = new AuthController(configServiceMock, new ExplorerSessionVerifier());
req = mock<Request>();
});

Expand All @@ -45,7 +47,7 @@ describe("AuthController", () => {
fetchSpy.mockRestore();
});

it("logins successfully with valid token, sets hasFullReadAccess true when role has full_read_access", async () => {
it("logs in successfully with valid token, sets hasFullReadAccess true when role has full_read_access", async () => {
const mockWallets = [mockWalletAddress, mockWalletAddress2];
const mockRoles = [
{ roleName: "admin", systemPermissions: ["full_read_access", "contract_deployment"] },
Expand All @@ -54,15 +56,15 @@ describe("AuthController", () => {
fetchSpy
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ wallets: mockWallets }),
json: jest.fn().mockResolvedValue({ roles: mockRoles }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ type: "user", expiresAt: new Date().toISOString() }),
json: jest.fn().mockResolvedValue({ wallets: mockWallets }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ roles: mockRoles }),
json: jest.fn().mockResolvedValue({ type: "user", expiresAt: new Date().toISOString() }),
});

const result = await controller.login(body, req);
Expand All @@ -78,26 +80,27 @@ describe("AuthController", () => {
expect(req.session.hasFullReadAccess).toBe(true);
expect(req.session.hasAdminRead).toBe(false);
expect(req.session.token).toBe(mockToken);
expect(req.session.audienceChecked).toBe(true);
expect(fetchSpy).toHaveBeenCalledWith(expect.any(URL), {
headers: { Authorization: `Bearer ${mockToken}` },
});
});

it("logins successfully and sets hasFullReadAccess false when roles have no read-all permissions", async () => {
it("logs in successfully and sets hasFullReadAccess false when roles have no read-all permissions", async () => {
const mockWallets = [mockWalletAddress];
const mockRoles = [{ roleName: "trader", systemPermissions: ["contract_deployment"] }];
fetchSpy
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ wallets: mockWallets }),
json: jest.fn().mockResolvedValue({ roles: mockRoles }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ type: "user", expiresAt: new Date().toISOString() }),
json: jest.fn().mockResolvedValue({ wallets: mockWallets }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ roles: mockRoles }),
json: jest.fn().mockResolvedValue({ type: "user", expiresAt: new Date().toISOString() }),
});

const result = await controller.login(body, req);
Expand All @@ -106,21 +109,21 @@ describe("AuthController", () => {
expect(req.session.hasFullReadAccess).toBe(false);
});

it("logins successfully and sets hasFullReadAccess true when role has full_sequencer_rpc_access", async () => {
it("logs in successfully and sets hasFullReadAccess true when role has full_sequencer_rpc_access", async () => {
const mockWallets = [mockWalletAddress];
const mockRoles = [{ roleName: "superuser", systemPermissions: ["full_sequencer_rpc_access"] }];
fetchSpy
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ wallets: mockWallets }),
json: jest.fn().mockResolvedValue({ roles: mockRoles }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ type: "user", expiresAt: new Date().toISOString() }),
json: jest.fn().mockResolvedValue({ wallets: mockWallets }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ roles: mockRoles }),
json: jest.fn().mockResolvedValue({ type: "user", expiresAt: new Date().toISOString() }),
});

const result = await controller.login(body, req);
Expand All @@ -129,13 +132,70 @@ describe("AuthController", () => {
expect(req.session.hasFullReadAccess).toBe(true);
});

it("throws 403 error for 403 response from permissions API", async () => {
it("logs in with the explorer's own application session", async () => {
Comment thread
mikhail-egorov marked this conversation as resolved.
fetchSpy
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ roles: [{ roleName: "user" }] }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ wallets: [mockWalletAddress] }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({
type: "user",
expiresAt: new Date().toISOString(),
oauthClientId: "block-explorer",
}),
});

const result = await controller.login(body, req);

expect(result.address).toBe(mockWalletAddress);
expect(req.session.token).toBe(mockToken);
});

it("throws 403 error when the token was issued to another application", async () => {
req.session = {};
fetchSpy
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ roles: [{ roleName: "admin", systemPermissions: ["admin_read"] }] }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ wallets: [mockWalletAddress] }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({
type: "user",
expiresAt: new Date().toISOString(),
oauthClientId: "some-dapp",
}),
});

await expect(controller.login(body, req)).rejects.toThrow(
new HttpException("Token was not issued for the block explorer", 403)
);
expect(req.session.token).toBeUndefined();
expect(req.session.hasAdminRead).toBeUndefined();
expect(req.session.audienceChecked).toBeUndefined();
});

it.each([401, 403])("rejects a junk token with upstream %s before calling current-session", async (status) => {
fetchSpy.mockResolvedValueOnce({
status: 403,
status,
json: jest.fn(),
});

await expect(controller.login(body, req)).rejects.toThrow(new HttpException("Invalid or expired token", 403));
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(new URL("https://permissions-api.example.com/api/profiles/me"), {
headers: { Authorization: `Bearer ${mockToken}` },
});
});

it("throws internal server error for invalid API response", async () => {
Expand All @@ -147,6 +207,23 @@ describe("AuthController", () => {
await expect(controller.login(body, req)).rejects.toThrow(InternalServerErrorException);
});

it("throws internal server error when the permissions API throttles the session check", async () => {
fetchSpy
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ roles: [{ roleName: "user" }] }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ wallets: [mockWalletAddress] }),
})
.mockResolvedValueOnce({ status: 429, json: jest.fn() });

await expect(controller.login(body, req)).rejects.toThrow(
new InternalServerErrorException("Authentication failed")
);
});

it("throws internal server error for network errors", async () => {
fetchSpy.mockRejectedValueOnce(new Error("Network error"));

Expand All @@ -159,19 +236,19 @@ describe("AuthController", () => {
fetchSpy
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ wallets: [] }),
json: jest.fn().mockResolvedValue({ roles: [{ roleName: "admin", systemPermissions }] }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ type: "user", expiresAt: new Date().toISOString() }),
json: jest.fn().mockResolvedValue({ wallets: [] }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ roles: [{ roleName: "admin", systemPermissions }] }),
json: jest.fn().mockResolvedValue({ type: "user", expiresAt: new Date().toISOString() }),
});
};

it("logins without a wallet when the user has full read access", async () => {
it("logs in without a wallet when the user has full read access", async () => {
mockWalletlessLogin(["full_read_access"]);

const result = await controller.login(body, req);
Expand All @@ -186,7 +263,7 @@ describe("AuthController", () => {
expect(req.session.wallets).toEqual([]);
});

it("logins without a wallet when the user has no read-all permissions", async () => {
it("logs in without a wallet when the user has no read-all permissions", async () => {
mockWalletlessLogin(["contract_deployment"]);

const result = await controller.login(body, req);
Expand All @@ -201,43 +278,43 @@ describe("AuthController", () => {
expect(req.session.wallets).toEqual([]);
});

it("throws 403 error when roles API returns 403", async () => {
it("throws 403 error when wallets API returns 403", async () => {
fetchSpy
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ wallets: [mockWalletAddress] }),
json: jest.fn().mockResolvedValue({ roles: [{ roleName: "user" }] }),
})
.mockResolvedValueOnce({
status: 403,
json: jest.fn(),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({
type: "user",
expiresAt: new Date().toISOString(),
}),
})
.mockResolvedValueOnce({
status: 403,
json: jest.fn(),
});

await expect(controller.login(body, req)).rejects.toThrow(new HttpException("Invalid or expired token", 403));
});

it("throws internal server error when roles API returns invalid data", async () => {
it("throws internal server error when wallets API returns invalid data", async () => {
fetchSpy
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ wallets: [mockWalletAddress] }),
json: jest.fn().mockResolvedValue({ roles: [{ roleName: "user" }] }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ invalid: "response" }),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({
type: "user",
expiresAt: new Date().toISOString(),
}),
})
.mockResolvedValueOnce({
status: 200,
json: jest.fn().mockResolvedValue({ invalid: "response" }),
});

await expect(controller.login(body, req)).rejects.toThrow(InternalServerErrorException);
Expand Down
42 changes: 11 additions & 31 deletions packages/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,21 @@ import { z } from "zod";
import { PrividiumApiError } from "../errors/prividiumApiError";
import { parseUserProfile } from "../api/pipes/addUserRoles.pipe";
import { NO_WALLET_VIEWER } from "../common/constants";
import { ExplorerSessionVerifier } from "./explorerSession";

const entityName = "auth";
const userWalletsSchema = z.object({ wallets: z.array(z.string()) });
const currentSessionSchema = z.object({
type: z.string(),
expiresAt: z.string().datetime(),
});

@ApiTags("Auth BFF")
@ApiExcludeController(!swagger.bffEnabled)
@Controller(entityName)
export class AuthController {
private readonly logger: Logger;

constructor(private readonly configService: ConfigService) {
constructor(
private readonly configService: ConfigService,
private readonly explorerSessions: ExplorerSessionVerifier
) {
this.logger = new Logger(AuthController.name);
}

Expand All @@ -55,10 +55,11 @@ export class AuthController {
@Req() req: Request
): Promise<{ address: string | null; wallets: string[]; hasFullReadAccess: boolean; hasAdminRead: boolean }> {
try {
const [wallets, sessionExpirationIso, { hasFullReadAccess, hasAdminRead }] = await Promise.all([
// Reject junk tokens before spending the permissions API's shared auth rate limit.
const { hasFullReadAccess, hasAdminRead } = await this.fetchUserProfile(body.token);
const [wallets, { expiresAt }] = await Promise.all([
this.fetchUserWallets(body.token),
this.fetchExpirationTimeIso(body.token),
this.fetchUserProfile(body.token),
this.explorerSessions.establish(this.configService.get("prividium.permissionsApiUrl"), body.token),
Comment thread
mikhail-egorov marked this conversation as resolved.
]);

// Store all wallets and use first address as default
Expand All @@ -68,7 +69,8 @@ export class AuthController {
req.session.token = body.token;
req.session.hasFullReadAccess = hasFullReadAccess;
req.session.hasAdminRead = hasAdminRead;
req.session.expiresAt = sessionExpirationIso;
req.session.expiresAt = expiresAt;
req.session.audienceChecked = true;
return {
address: address === NO_WALLET_VIEWER ? null : address,
wallets,
Expand Down Expand Up @@ -168,26 +170,4 @@ export class AuthController {

return parseUserProfile(await response.json());
}

private async fetchExpirationTimeIso(token: string): Promise<string> {
const response = await fetch(
new URL("/api/auth/current-session", this.configService.get("prividium.permissionsApiUrl")),
{
headers: { Authorization: `Bearer ${token}` },
}
);

// If user token expiration cannot be fetch user cannot log in to the system.
if (response.status !== 200) {
throw new PrividiumApiError("Invalid or expired token", 403);
}

const data = await response.json();
const validatedData = currentSessionSchema.safeParse(data);
if (!validatedData.success) {
throw new Error(`Invalid response from permissions API: ${JSON.stringify(validatedData.error)}`);
}

return validatedData.data.expiresAt;
}
}
3 changes: 3 additions & 0 deletions packages/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { ExplorerSessionVerifier } from "./explorerSession";

@Module({
controllers: [AuthController],
providers: [{ provide: ExplorerSessionVerifier, useValue: new ExplorerSessionVerifier() }],
exports: [ExplorerSessionVerifier],
})
export class AuthModule {}
Loading
Loading