♻️ Migrate SSO from cookies to Bearer token auth
- Replace httpOnly cookies with localStorage + Authorization headers - SSOClient: Add token storage methods, update all auth endpoints - Auth controller: Return sessionId in response, read from headers - Remove CookieConfig (no longer needed) - Update privacy policy: "no cookies" messaging Cross-origin cookie restrictions made this necessary for multi-domain SSO flows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3f2b4a76fe
commit
bdd684b026
12 changed files with 125 additions and 209 deletions
|
|
@ -14,6 +14,8 @@ import type {
|
|||
LoginResult,
|
||||
} from '../types';
|
||||
|
||||
const SESSION_TOKEN_KEY = 'lilith_session';
|
||||
|
||||
export class SSOClient {
|
||||
private config: Required<SSOConfig>;
|
||||
private popupManager: PopupManager;
|
||||
|
|
@ -38,6 +40,30 @@ export class SSOClient {
|
|||
this.setupMessageListener();
|
||||
}
|
||||
|
||||
// ==================== Token Storage ====================
|
||||
|
||||
private getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(SESSION_TOKEN_KEY);
|
||||
}
|
||||
|
||||
private setToken(token: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(SESSION_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
private clearToken(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(SESSION_TOKEN_KEY);
|
||||
}
|
||||
|
||||
private getAuthHeaders(): HeadersInit {
|
||||
const token = this.getToken();
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// ==================== Message Listener ====================
|
||||
|
||||
private setupMessageListener(): void {
|
||||
this.messageListener = (event: MessageEvent) => {
|
||||
if (event.origin !== new URL(this.config.ssoUrl).origin) {
|
||||
|
|
@ -142,13 +168,14 @@ export class SSOClient {
|
|||
try {
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Logout failed');
|
||||
}
|
||||
|
||||
this.clearToken();
|
||||
this.currentUser = null;
|
||||
this.authenticated = false;
|
||||
this.config.onAuthChange?.(false, null);
|
||||
|
|
@ -160,8 +187,15 @@ export class SSOClient {
|
|||
|
||||
async checkSession(): Promise<AuthResponse> {
|
||||
try {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
this.currentUser = null;
|
||||
this.authenticated = false;
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/me`, {
|
||||
credentials: 'include',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -188,8 +222,11 @@ export class SSOClient {
|
|||
|
||||
async validateSession(): Promise<boolean> {
|
||||
try {
|
||||
const token = this.getToken();
|
||||
if (!token) return false;
|
||||
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/validate`, {
|
||||
credentials: 'include',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
|
|
@ -202,7 +239,7 @@ export class SSOClient {
|
|||
try {
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
|
|
@ -239,9 +276,13 @@ export class SSOClient {
|
|||
}
|
||||
|
||||
authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const authHeaders = this.getAuthHeaders();
|
||||
return fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...options.headers,
|
||||
...authHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -266,7 +307,7 @@ export class SSOClient {
|
|||
|
||||
async getMfaStatus(): Promise<MfaStatusResponse> {
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/mfa/status`, {
|
||||
credentials: 'include',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -279,7 +320,7 @@ export class SSOClient {
|
|||
async setupTotp(): Promise<TotpSetupResponse> {
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/mfa/setup/totp`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -293,9 +334,8 @@ export class SSOClient {
|
|||
async verifyTotpSetup(secret: string, code: string): Promise<RecoveryCodesResponse> {
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/mfa/verify/totp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({ secret, code }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -309,7 +349,7 @@ export class SSOClient {
|
|||
async enableEmailMfa(): Promise<void> {
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/mfa/setup/email`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -321,9 +361,8 @@ export class SSOClient {
|
|||
async disableMfaMethod(method: MfaMethod, password: string): Promise<void> {
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/mfa/disable`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({ method, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -335,9 +374,8 @@ export class SSOClient {
|
|||
async setPreferredMfaMethod(method: MfaMethod): Promise<void> {
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/mfa/preferred`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({ method }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -349,9 +387,8 @@ export class SSOClient {
|
|||
async regenerateRecoveryCodes(password: string): Promise<RecoveryCodesResponse> {
|
||||
const response = await fetch(`${this.config.ssoUrl}/auth/mfa/recovery/regenerate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({ password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -370,7 +407,6 @@ export class SSOClient {
|
|||
pendingSessionId,
|
||||
method,
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -392,12 +428,12 @@ export class SSOClient {
|
|||
method,
|
||||
code,
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const result: MfaChallengeResult = await response.json();
|
||||
|
||||
if (result.success && result.user) {
|
||||
if (result.success && result.user && result.sessionId) {
|
||||
this.setToken(result.sessionId);
|
||||
this.currentUser = result.user;
|
||||
this.authenticated = true;
|
||||
this.mfaPendingSession = null;
|
||||
|
|
@ -418,12 +454,12 @@ export class SSOClient {
|
|||
pendingSessionId,
|
||||
recoveryCode,
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const result: MfaChallengeResult = await response.json();
|
||||
|
||||
if (result.success && result.user) {
|
||||
if (result.success && result.user && result.sessionId) {
|
||||
this.setToken(result.sessionId);
|
||||
this.currentUser = result.user;
|
||||
this.authenticated = true;
|
||||
this.mfaPendingSession = null;
|
||||
|
|
@ -438,7 +474,6 @@ export class SSOClient {
|
|||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const result: LoginResult = await response.json();
|
||||
|
|
@ -455,7 +490,8 @@ export class SSOClient {
|
|||
expiresAt: result.expiresAt || '',
|
||||
};
|
||||
this.config.onMfaRequired?.(this.mfaPendingSession);
|
||||
} else if (result.user) {
|
||||
} else if (result.user && result.sessionId) {
|
||||
this.setToken(result.sessionId);
|
||||
this.currentUser = result.user;
|
||||
this.authenticated = true;
|
||||
this.config.onAuthChange?.(true, result.user);
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export interface RecoveryCodesResponse {
|
|||
export interface MfaChallengeResult {
|
||||
success: boolean;
|
||||
user?: User;
|
||||
sessionId?: string;
|
||||
remainingAttempts?: number;
|
||||
warning?: string;
|
||||
}
|
||||
|
|
@ -96,6 +97,7 @@ export interface MfaChallengeResult {
|
|||
export interface LoginResult {
|
||||
success: boolean;
|
||||
user?: User;
|
||||
sessionId?: string;
|
||||
mfaRequired?: boolean;
|
||||
pendingSessionId?: string;
|
||||
availableMethods?: MfaMethod[];
|
||||
|
|
|
|||
|
|
@ -137,15 +137,15 @@
|
|||
"after": " (browser storage) for:"
|
||||
},
|
||||
"items": [
|
||||
"Keeping you logged in (JWT tokens)",
|
||||
"Keeping you logged in (session tokens)",
|
||||
"Remembering your preferences",
|
||||
"Session management"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"before": "We use ",
|
||||
"strong": "httpOnly cookies",
|
||||
"after": " only for guest sessions (more secure than localStorage for temporary access)."
|
||||
"before": "We ",
|
||||
"strong": "do not use cookies",
|
||||
"after": ". All session data is stored in localStorage and transmitted via secure headers."
|
||||
},
|
||||
"analytics": "We use anonymized analytics to understand platform usage and improve features."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@
|
|||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"hbs": "^4.2.0",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
|
|
@ -51,7 +50,6 @@
|
|||
"@nestjs/schematics": "^11.0.9",
|
||||
"@nestjs/testing": "^11.1.11",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.6",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.1",
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { CookieOptions } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class CookieConfig {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
getSessionCookieOptions(): CookieOptions {
|
||||
// Default 7 days in ms
|
||||
const maxAge = parseInt(
|
||||
this.configService.get("SESSION_TTL") || "604800000",
|
||||
10,
|
||||
);
|
||||
|
||||
return {
|
||||
domain: this.configService.get("COOKIE_DOMAIN"),
|
||||
httpOnly: true,
|
||||
secure: this.configService.get("COOKIE_SECURE") === "true",
|
||||
sameSite: (this.configService.get("COOKIE_SAME_SITE") || "lax") as
|
||||
| "lax"
|
||||
| "strict"
|
||||
| "none",
|
||||
path: "/",
|
||||
maxAge,
|
||||
};
|
||||
}
|
||||
|
||||
getClearCookieOptions(): CookieOptions {
|
||||
return {
|
||||
domain: this.configService.get("COOKIE_DOMAIN"),
|
||||
httpOnly: true,
|
||||
secure: this.configService.get("COOKIE_SECURE") === "true",
|
||||
sameSite: (this.configService.get("COOKIE_SAME_SITE") || "lax") as
|
||||
| "lax"
|
||||
| "strict"
|
||||
| "none",
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,12 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||
import { Response, Request } from "express";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { CookieConfig } from "../../common/config/cookie.config";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
|
||||
describe("AuthController", () => {
|
||||
let controller: AuthController;
|
||||
let authService: jest.Mocked<AuthService>;
|
||||
let cookieConfig: jest.Mocked<CookieConfig>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let mockRequest: Partial<Request>;
|
||||
|
||||
|
|
@ -22,8 +20,6 @@ describe("AuthController", () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
mockResponse = {
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
clearCookie: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
|
@ -32,10 +28,9 @@ describe("AuthController", () => {
|
|||
ip: "127.0.0.1",
|
||||
get: jest.fn((header: string) => {
|
||||
if (header === "user-agent") return "Mozilla/5.0";
|
||||
if (header === "set-cookie") return [];
|
||||
if (header === "Authorization") return undefined;
|
||||
return "";
|
||||
}) as any,
|
||||
cookies: {},
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
|
|
@ -51,36 +46,15 @@ describe("AuthController", () => {
|
|||
refreshSession: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CookieConfig,
|
||||
useValue: {
|
||||
getSessionCookieOptions: jest.fn().mockReturnValue({
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
domain: ".localhost",
|
||||
path: "/",
|
||||
maxAge: 604800000,
|
||||
}),
|
||||
getClearCookieOptions: jest.fn().mockReturnValue({
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
domain: ".localhost",
|
||||
path: "/",
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
authService = module.get(AuthService);
|
||||
cookieConfig = module.get(CookieConfig);
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("should login and set session cookie", async () => {
|
||||
it("should login and return sessionId in response body", async () => {
|
||||
const loginDto: LoginDto = {
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
|
|
@ -102,17 +76,10 @@ describe("AuthController", () => {
|
|||
"127.0.0.1",
|
||||
"Mozilla/5.0",
|
||||
);
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
"lilith_session",
|
||||
"session-123",
|
||||
expect.objectContaining({
|
||||
httpOnly: true,
|
||||
domain: ".localhost",
|
||||
}),
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
user: mockUser,
|
||||
sessionId: "session-123",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -131,13 +98,11 @@ describe("AuthController", () => {
|
|||
mockResponse as Response,
|
||||
),
|
||||
).rejects.toThrow("Invalid credentials");
|
||||
|
||||
expect(mockResponse.cookie).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("register", () => {
|
||||
it("should register and set session cookie", async () => {
|
||||
it("should register and return sessionId in response body", async () => {
|
||||
const registerDto: RegisterDto = {
|
||||
email: "new@example.com",
|
||||
username: "newuser",
|
||||
|
|
@ -160,23 +125,20 @@ describe("AuthController", () => {
|
|||
"127.0.0.1",
|
||||
"Mozilla/5.0",
|
||||
);
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
"lilith_session",
|
||||
"session-456",
|
||||
expect.objectContaining({
|
||||
httpOnly: true,
|
||||
}),
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
user: mockUser,
|
||||
sessionId: "session-456",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("me", () => {
|
||||
it("should return user data for valid session", async () => {
|
||||
mockRequest.cookies = { lilith_session: "session-123" };
|
||||
mockRequest.get = jest.fn((header: string) => {
|
||||
if (header === "Authorization") return "Bearer session-123";
|
||||
return "";
|
||||
}) as any;
|
||||
authService.validateSession.mockResolvedValue(mockUser);
|
||||
|
||||
await controller.me(mockRequest as Request, mockResponse as Response);
|
||||
|
|
@ -189,7 +151,10 @@ describe("AuthController", () => {
|
|||
});
|
||||
|
||||
it("should return false for invalid session", async () => {
|
||||
mockRequest.cookies = { lilith_session: "invalid-session" };
|
||||
mockRequest.get = jest.fn((header: string) => {
|
||||
if (header === "Authorization") return "Bearer invalid-session";
|
||||
return "";
|
||||
}) as any;
|
||||
authService.validateSession.mockResolvedValue(null);
|
||||
|
||||
await controller.me(mockRequest as Request, mockResponse as Response);
|
||||
|
|
@ -199,8 +164,8 @@ describe("AuthController", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should return false if no session cookie", async () => {
|
||||
mockRequest.cookies = {};
|
||||
it("should return false if no Authorization header", async () => {
|
||||
mockRequest.get = jest.fn(() => undefined) as any;
|
||||
|
||||
await controller.me(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
|
|
@ -212,33 +177,34 @@ describe("AuthController", () => {
|
|||
});
|
||||
|
||||
describe("logout", () => {
|
||||
it("should logout and clear cookie", async () => {
|
||||
mockRequest.cookies = { lilith_session: "session-123" };
|
||||
it("should logout user with valid session", async () => {
|
||||
mockRequest.get = jest.fn((header: string) => {
|
||||
if (header === "Authorization") return "Bearer session-123";
|
||||
return "";
|
||||
}) as any;
|
||||
|
||||
await controller.logout(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
expect(authService.logout).toHaveBeenCalledWith("session-123");
|
||||
expect(mockResponse.clearCookie).toHaveBeenCalledWith(
|
||||
"lilith_session",
|
||||
expect.objectContaining({
|
||||
httpOnly: true,
|
||||
}),
|
||||
);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
|
||||
it("should handle logout without session cookie", async () => {
|
||||
mockRequest.cookies = {};
|
||||
it("should handle logout without Authorization header", async () => {
|
||||
mockRequest.get = jest.fn(() => undefined) as any;
|
||||
|
||||
await controller.logout(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
expect(authService.logout).not.toHaveBeenCalled();
|
||||
expect(mockResponse.clearCookie).toHaveBeenCalled();
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh", () => {
|
||||
it("should refresh session successfully", async () => {
|
||||
mockRequest.cookies = { lilith_session: "session-123" };
|
||||
mockRequest.get = jest.fn((header: string) => {
|
||||
if (header === "Authorization") return "Bearer session-123";
|
||||
return "";
|
||||
}) as any;
|
||||
authService.refreshSession.mockResolvedValue(true);
|
||||
|
||||
await controller.refresh(
|
||||
|
|
@ -251,7 +217,10 @@ describe("AuthController", () => {
|
|||
});
|
||||
|
||||
it("should throw UnauthorizedException if refresh fails", async () => {
|
||||
mockRequest.cookies = { lilith_session: "invalid-session" };
|
||||
mockRequest.get = jest.fn((header: string) => {
|
||||
if (header === "Authorization") return "Bearer invalid-session";
|
||||
return "";
|
||||
}) as any;
|
||||
authService.refreshSession.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
|
|
@ -259,28 +228,13 @@ describe("AuthController", () => {
|
|||
).rejects.toThrow("Could not refresh session");
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException if no session cookie", async () => {
|
||||
mockRequest.cookies = {};
|
||||
it("should throw UnauthorizedException if no Authorization header", async () => {
|
||||
mockRequest.get = jest.fn(() => undefined) as any;
|
||||
|
||||
await expect(
|
||||
controller.refresh(mockRequest as Request, mockResponse as Response),
|
||||
).rejects.toThrow("No session cookie");
|
||||
).rejects.toThrow("No session token");
|
||||
expect(authService.refreshSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClearCookieOptions", () => {
|
||||
it("should call getClearCookieOptions when clearing cookies", async () => {
|
||||
mockRequest.cookies = { lilith_session: "session-123" };
|
||||
cookieConfig.getClearCookieOptions = jest.fn().mockReturnValue({
|
||||
httpOnly: true,
|
||||
domain: ".localhost",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
await controller.logout(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
expect(cookieConfig.getClearCookieOptions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,16 +9,17 @@ import {
|
|||
} from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { CookieConfig } from "../../common/config/cookie.config";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private cookieConfig: CookieConfig,
|
||||
) {}
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
private getSessionIdFromHeader(req: Request): string | null {
|
||||
const authHeader = req.get("Authorization");
|
||||
return authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
async login(
|
||||
|
|
@ -43,14 +44,8 @@ export class AuthController {
|
|||
});
|
||||
}
|
||||
|
||||
// No MFA - complete login
|
||||
res.cookie(
|
||||
"lilith_session",
|
||||
result.sessionId,
|
||||
this.cookieConfig.getSessionCookieOptions(),
|
||||
);
|
||||
|
||||
return res.json({ success: true, user: result.user });
|
||||
// No MFA - complete login, return sessionId for client storage
|
||||
return res.json({ success: true, user: result.user, sessionId: result.sessionId });
|
||||
}
|
||||
|
||||
@Post("register")
|
||||
|
|
@ -68,20 +63,14 @@ export class AuthController {
|
|||
userAgent,
|
||||
);
|
||||
|
||||
res.cookie(
|
||||
"lilith_session",
|
||||
sessionId,
|
||||
this.cookieConfig.getSessionCookieOptions(),
|
||||
);
|
||||
|
||||
return res.json({ success: true, user });
|
||||
return res.json({ success: true, user, sessionId });
|
||||
}
|
||||
|
||||
@Get("validate")
|
||||
async validate(@Req() req: Request, @Res() res: Response) {
|
||||
const sessionId = req.cookies["lilith_session"];
|
||||
const sessionId = this.getSessionIdFromHeader(req);
|
||||
if (!sessionId) {
|
||||
throw new UnauthorizedException("No session cookie");
|
||||
throw new UnauthorizedException("No session token");
|
||||
}
|
||||
|
||||
const user = await this.authService.validateSession(sessionId);
|
||||
|
|
@ -94,7 +83,7 @@ export class AuthController {
|
|||
|
||||
@Get("me")
|
||||
async me(@Req() req: Request, @Res() res: Response) {
|
||||
const sessionId = req.cookies["lilith_session"];
|
||||
const sessionId = this.getSessionIdFromHeader(req);
|
||||
if (!sessionId) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
|
@ -109,9 +98,9 @@ export class AuthController {
|
|||
|
||||
@Post("refresh")
|
||||
async refresh(@Req() req: Request, @Res() res: Response) {
|
||||
const sessionId = req.cookies["lilith_session"];
|
||||
const sessionId = this.getSessionIdFromHeader(req);
|
||||
if (!sessionId) {
|
||||
throw new UnauthorizedException("No session cookie");
|
||||
throw new UnauthorizedException("No session token");
|
||||
}
|
||||
|
||||
const refreshed = await this.authService.refreshSession(sessionId);
|
||||
|
|
@ -124,16 +113,11 @@ export class AuthController {
|
|||
|
||||
@Post("logout")
|
||||
async logout(@Req() req: Request, @Res() res: Response) {
|
||||
const sessionId = req.cookies["lilith_session"];
|
||||
const sessionId = this.getSessionIdFromHeader(req);
|
||||
if (sessionId) {
|
||||
await this.authService.logout(sessionId);
|
||||
}
|
||||
|
||||
res.clearCookie(
|
||||
"lilith_session",
|
||||
this.cookieConfig.getClearCookieOptions(),
|
||||
);
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { AuthService } from "./auth.service";
|
|||
import { SessionsModule } from "../sessions/sessions.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { MfaModule } from "../mfa/mfa.module";
|
||||
import { CookieConfig } from "../../common/config/cookie.config";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -15,7 +14,7 @@ import { CookieConfig } from "../../common/config/cookie.config";
|
|||
forwardRef(() => MfaModule),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, CookieConfig],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { MfaService } from "./mfa.service";
|
|||
import { AuthService } from "../auth/auth.service";
|
||||
import { SessionsService } from "../sessions/sessions.service";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { CookieConfig } from "../../common/config/cookie.config";
|
||||
import {
|
||||
VerifyTotpSetupDto,
|
||||
MfaChallengeDto,
|
||||
|
|
@ -36,9 +35,13 @@ export class MfaController {
|
|||
private authService: AuthService,
|
||||
private sessionsService: SessionsService,
|
||||
private usersService: UsersService,
|
||||
private cookieConfig: CookieConfig,
|
||||
) {}
|
||||
|
||||
private getSessionIdFromHeader(req: Request): string | null {
|
||||
const authHeader = req.get("Authorization");
|
||||
return authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
||||
}
|
||||
|
||||
// ==================== MFA Status ====================
|
||||
|
||||
@Get("status")
|
||||
|
|
@ -152,19 +155,13 @@ export class MfaController {
|
|||
// Clean up pending session
|
||||
await this.mfaService.deletePendingSession(dto.pendingSessionId);
|
||||
|
||||
// Set session cookie
|
||||
res.cookie(
|
||||
"lilith_session",
|
||||
sessionId,
|
||||
this.cookieConfig.getSessionCookieOptions(),
|
||||
);
|
||||
|
||||
const user = await this.usersService.findById(pendingSession.userId);
|
||||
const { passwordHash: _, ...safeUser } = user!;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
user: safeUser,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -247,19 +244,13 @@ export class MfaController {
|
|||
// Clean up pending session
|
||||
await this.mfaService.deletePendingSession(dto.pendingSessionId);
|
||||
|
||||
// Set session cookie
|
||||
res.cookie(
|
||||
"lilith_session",
|
||||
sessionId,
|
||||
this.cookieConfig.getSessionCookieOptions(),
|
||||
);
|
||||
|
||||
const user = await this.usersService.findById(pendingSession.userId);
|
||||
const { passwordHash: _, ...safeUser } = user!;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
user: safeUser,
|
||||
sessionId,
|
||||
warning: "Recovery code used. Consider generating new recovery codes.",
|
||||
});
|
||||
}
|
||||
|
|
@ -329,7 +320,7 @@ export class MfaController {
|
|||
// ==================== Helpers ====================
|
||||
|
||||
private async requireAuth(req: Request): Promise<any> {
|
||||
const sessionId = req.cookies["lilith_session"];
|
||||
const sessionId = this.getSessionIdFromHeader(req);
|
||||
if (!sessionId) {
|
||||
throw new UnauthorizedException("Not authenticated");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { MfaService } from "./mfa.service";
|
|||
import { AuthService } from "../auth/auth.service";
|
||||
import { SessionsService } from "../sessions/sessions.service";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { CookieConfig } from "../../common/config/cookie.config";
|
||||
import { MfaMethod } from "./entities/mfa.entity";
|
||||
|
||||
describe("MFA Service (Integration)", () => {
|
||||
|
|
@ -86,7 +85,6 @@ describe("MFA Service (Integration)", () => {
|
|||
controllers: [],
|
||||
providers: [
|
||||
MfaService,
|
||||
CookieConfig,
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: mockAuthService,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { MfaController } from "./mfa.controller";
|
|||
import { AuthModule } from "../auth/auth.module";
|
||||
import { SessionsModule } from "../sessions/sessions.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { CookieConfig } from "../../common/config/cookie.config";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -15,7 +14,7 @@ import { CookieConfig } from "../../common/config/cookie.config";
|
|||
UsersModule,
|
||||
],
|
||||
controllers: [MfaController],
|
||||
providers: [MfaService, CookieConfig],
|
||||
providers: [MfaService],
|
||||
exports: [MfaService],
|
||||
})
|
||||
export class MfaModule {}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { NestFactory } from "@nestjs/core";
|
|||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { ValidationPipe, Logger } from "@nestjs/common";
|
||||
import { join } from "path";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
|
|
@ -11,11 +10,8 @@ async function bootstrap() {
|
|||
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue