feat(#193): Align authentication mechanism between API and web client
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

- Update AuthUser type in @mosaic/shared to include workspace fields
- Update AuthGuard to support both cookie-based and Bearer token authentication
- Add /auth/session endpoint for session validation
- Install and configure cookie-parser middleware
- Update CurrentUser decorator to use shared AuthUser type
- Update tests for cookie and token authentication (20 tests passing)

This ensures consistent authentication handling across API and web client,
with proper type safety and support for both web browsers (cookies) and
API clients (Bearer tokens).

Fixes #193

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 22:29:42 -06:00
parent 8aadfb99af
commit a2b61d2bff
93 changed files with 2126 additions and 69 deletions

View File

@@ -29,9 +29,13 @@ describe("AuthGuard", () => {
vi.clearAllMocks();
});
const createMockExecutionContext = (headers: any = {}): ExecutionContext => {
const createMockExecutionContext = (
headers: Record<string, string> = {},
cookies: Record<string, string> = {}
): ExecutionContext => {
const mockRequest = {
headers,
cookies,
};
return {
@@ -42,57 +46,139 @@ describe("AuthGuard", () => {
};
describe("canActivate", () => {
it("should return true for valid session", async () => {
const mockSessionData = {
user: {
id: "user-123",
email: "test@example.com",
name: "Test User",
},
session: {
id: "session-123",
},
};
const mockSessionData = {
user: {
id: "user-123",
email: "test@example.com",
name: "Test User",
},
session: {
id: "session-123",
token: "session-token",
expiresAt: new Date(Date.now() + 86400000),
},
};
mockAuthService.verifySession.mockResolvedValue(mockSessionData);
describe("Bearer token authentication", () => {
it("should return true for valid Bearer token", async () => {
mockAuthService.verifySession.mockResolvedValue(mockSessionData);
const context = createMockExecutionContext({
authorization: "Bearer valid-token",
const context = createMockExecutionContext({
authorization: "Bearer valid-token",
});
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
});
const result = await guard.canActivate(context);
it("should throw UnauthorizedException for invalid Bearer token", async () => {
mockAuthService.verifySession.mockResolvedValue(null);
expect(result).toBe(true);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
const context = createMockExecutionContext({
authorization: "Bearer invalid-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow("Invalid or expired session");
});
});
it("should throw UnauthorizedException if no token provided", async () => {
const context = createMockExecutionContext({});
describe("Cookie-based authentication", () => {
it("should return true for valid session cookie", async () => {
mockAuthService.verifySession.mockResolvedValue(mockSessionData);
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow("No authentication token provided");
});
const context = createMockExecutionContext(
{},
{
"better-auth.session_token": "cookie-token",
}
);
it("should throw UnauthorizedException if session is invalid", async () => {
mockAuthService.verifySession.mockResolvedValue(null);
const result = await guard.canActivate(context);
const context = createMockExecutionContext({
authorization: "Bearer invalid-token",
expect(result).toBe(true);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("cookie-token");
});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow("Invalid or expired session");
});
it("should prefer cookie over Bearer token when both present", async () => {
mockAuthService.verifySession.mockResolvedValue(mockSessionData);
it("should throw UnauthorizedException if session verification fails", async () => {
mockAuthService.verifySession.mockRejectedValue(new Error("Verification failed"));
const context = createMockExecutionContext(
{
authorization: "Bearer bearer-token",
},
{
"better-auth.session_token": "cookie-token",
}
);
const context = createMockExecutionContext({
authorization: "Bearer error-token",
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("cookie-token");
});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow("Authentication failed");
it("should fallback to Bearer token if no cookie", async () => {
mockAuthService.verifySession.mockResolvedValue(mockSessionData);
const context = createMockExecutionContext(
{
authorization: "Bearer bearer-token",
},
{}
);
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("bearer-token");
});
});
describe("Error handling", () => {
it("should throw UnauthorizedException if no token provided", async () => {
const context = createMockExecutionContext({}, {});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow(
"No authentication token provided"
);
});
it("should throw UnauthorizedException if session verification fails", async () => {
mockAuthService.verifySession.mockRejectedValue(new Error("Verification failed"));
const context = createMockExecutionContext({
authorization: "Bearer error-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow("Authentication failed");
});
it("should attach user and session to request on success", async () => {
mockAuthService.verifySession.mockResolvedValue(mockSessionData);
const mockRequest = {
headers: {
authorization: "Bearer valid-token",
},
cookies: {},
};
const context = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;
await guard.canActivate(context);
expect(mockRequest).toHaveProperty("user", mockSessionData.user);
expect(mockRequest).toHaveProperty("session", mockSessionData.session);
});
});
});
});