Skip to content

Commit ad8b76c

Browse files
authored
PAV-5: Login social Google (OAuth) (#140) (#141)
## Descricao Integra o botao "Entrar com Google" no frontend com o backend OAuth, usando fluxo Full Redirect. **Backend:** - Corrigido callback OAuth para usar `req.session` (compartilhado com middleware `withSession`) em vez de criar instancia separada de `getIronSession` - Callback agora salva `session.userId` e redireciona ao frontend (`res.redirect`) em vez de retornar JSON - Adicionada variavel de ambiente `FRONTEND_URL` para controlar o redirect pos-OAuth **Frontend:** - Adicionada funcao `getGoogleAuthUrl()` no `authService.ts` - Botao Google nas paginas de login e registro agora inicia o fluxo OAuth ao clicar - Criada pagina `AuthCallback` que detecta a sessao e navega para `/app` - Adicionado `refreshUser()` no `AuthContext` para re-buscar `/auth/me` apos redirect OAuth **Testes:** - Atualizados testes unitarios e de integracao do `auth.controller` para esperar redirects (302) em vez de respostas JSON ## Linear link https://linear.app/tatame/issue/PAV-5/login-social-google-oauth ## Como foi testado Testes unitarios e de integracao passando (305/305). Login com Google testado manualmente end-to-end em ambiente local com Docker.
2 parents bda0504 + ec2e49d commit ad8b76c

16 files changed

Lines changed: 774 additions & 112 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Regras do Projeto
2+
3+
## Git
4+
5+
- NUNCA faça commits automaticamente. Sempre pergunte ao usuario antes de commitar.

backend/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ SESSION_SECRET=
1414
# CORS / frontend access (separar por vírgula)
1515
CORS_ALLOWED_ORIGINS=
1616

17+
# URL do frontend (usada para redirect pos-OAuth)
18+
FRONTEND_URL=http://localhost:5173
19+
1720
# LinkedIn search filters (defaults)
1821
SEARCH_LOCATION=Brasil
1922
SEARCH_GEO_ID=106057199
Lines changed: 15 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { randomBytes } from "crypto";
22
import { Request, Response } from "express";
3-
import { getIronSession } from "iron-session";
43
import { z } from "zod";
54
import {
65
AuthCallbackParamsSchema,
@@ -9,40 +8,17 @@ import {
98

109
import { AuthService } from "./auth.service.js";
1110

12-
interface SessionData {
13-
oauth_state?: string;
14-
userId?: string;
15-
}
16-
17-
const sessionOptions = {
18-
password: process.env.SESSION_SECRET!,
19-
cookieName: "vagas_session",
20-
21-
cookieOptions: {
22-
httpOnly: true,
23-
secure: process.env.NODE_ENV === "production",
24-
maxAge: 60 * 60 * 24 * 7,
25-
},
26-
};
27-
2811
export class AuthController {
2912
constructor(private readonly authService: AuthService) {}
3013

3114
async getUrl(req: Request, res: Response) {
3215
try {
33-
const session = await getIronSession<SessionData>(
34-
req,
35-
res,
36-
sessionOptions,
37-
);
38-
3916
const provider = OAuthProviderSchema.parse(req.params.provider);
4017

4118
const state = randomBytes(16).toString("hex");
4219

43-
session.oauth_state = state;
44-
45-
await session.save();
20+
(req.session as any).oauth_state = state;
21+
await req.session.save();
4622

4723
const url = await this.authService.getAuthUrl(provider, state);
4824

@@ -62,7 +38,7 @@ export class AuthController {
6238
}
6339

6440
async callback(req: Request, res: Response) {
65-
const session = await getIronSession<SessionData>(req, res, sessionOptions);
41+
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
6642

6743
try {
6844
const callbackUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
@@ -74,42 +50,30 @@ export class AuthController {
7450
callbackUrl,
7551
});
7652

77-
if (!session.oauth_state) {
78-
return res.status(400).json({
79-
error: "OAuth state ausente",
80-
});
53+
const oauthState = (req.session as any).oauth_state;
54+
55+
if (!oauthState) {
56+
return res.redirect(`${frontendUrl}/login?error=oauth_state_missing`);
8157
}
8258

83-
if (session.oauth_state !== params.state) {
84-
return res.status(400).json({
85-
error: "OAuth state inválido",
86-
});
59+
if (oauthState !== params.state) {
60+
return res.redirect(`${frontendUrl}/login?error=oauth_state_invalid`);
8761
}
8862

89-
delete session.oauth_state;
90-
await session.save();
63+
delete (req.session as any).oauth_state;
9164

9265
const result = await this.authService.handleCallback({
9366
...params,
9467
callbackUrl,
9568
});
9669

97-
return res.json(result);
70+
req.session.userId = result.session.userId;
71+
await req.session.save();
72+
73+
return res.redirect(`${frontendUrl}/auth/callback`);
9874
} catch (error) {
9975
console.error("OAuth callback error:", error);
100-
101-
if (error instanceof z.ZodError) {
102-
return res.status(400).json({
103-
error: "Parâmetros de callback inválidos",
104-
details: error.format(),
105-
});
106-
}
107-
108-
const message = error instanceof Error ? error.message : "Erro interno";
109-
110-
return res.status(500).json({
111-
error: message,
112-
});
76+
return res.redirect(`${frontendUrl}/login?error=oauth_failed`);
11377
}
11478
}
11579
}

backend/tests/integration/routes/auth.routes.test.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -181,13 +181,12 @@ describe("Integration - Auth Routes", () => {
181181
// ── GET /:provider/callback ───────────────────────────────────────────────
182182

183183
describe("GET /:provider/callback", () => {
184-
it("retorna 200 e dados do usuario em callback valido", async () => {
184+
it("redireciona ao frontend em callback valido", async () => {
185185
const res = await request(app)
186186
.get(`${BASE}/github/callback?code=abc123&state=valid-state-abc123`)
187-
.expect(200);
187+
.expect(302);
188188

189-
expect(res.body).toHaveProperty("user");
190-
expect(res.body.user).toHaveProperty("email", "user@example.com");
189+
expect(res.headers.location).toContain("/auth/callback");
191190
});
192191

193192
it("chama handleCallback com os params corretos", async () => {
@@ -208,79 +207,80 @@ describe("Integration - Auth Routes", () => {
208207
const session = {
209208
oauth_state: "valid-state-abc123",
210209
save: vi.fn().mockResolvedValue(undefined),
210+
userId: undefined as string | undefined,
211211
};
212212
vi.mocked(getIronSession).mockResolvedValue(session as any);
213213

214214
await request(app)
215215
.get(`${BASE}/github/callback?code=abc123&state=valid-state-abc123`)
216-
.expect(200);
216+
.expect(302);
217217

218218
expect(session.oauth_state).toBeUndefined();
219219
expect(session.save).toHaveBeenCalled();
220220
});
221221

222-
it("retorna 400 quando oauth_state da sessao esta ausente", async () => {
222+
it("redireciona ao login quando oauth_state da sessao esta ausente", async () => {
223223
vi.mocked(getIronSession).mockResolvedValue({
224224
oauth_state: undefined,
225225
save: vi.fn(),
226226
} as any);
227227

228228
const res = await request(app)
229229
.get(`${BASE}/github/callback?code=abc123&state=valid-state-abc123`)
230-
.expect(400);
230+
.expect(302);
231231

232-
expect(res.body).toHaveProperty("error", "OAuth state ausente");
232+
expect(res.headers.location).toContain("/login?error=oauth_state_missing");
233233
});
234234

235-
it("retorna 400 quando state do query nao confere com o da sessao", async () => {
235+
it("redireciona ao login quando state do query nao confere com o da sessao", async () => {
236236
vi.mocked(getIronSession).mockResolvedValue({
237237
oauth_state: "outro-state",
238238
save: vi.fn(),
239239
} as any);
240240

241241
const res = await request(app)
242242
.get(`${BASE}/github/callback?code=abc123&state=state-errado`)
243-
.expect(400);
243+
.expect(302);
244244

245-
expect(res.body).toHaveProperty("error", "OAuth state inválido");
245+
expect(res.headers.location).toContain("/login?error=oauth_state_invalid");
246246
});
247247

248-
it("retorna 400 para provider invalido nos params (ZodError)", async () => {
248+
it("redireciona ao login para provider invalido nos params", async () => {
249249
vi.mocked(getIronSession).mockResolvedValue({
250250
oauth_state: "valid-state-abc123",
251251
save: vi.fn(),
252252
} as any);
253253

254254
const res = await request(app)
255255
.get(`${BASE}/twitter/callback?code=abc&state=valid-state-abc123`)
256-
.expect(400);
256+
.expect(302);
257257

258-
expect(res.body).toHaveProperty("error", "Parâmetros de callback inválidos");
258+
expect(res.headers.location).toContain("/login?error=oauth_failed");
259259
});
260260

261-
it("retorna 400 quando code esta ausente (ZodError)", async () => {
261+
it("redireciona ao login quando code esta ausente", async () => {
262262
vi.mocked(getIronSession).mockResolvedValue({
263263
oauth_state: "valid-state-abc123",
264264
save: vi.fn(),
265265
} as any);
266266

267267
const res = await request(app)
268268
.get(`${BASE}/github/callback?state=valid-state-abc123`)
269-
.expect(400);
269+
.expect(302);
270270

271-
expect(res.body).toHaveProperty("error");
271+
expect(res.headers.location).toContain("/login?error=oauth_failed");
272272
});
273273

274-
it("retorna 500 quando handleCallback lanca erro", async () => {
274+
it("redireciona ao login quando handleCallback lanca erro", async () => {
275275
mockAuthService.handleCallback.mockRejectedValueOnce(
276276
new Error("upstream OAuth error"),
277277
);
278278

279279
const res = await request(app)
280280
.get(`${BASE}/github/callback?code=abc123&state=valid-state-abc123`)
281-
.expect(500);
281+
.expect(302);
282282

283-
expect(res.body).toHaveProperty("error", "upstream OAuth error");
283+
expect(res.headers.location).toContain("/login?error=oauth_failed");
284284
});
285285
});
286286

backend/tests/unit/modules/auth/auth.controller.test.ts

Lines changed: 24 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
import { Request, Response } from "express";
2-
import { getIronSession } from "iron-session";
32
import { beforeEach, describe, expect, it, vi } from "vitest";
43
import { z } from "zod";
5-
import { AuthController } from "../../../../src/modules/auth/auth.controller"; // Ajuste o caminho se necessário
4+
import { AuthController } from "../../../../src/modules/auth/auth.controller";
65

7-
// Mock das dependências externas
8-
vi.mock("iron-session", () => ({
9-
getIronSession: vi.fn(),
10-
}));
11-
12-
// Mock dos Schemas do Zod para isolar o comportamento se necessário,
13-
// mas vamos garantir que os inputs passem por eles perfeitamente.
146
vi.mock("../../../../src/types/auth.types.js", () => ({
157
OAuthProviderSchema: {
168
parse: vi.fn((val) => {
@@ -35,37 +27,37 @@ describe("AuthController", () => {
3527

3628
beforeEach(() => {
3729
vi.stubEnv("SESSION_SECRET", "um-password-longo-com-mais-de-32-caracteres");
30+
vi.stubEnv("FRONTEND_URL", "http://localhost:5173");
3831
vi.clearAllMocks();
3932

40-
// Mock do AuthService
4133
authServiceMock = {
4234
getAuthUrl: vi.fn().mockResolvedValue("https://provider.com/auth"),
4335
handleCallback: vi
4436
.fn()
45-
.mockResolvedValue({ token: "jwt-token", user: { id: "1" } }),
37+
.mockResolvedValue({ user: { id: "1" }, session: { userId: "1" } }),
4638
};
4739

4840
authController = new AuthController(authServiceMock);
4941

50-
// Mock da Sessão do iron-session
5142
sessionMock = {
5243
save: vi.fn().mockResolvedValue(undefined),
5344
oauth_state: undefined,
45+
userId: undefined,
5446
};
55-
(getIronSession as any).mockResolvedValue(sessionMock);
5647

57-
// Mocks do Express Request e Response
5848
reqMock = {
5949
params: {},
6050
query: {},
6151
protocol: "http",
6252
get: vi.fn().mockReturnValue("localhost:3000"),
6353
originalUrl: "/auth/callback",
54+
session: sessionMock,
6455
};
6556

6657
resMock = {
6758
json: vi.fn().mockReturnThis(),
6859
status: vi.fn().mockReturnThis(),
60+
redirect: vi.fn().mockReturnThis(),
6961
};
7062
});
7163

@@ -100,14 +92,15 @@ describe("AuthController", () => {
10092
});
10193

10294
describe("callback", () => {
103-
it("deve processar o callback com sucesso quando o state for válido", async () => {
95+
it("deve redirecionar ao frontend apos callback valido", async () => {
10496
sessionMock.oauth_state = "state_secreto_123";
10597
reqMock.params = { provider: "google" };
10698
reqMock.query = { code: "auth_code_abc", state: "state_secreto_123" };
10799

108100
await authController.callback(reqMock as Request, resMock as Response);
109101

110-
expect(sessionMock.oauth_state).toBeUndefined(); // Deve deletar após o uso
102+
expect(sessionMock.oauth_state).toBeUndefined();
103+
expect(sessionMock.userId).toBe("1");
111104
expect(sessionMock.save).toHaveBeenCalled();
112105
expect(authServiceMock.handleCallback).toHaveBeenCalledWith(
113106
expect.objectContaining({
@@ -117,39 +110,36 @@ describe("AuthController", () => {
117110
callbackUrl: "http://localhost:3000/auth/callback",
118111
}),
119112
);
120-
expect(resMock.json).toHaveBeenCalledWith({
121-
token: "jwt-token",
122-
user: { id: "1" },
123-
});
113+
expect(resMock.redirect).toHaveBeenCalledWith(
114+
"http://localhost:5173/auth/callback",
115+
);
124116
});
125117

126-
it("deve retornar 400 se o oauth_state estiver ausente na sessão", async () => {
118+
it("deve redirecionar ao login se o oauth_state estiver ausente na sessão", async () => {
127119
sessionMock.oauth_state = undefined;
128120
reqMock.params = { provider: "google" };
129121
reqMock.query = { code: "code", state: "any_state" };
130122

131123
await authController.callback(reqMock as Request, resMock as Response);
132124

133-
expect(resMock.status).toHaveBeenCalledWith(400);
134-
expect(resMock.json).toHaveBeenCalledWith({
135-
error: "OAuth state ausente",
136-
});
125+
expect(resMock.redirect).toHaveBeenCalledWith(
126+
"http://localhost:5173/login?error=oauth_state_missing",
127+
);
137128
});
138129

139-
it("deve retornar 400 se o state da query for diferente do state da sessão", async () => {
130+
it("deve redirecionar ao login se o state da query for diferente do state da sessão", async () => {
140131
sessionMock.oauth_state = "state_original";
141132
reqMock.params = { provider: "google" };
142133
reqMock.query = { code: "code", state: "state_ataque_csrf" };
143134

144135
await authController.callback(reqMock as Request, resMock as Response);
145136

146-
expect(resMock.status).toHaveBeenCalledWith(400);
147-
expect(resMock.json).toHaveBeenCalledWith({
148-
error: "OAuth state inválido",
149-
});
137+
expect(resMock.redirect).toHaveBeenCalledWith(
138+
"http://localhost:5173/login?error=oauth_state_invalid",
139+
);
150140
});
151141

152-
it("deve retornar 500 se o serviço falhar", async () => {
142+
it("deve redirecionar ao login com erro se o serviço falhar", async () => {
153143
sessionMock.oauth_state = "valid_state";
154144
reqMock.params = { provider: "google" };
155145
reqMock.query = { code: "code", state: "valid_state" };
@@ -160,10 +150,9 @@ describe("AuthController", () => {
160150

161151
await authController.callback(reqMock as Request, resMock as Response);
162152

163-
expect(resMock.status).toHaveBeenCalledWith(500);
164-
expect(resMock.json).toHaveBeenCalledWith({
165-
error: "Falha na comunicação com o provedor",
166-
});
153+
expect(resMock.redirect).toHaveBeenCalledWith(
154+
"http://localhost:5173/login?error=oauth_failed",
155+
);
167156
});
168157
});
169158
});

0 commit comments

Comments
 (0)