Skip to content
Merged
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
36 changes: 36 additions & 0 deletions backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { randomBytes } from "crypto";
import { Request, Response } from "express";
import { AppError } from "../../lib/errors.js";
import type { OAuthProvider } from "../types/auth.types.js";
import { AuthCallbackParamsSchema } from "../types/auth.types.js";
import { linkProviderToUser } from "../users/functions/linkProviderToUser.js";
import { AuthService } from "./auth.service.js";

export class AuthController {
Expand All @@ -12,6 +14,13 @@ export class AuthController {
const state = randomBytes(16).toString("hex");

(req.session as { oauth_state?: string }).oauth_state = state;

if (req.query.intent === "link" && req.session.userId) {
(req.session as { oauth_intent?: string }).oauth_intent = "link";
} else {
delete (req.session as { oauth_intent?: string }).oauth_intent;
}

await req.session.save();

const url = await this.authService.getAuthUrl(provider, state);
Expand Down Expand Up @@ -43,6 +52,33 @@ export class AuthController {

delete (req.session as { oauth_state?: string }).oauth_state;

const intent = (req.session as { oauth_intent?: string }).oauth_intent;
delete (req.session as { oauth_intent?: string }).oauth_intent;

if (intent === "link" && req.session.userId) {
try {
const profile = await this.authService.getProfileFromProvider({
...params,
callbackUrl,
});
await linkProviderToUser({
userId: req.session.userId,
provider: params.provider,
profile,
});
await req.session.save();
return res.redirect(
`${frontendUrl}/perfil?linked=${params.provider}`,
);
} catch (linkError) {
const code =
linkError instanceof AppError && linkError.code === "CONFLICT"
? "provider_already_linked"
: "link_failed";
return res.redirect(`${frontendUrl}/perfil?error=${code}`);
}
}

const result = await this.authService.handleCallback({
...params,
callbackUrl,
Expand Down
28 changes: 28 additions & 0 deletions backend/src/modules/auth/connections.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// backend/src/modules/auth/connections.controller.ts
import { Request, Response } from "express";
import { AppError } from "../../lib/errors";
import { disconnectProvider } from "../users/functions/disconnectProvider";
import {
listUserConnections,
SUPPORTED_PROVIDERS,
} from "../users/functions/listUserConnections";

export class ConnectionsController {
async list(req: Request, res: Response) {
const userId = req.session.userId as string;
const result = await listUserConnections(userId);
return res.json(result);
}

async disconnect(req: Request, res: Response) {
const userId = req.session.userId as string;
const provider = req.params.provider;

if (!(SUPPORTED_PROVIDERS as readonly string[]).includes(provider)) {
throw AppError.validation("Provider inválido.");
}

await disconnectProvider({ userId, provider });
return res.json({ ok: true });
}
}
48 changes: 48 additions & 0 deletions backend/src/modules/users/functions/disconnectProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { and, eq } from "drizzle-orm";
import { db } from "../../../db/client";
import { accounts } from "../../../db/schema";
import { DB } from "../../../db/types/types";
import { AppError } from "../../../lib/errors";

type DisconnectParams = { userId: string; provider: string };

async function runDisconnect(
{ userId, provider }: DisconnectParams,
tx: DB,
): Promise<void> {
const userAccounts = await tx
.select()
.from(accounts)
.where(eq(accounts.userId, userId))
.for("update");

const hasPassword = Boolean(
await tx.query.credentials.findFirst({
where: (c, { eq }) => eq(c.userId, userId),
}),
);

const remainingProviders = new Set(
userAccounts.map((a) => a.provider).filter((p) => p !== provider),
);

const methodsAfter = remainingProviders.size + (hasPassword ? 1 : 0);

if (methodsAfter === 0) {
throw AppError.conflict(
"Não é possível desconectar seu último método de login.",
);
}

await tx
.delete(accounts)
.where(and(eq(accounts.userId, userId), eq(accounts.provider, provider)));
}

export async function disconnectProvider(
params: DisconnectParams,
tx?: DB,
): Promise<void> {
if (tx) return runDisconnect(params, tx);
return db.transaction((t) => runDisconnect(params, t as unknown as DB));
}
28 changes: 28 additions & 0 deletions backend/src/modules/users/functions/linkProviderToUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { db } from "../../../db/client";
import { DB } from "../../../db/types/types";
import { AppError } from "../../../lib/errors";
import { OAuthProfile } from "../../types/auth.types";
import { createAccount } from "./createAccount";

type LinkProviderParams = {
userId: string;
provider: string;
profile: OAuthProfile;
};

export async function linkProviderToUser(
{ userId, provider, profile }: LinkProviderParams,
tx: DB = db,
): Promise<void> {
const existingAccount = await tx.query.accounts.findFirst({
where: (acc, { eq, and }) =>
and(eq(acc.provider, provider), eq(acc.providerAccountId, profile.id)),
});

if (existingAccount) {
if (existingAccount.userId === userId) return;
throw AppError.conflict("Essa conta já está vinculada a outro usuário.");
}

await createAccount({ userId, provider, profile }, tx);
}
42 changes: 42 additions & 0 deletions backend/src/modules/users/functions/listUserConnections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { db } from "../../../db/client";
import { DB } from "../../../db/types/types";

export const SUPPORTED_PROVIDERS = ["google", "linkedin", "github"] as const;
export type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number];

export type ConnectionStatus = {
provider: SupportedProvider;
connected: boolean;
connectedAt: Date | null;
};

export type UserConnections = {
hasPassword: boolean;
connections: ConnectionStatus[];
};

export async function listUserConnections(
userId: string,
tx: DB = db,
): Promise<UserConnections> {
const userAccounts = await tx.query.accounts.findMany({
where: (acc, { eq }) => eq(acc.userId, userId),
});

const hasPassword = Boolean(
await tx.query.credentials.findFirst({
where: (c, { eq }) => eq(c.userId, userId),
}),
);

const connections: ConnectionStatus[] = SUPPORTED_PROVIDERS.map((provider) => {
const account = userAccounts.find((a) => a.provider === provider);
return {
provider,
connected: Boolean(account),
connectedAt: account?.createdAt ?? null,
};
});

return { hasPassword, connections };
}
11 changes: 11 additions & 0 deletions backend/src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
authAccountRateLimiter,
authIpRateLimiter,
} from "../middleware/rateLimit";
import { requireAuth } from "../middleware/requireAuth";
import { validate } from "../middleware/validate";
import { AuthController } from "../modules/auth/auth.controller";
import { AuthService } from "../modules/auth/auth.service";
import { ConnectionsController } from "../modules/auth/connections.controller";
import { CredentialsController } from "../modules/auth/credentials.controller";
import { CredentialsService } from "../modules/auth/credentials.service";
import { OAuthProviderSchema } from "../modules/types/auth.types";
Expand All @@ -21,6 +23,7 @@ const authService = new AuthService();
const authController = new AuthController(authService);
const credentialsService = new CredentialsService();
const credentialsController = new CredentialsController(credentialsService);
const connectionsController = new ConnectionsController();

const providerParamsSchema = z.object({
provider: OAuthProviderSchema,
Expand All @@ -38,6 +41,14 @@ router.get("/:provider/callback", (req, res, next) => {
authController.callback(req, res).catch(next);
});

// Connections (usuário logado)
router.get("/connections", requireAuth, (req, res, next) => {
connectionsController.list(req, res).catch(next);
});
router.delete("/connections/:provider", requireAuth, (req, res, next) => {
connectionsController.disconnect(req, res).catch(next);
});

// Credentials
router.post(
"/register",
Expand Down
87 changes: 87 additions & 0 deletions backend/tests/integration/routes/connections.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// backend/tests/integration/routes/connections.routes.test.ts
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppError } from "../../../src/lib/errors";

const mocks = vi.hoisted(() => ({
listUserConnections: vi.fn(),
disconnectProvider: vi.fn(),
}));

vi.mock("../../../src/modules/users/functions/listUserConnections", () => ({
listUserConnections: mocks.listUserConnections,
SUPPORTED_PROVIDERS: ["google", "linkedin", "github"],
}));

vi.mock("../../../src/modules/users/functions/disconnectProvider", () => ({
disconnectProvider: mocks.disconnectProvider,
}));

// Sessão fake: injeta userId
vi.mock("../../../src/middleware/requireAuth", () => ({
requireAuth: (req: any, _res: any, next: any) => {
req.session = { userId: "user-A" };
next();
},
}));

import { errorHandler } from "../../../src/middleware/errorHandler";
import { ConnectionsController } from "../../../src/modules/auth/connections.controller";
import { requireAuth } from "../../../src/middleware/requireAuth";

function buildApp() {
const app = express();
app.use(express.json());
const controller = new ConnectionsController();
app.get("/auth/connections", requireAuth, (req, res, next) =>
controller.list(req, res).catch(next),
);
app.delete("/auth/connections/:provider", requireAuth, (req, res, next) =>
controller.disconnect(req, res).catch(next),
);
app.use(errorHandler);
return app;
}

beforeEach(() => {
vi.clearAllMocks();
});

describe("connections routes", () => {
it("GET /auth/connections retorna status", async () => {
mocks.listUserConnections.mockResolvedValue({
hasPassword: false,
connections: [{ provider: "google", connected: true, connectedAt: null }],
});
const res = await request(buildApp()).get("/auth/connections");
expect(res.status).toBe(200);
expect(res.body.connections[0].provider).toBe("google");
});

it("DELETE /auth/connections/:provider retorna 200", async () => {
mocks.disconnectProvider.mockResolvedValue(undefined);
const res = await request(buildApp()).delete("/auth/connections/google");
expect(res.status).toBe(200);
expect(mocks.disconnectProvider).toHaveBeenCalledWith({
userId: "user-A",
provider: "google",
});
});

it("DELETE responde 409 no último método", async () => {
mocks.disconnectProvider.mockRejectedValue(
AppError.conflict("Não é possível desconectar seu último método de login."),
);
const res = await request(buildApp()).delete("/auth/connections/google");
expect(res.status).toBe(409);
expect(res.body.code).toBe("CONFLICT");
});

it("DELETE responde 400 para provider não suportado", async () => {
const res = await request(buildApp()).delete("/auth/connections/facebook");
expect(res.status).toBe(400);
expect(res.body.code).toBe("VALIDATION_ERROR");
expect(mocks.disconnectProvider).not.toHaveBeenCalled();
});
});
Loading
Loading