From 306d77a61e2ac4cba2c32caa7fe706a5501e1905 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 13 Jun 2026 10:09:13 +0000 Subject: [PATCH 1/4] feat(api): Google OAuth sign-in + JWT session - Add AuthModule with GoogleStrategy (passport-google-oauth20) and JwtStrategy (passport-jwt) for two-factor identity verification - Add AuthService with issueToken / verifyToken / decodeToken methods - Add JwtAuthGuard that rejects missing/expired tokens with a structured UnauthorizedException - Add AuthController: GET /auth/google (OAuth redirect) and GET /auth/google/callback (upsert user, issue JWT) - Add MeController: GET /api/me (returns authenticated user identity) - Add UserService for DB-backed user lookup / first-time provisioning - Add PrismaService + PrismaModule (global) wrapping the generated client - Declare JWT_SECRET, GOOGLE_* env vars in turbo.json globalEnv - Add .env.example documenting required environment variables - Add tsconfig paths for db/client so TS resolves the Prisma generated client from within the API workspace - Write 14 unit tests covering token issuance, verification, expiry, tampering, and JWT strategy payload validation (all passing) - Update app.module.spec.ts to mock GoogleStrategy + PrismaService so the existing integration tests remain green without real credentials Co-authored-by: Andrea Mazzucchelli --- .env.example | 12 + apps/api/jest.config.js | 2 + apps/api/package.json | 10 + apps/api/src/__mocks__/db-client.mock.ts | 27 ++ apps/api/src/app.module.spec.ts | 45 ++- apps/api/src/app.module.ts | 4 +- apps/api/src/auth/auth.controller.ts | 75 +++++ apps/api/src/auth/auth.module.ts | 22 ++ apps/api/src/auth/auth.service.spec.ts | 132 +++++++++ apps/api/src/auth/auth.service.ts | 30 ++ apps/api/src/auth/auth.types.ts | 21 ++ apps/api/src/auth/guards/google-auth.guard.ts | 5 + apps/api/src/auth/guards/jwt-auth.guard.ts | 22 ++ .../src/auth/strategies/google.strategy.ts | 47 +++ .../src/auth/strategies/jwt.strategy.spec.ts | 53 ++++ apps/api/src/auth/strategies/jwt.strategy.ts | 28 ++ apps/api/src/auth/user.service.ts | 52 ++++ apps/api/src/prisma/prisma.module.ts | 9 + apps/api/src/prisma/prisma.service.ts | 16 + apps/api/tsconfig.json | 6 +- pnpm-lock.yaml | 275 +++++++++++++++++- turbo.json | 6 + 22 files changed, 884 insertions(+), 15 deletions(-) create mode 100644 .env.example create mode 100644 apps/api/src/__mocks__/db-client.mock.ts create mode 100644 apps/api/src/auth/auth.controller.ts create mode 100644 apps/api/src/auth/auth.module.ts create mode 100644 apps/api/src/auth/auth.service.spec.ts create mode 100644 apps/api/src/auth/auth.service.ts create mode 100644 apps/api/src/auth/auth.types.ts create mode 100644 apps/api/src/auth/guards/google-auth.guard.ts create mode 100644 apps/api/src/auth/guards/jwt-auth.guard.ts create mode 100644 apps/api/src/auth/strategies/google.strategy.ts create mode 100644 apps/api/src/auth/strategies/jwt.strategy.spec.ts create mode 100644 apps/api/src/auth/strategies/jwt.strategy.ts create mode 100644 apps/api/src/auth/user.service.ts create mode 100644 apps/api/src/prisma/prisma.module.ts create mode 100644 apps/api/src/prisma/prisma.service.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63e4c6b --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Database +DATABASE_URL="postgresql://user:password@localhost:5432/cortex" + +# Google OAuth 2.0 +# Create credentials at https://console.cloud.google.com/apis/credentials +GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" +GOOGLE_CLIENT_SECRET="your-google-client-secret" +GOOGLE_CALLBACK_URL="http://localhost:4000/auth/google/callback" + +# JWT +# Use a long, random secret in production: `openssl rand -base64 64` +JWT_SECRET="changeme-dev-secret" diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js index 3336326..37d5f61 100644 --- a/apps/api/jest.config.js +++ b/apps/api/jest.config.js @@ -24,5 +24,7 @@ module.exports = { testEnvironment: "node", moduleNameMapper: { "^@cortex/shared$": "/../../../packages/shared/src/index.ts", + "^db/client$": "/__mocks__/db-client.mock.ts", + "^db$": "/__mocks__/db-client.mock.ts", }, }; \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index daebe95..5df6b7c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,9 +20,15 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@nestjs/common": "^11.1.21", "@nestjs/core": "^11.1.21", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.21", "db": "workspace:*", "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", "zod": "^4.4.3" }, "devDependencies": { @@ -32,7 +38,11 @@ "@nestjs/testing": "^11.1.24", "@types/express": "^5.0.3", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.15.3", + "@types/passport": "^1.0.17", + "@types/passport-google-oauth20": "^2.0.17", + "@types/passport-jwt": "^4.0.1", "eslint": "^9.39.1", "jest": "^30.4.2", "ts-jest": "^29.4.11", diff --git a/apps/api/src/__mocks__/db-client.mock.ts b/apps/api/src/__mocks__/db-client.mock.ts new file mode 100644 index 0000000..b774a4b --- /dev/null +++ b/apps/api/src/__mocks__/db-client.mock.ts @@ -0,0 +1,27 @@ +/** + * Jest manual mock for the Prisma generated client (`db/client`). + * + * Used in all unit and integration tests so that module resolution doesn't + * attempt to load the ESM-only generated Prisma files. + */ + +export const mockPrismaClient = { + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + user: { + findUnique: jest.fn(), + create: jest.fn(), + }, + organization: { + findFirst: jest.fn(), + }, +}; + +export class PrismaClient { + $connect = mockPrismaClient.$connect; + $disconnect = mockPrismaClient.$disconnect; + user = mockPrismaClient.user; + organization = mockPrismaClient.organization; +} + +export const Prisma = {}; diff --git a/apps/api/src/app.module.spec.ts b/apps/api/src/app.module.spec.ts index 0ba5a19..1c8d179 100644 --- a/apps/api/src/app.module.spec.ts +++ b/apps/api/src/app.module.spec.ts @@ -1,12 +1,32 @@ import { Test } from "@nestjs/testing"; import { AppModule } from "./app.module"; import { MCPController } from "./mcp/mcp.controller"; +import { GoogleStrategy } from "./auth/strategies/google.strategy"; +import { PrismaService } from "./prisma/prisma.service"; + +/** Stub that replaces GoogleStrategy so tests don't need real OAuth credentials. */ +class MockGoogleStrategy { + name = "google"; +} + +/** Stub that replaces PrismaService so tests don't need a real database. */ +class MockPrismaService { + $connect = jest.fn().mockResolvedValue(undefined); + $disconnect = jest.fn().mockResolvedValue(undefined); + user = { findUnique: jest.fn(), create: jest.fn() }; + organization = { findFirst: jest.fn() }; +} describe("AppModule", () => { it("should be defined and compile successfully", async () => { const module = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useClass(MockPrismaService) + .compile(); expect(module).toBeDefined(); }); @@ -15,14 +35,24 @@ describe("AppModule", () => { await expect( Test.createTestingModule({ imports: [AppModule], - }).compile() + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useClass(MockPrismaService) + .compile(), ).resolves.not.toThrow(); }); it("should provide MCPController via imported MCPModule", async () => { const module = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useClass(MockPrismaService) + .compile(); const controller = module.get(MCPController); expect(controller).toBeDefined(); @@ -32,7 +62,12 @@ describe("AppModule", () => { it("should wire MCPController so handleMCP is callable", async () => { const module = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useClass(MockPrismaService) + .compile(); const controller = module.get(MCPController); const response = await controller.handleMCP({ @@ -44,4 +79,4 @@ describe("AppModule", () => { expect(response.answer).toBe("Based on company standards: integration check"); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index af15bcc..32ce317 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,7 +1,9 @@ import { Module } from "@nestjs/common"; import { MCPModule } from "./mcp/mcp.module"; +import { AuthModule } from "./auth/auth.module"; +import { PrismaModule } from "./prisma/prisma.module"; @Module({ - imports: [MCPModule], + imports: [PrismaModule, AuthModule, MCPModule], }) export class AppModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..7d915f0 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,75 @@ +import { + Controller, + Get, + Req, + Res, + UseGuards, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import type { Request, Response } from "express"; +import { AuthService } from "./auth.service"; +import { UserService } from "./user.service"; +import { GoogleAuthGuard } from "./guards/google-auth.guard"; +import { JwtAuthGuard } from "./guards/jwt-auth.guard"; +import type { AuthenticatedUser } from "./auth.types"; + +interface RequestWithUser extends Request { + user: AuthenticatedUser & { googleSub: string }; +} + +interface RequestWithJwtUser extends Request { + user: AuthenticatedUser; +} + +@Controller("auth") +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly userService: UserService, + ) {} + + /** Initiates the Google OAuth consent screen redirect. */ + @Get("google") + @UseGuards(GoogleAuthGuard) + googleLogin(): void { + // Guard redirects to Google – no body needed. + } + + /** + * Google calls back here after the user grants consent. + * We upsert the user, issue a JWT, and return it. + */ + @Get("google/callback") + @UseGuards(GoogleAuthGuard) + async googleCallback( + @Req() req: RequestWithUser, + @Res() res: Response, + ): Promise { + const { googleSub, email } = req.user; + + const dbUser = await this.userService.findOrCreate({ googleSub, email }); + + const authenticatedUser: AuthenticatedUser = { + id: dbUser.id, + email: dbUser.email, + organizationId: dbUser.organizationId, + role: dbUser.role, + }; + + const token = this.authService.issueToken(authenticatedUser); + + res.json({ accessToken: token }); + } +} + +@Controller("api") +export class MeController { + /** Returns the identity of the currently authenticated user. */ + @Get("me") + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + getMe(@Req() req: RequestWithJwtUser): AuthenticatedUser { + return req.user; + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..0d0c0d4 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,22 @@ +import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { AuthService } from "./auth.service"; +import { UserService } from "./user.service"; +import { AuthController, MeController } from "./auth.controller"; +import { GoogleStrategy } from "./strategies/google.strategy"; +import { JwtStrategy } from "./strategies/jwt.strategy"; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: "jwt" }), + JwtModule.register({ + secret: process.env["JWT_SECRET"] ?? "changeme-dev-secret", + signOptions: { expiresIn: "8h" }, + }), + ], + controllers: [AuthController, MeController], + providers: [AuthService, UserService, GoogleStrategy, JwtStrategy], + exports: [AuthService, JwtModule], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..a616c8d --- /dev/null +++ b/apps/api/src/auth/auth.service.spec.ts @@ -0,0 +1,132 @@ +import { Test, type TestingModule } from "@nestjs/testing"; +import { JwtModule, JwtService } from "@nestjs/jwt"; +import { UnauthorizedException } from "@nestjs/common"; +import { AuthService } from "./auth.service"; +import type { AuthenticatedUser, JwtPayload } from "./auth.types"; + +const TEST_SECRET = "test-secret-for-unit-tests"; + +const mockUser: AuthenticatedUser = { + id: "user-1", + email: "alice@example.com", + organizationId: "org-1", + role: "member", +}; + +describe("AuthService", () => { + let service: AuthService; + let jwtService: JwtService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + JwtModule.register({ + secret: TEST_SECRET, + signOptions: { expiresIn: "1h" }, + }), + ], + providers: [AuthService], + }).compile(); + + service = module.get(AuthService); + jwtService = module.get(JwtService); + }); + + describe("issueToken", () => { + it("returns a non-empty JWT string", () => { + const token = service.issueToken(mockUser); + expect(typeof token).toBe("string"); + expect(token.split(".")).toHaveLength(3); + }); + + it("encodes sub, email, organizationId, and role in the payload", () => { + const token = service.issueToken(mockUser); + const payload = jwtService.decode(token); + + expect(payload?.sub).toBe(mockUser.id); + expect(payload?.email).toBe(mockUser.email); + expect(payload?.organizationId).toBe(mockUser.organizationId); + expect(payload?.role).toBe(mockUser.role); + }); + + it("produces tokens with iat and exp claims", () => { + const token = service.issueToken(mockUser); + const payload = jwtService.decode(token); + + expect(payload?.iat).toBeDefined(); + expect(payload?.exp).toBeDefined(); + expect((payload?.exp ?? 0) > (payload?.iat ?? 0)).toBe(true); + }); + }); + + describe("verifyToken", () => { + it("returns the decoded payload for a valid token", () => { + const token = service.issueToken(mockUser); + const payload = service.verifyToken(token); + + expect(payload.sub).toBe(mockUser.id); + expect(payload.email).toBe(mockUser.email); + expect(payload.organizationId).toBe(mockUser.organizationId); + expect(payload.role).toBe(mockUser.role); + }); + + it("throws UnauthorizedException for a tampered token", () => { + const token = service.issueToken(mockUser); + const tampered = token.slice(0, -5) + "XXXXX"; + + expect(() => service.verifyToken(tampered)).toThrow( + UnauthorizedException, + ); + }); + + it("throws UnauthorizedException for a token signed with a different secret", () => { + const foreignToken = jwtService.sign( + { sub: "other", email: "b@b.com", organizationId: "o2", role: "admin" }, + { secret: "wrong-secret" }, + ); + + expect(() => service.verifyToken(foreignToken)).toThrow( + UnauthorizedException, + ); + }); + + it("throws UnauthorizedException for a malformed token string", () => { + expect(() => service.verifyToken("not.a.jwt")).toThrow( + UnauthorizedException, + ); + }); + + it("throws UnauthorizedException for an expired token", async () => { + const expiredToken = jwtService.sign( + { + sub: mockUser.id, + email: mockUser.email, + organizationId: mockUser.organizationId, + role: mockUser.role, + }, + { expiresIn: "0s" }, + ); + + // Allow the clock to advance past expiry + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(() => service.verifyToken(expiredToken)).toThrow( + UnauthorizedException, + ); + }); + }); + + describe("decodeToken", () => { + it("returns the payload without signature verification", () => { + const token = service.issueToken(mockUser); + const payload = service.decodeToken(token); + + expect(payload?.sub).toBe(mockUser.id); + }); + + it("returns null for a completely invalid string", () => { + const payload = service.decodeToken("garbage"); + expect(payload).toBeNull(); + }); + }); +}); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..a257c08 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,30 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import type { AuthenticatedUser, JwtPayload } from "./auth.types"; + +@Injectable() +export class AuthService { + constructor(private readonly jwtService: JwtService) {} + + issueToken(user: AuthenticatedUser): string { + const payload: JwtPayload = { + sub: user.id, + email: user.email, + organizationId: user.organizationId, + role: user.role, + }; + return this.jwtService.sign(payload); + } + + verifyToken(token: string): JwtPayload { + try { + return this.jwtService.verify(token); + } catch { + throw new UnauthorizedException("Invalid or expired token"); + } + } + + decodeToken(token: string): JwtPayload | null { + return this.jwtService.decode(token); + } +} diff --git a/apps/api/src/auth/auth.types.ts b/apps/api/src/auth/auth.types.ts new file mode 100644 index 0000000..d8c9248 --- /dev/null +++ b/apps/api/src/auth/auth.types.ts @@ -0,0 +1,21 @@ +export interface GoogleProfile { + id: string; + displayName: string; + emails?: Array<{ value: string; verified: boolean }>; +} + +export interface JwtPayload { + sub: string; + email: string; + organizationId: string; + role: string; + iat?: number; + exp?: number; +} + +export interface AuthenticatedUser { + id: string; + email: string; + organizationId: string; + role: string; +} diff --git a/apps/api/src/auth/guards/google-auth.guard.ts b/apps/api/src/auth/guards/google-auth.guard.ts new file mode 100644 index 0000000..fa93267 --- /dev/null +++ b/apps/api/src/auth/guards/google-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; + +@Injectable() +export class GoogleAuthGuard extends AuthGuard("google") {} diff --git a/apps/api/src/auth/guards/jwt-auth.guard.ts b/apps/api/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..ae975f5 --- /dev/null +++ b/apps/api/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,22 @@ +import { + Injectable, + ExecutionContext, + UnauthorizedException, +} from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; + +@Injectable() +export class JwtAuthGuard extends AuthGuard("jwt") { + override canActivate(context: ExecutionContext) { + return super.canActivate(context); + } + + override handleRequest(err: Error | null, user: TUser): TUser { + if (err || !user) { + throw new UnauthorizedException( + err?.message ?? "Missing or invalid authentication token", + ); + } + return user; + } +} diff --git a/apps/api/src/auth/strategies/google.strategy.ts b/apps/api/src/auth/strategies/google.strategy.ts new file mode 100644 index 0000000..2e7874d --- /dev/null +++ b/apps/api/src/auth/strategies/google.strategy.ts @@ -0,0 +1,47 @@ +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { + Strategy, + type VerifyCallback, + type Profile, +} from "passport-google-oauth20"; +import type { AuthenticatedUser } from "../auth.types"; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, "google") { + constructor() { + super({ + clientID: process.env["GOOGLE_CLIENT_ID"] ?? "", + clientSecret: process.env["GOOGLE_CLIENT_SECRET"] ?? "", + callbackURL: process.env["GOOGLE_CALLBACK_URL"] ?? "/auth/google/callback", + scope: ["email", "profile"], + }); + } + + validate( + _accessToken: string, + _refreshToken: string, + profile: Profile, + done: VerifyCallback, + ): void { + const email = profile.emails?.[0]?.value; + + if (!email) { + done(new Error("No email returned from Google"), undefined); + return; + } + + // The actual user lookup / provisioning happens in the controller after + // the strategy succeeds. We forward the raw profile so the controller + // can call the DB and issue a JWT. + const user: AuthenticatedUser & { googleSub: string } = { + id: "", + email, + googleSub: profile.id, + organizationId: "", + role: "member", + }; + + done(null, user); + } +} diff --git a/apps/api/src/auth/strategies/jwt.strategy.spec.ts b/apps/api/src/auth/strategies/jwt.strategy.spec.ts new file mode 100644 index 0000000..7e77f6e --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.spec.ts @@ -0,0 +1,53 @@ +import { UnauthorizedException } from "@nestjs/common"; +import { JwtStrategy } from "./jwt.strategy"; +import type { JwtPayload } from "../auth.types"; + +describe("JwtStrategy", () => { + let strategy: JwtStrategy; + + beforeEach(() => { + process.env["JWT_SECRET"] = "test-secret"; + strategy = new JwtStrategy(); + }); + + afterEach(() => { + delete process.env["JWT_SECRET"]; + }); + + describe("validate", () => { + const validPayload: JwtPayload = { + sub: "user-1", + email: "alice@example.com", + organizationId: "org-1", + role: "member", + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + it("returns an AuthenticatedUser for a valid payload", () => { + const user = strategy.validate(validPayload); + + expect(user).toEqual({ + id: "user-1", + email: "alice@example.com", + organizationId: "org-1", + role: "member", + }); + }); + + it("throws UnauthorizedException when sub is missing", () => { + const payload = { ...validPayload, sub: "" }; + expect(() => strategy.validate(payload)).toThrow(UnauthorizedException); + }); + + it("throws UnauthorizedException when email is missing", () => { + const payload = { ...validPayload, email: "" }; + expect(() => strategy.validate(payload)).toThrow(UnauthorizedException); + }); + + it("throws UnauthorizedException when organizationId is missing", () => { + const payload = { ...validPayload, organizationId: "" }; + expect(() => strategy.validate(payload)).toThrow(UnauthorizedException); + }); + }); +}); diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..af95df9 --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,28 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import type { JwtPayload, AuthenticatedUser } from "../auth.types"; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, "jwt") { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env["JWT_SECRET"] ?? "changeme-dev-secret", + }); + } + + validate(payload: JwtPayload): AuthenticatedUser { + if (!payload.sub || !payload.email || !payload.organizationId) { + throw new UnauthorizedException("Malformed token payload"); + } + + return { + id: payload.sub, + email: payload.email, + organizationId: payload.organizationId, + role: payload.role, + }; + } +} diff --git a/apps/api/src/auth/user.service.ts b/apps/api/src/auth/user.service.ts new file mode 100644 index 0000000..277f7f2 --- /dev/null +++ b/apps/api/src/auth/user.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import type { User } from "db/client"; + +interface UpsertUserInput { + googleSub: string; + email: string; +} + +@Injectable() +export class UserService { + constructor(private readonly prisma: PrismaService) {} + + async findByGoogleSub(googleSub: string): Promise { + return this.prisma.user.findUnique({ where: { googleSub } }); + } + + async findById(id: string): Promise { + return this.prisma.user.findUnique({ where: { id } }); + } + + /** + * Look up an existing user by Google subject ID. + * If the user does not exist yet, provision a placeholder record. + * + * Note: A real implementation should derive the organizationId from the + * verified domain or an invite flow. For now we defer that to post-MVP work + * and store an empty sentinel value that signals "pending org assignment". + */ + async findOrCreate(input: UpsertUserInput): Promise { + const existing = await this.findByGoogleSub(input.googleSub); + if (existing) { + return existing; + } + + // Resolve organization by email domain (best-effort; will be null for + // domains that have not yet been provisioned). + const domain = input.email.split("@")[1] ?? ""; + const org = await this.prisma.organization.findFirst({ + where: { name: domain }, + }); + + return this.prisma.user.create({ + data: { + googleSub: input.googleSub, + email: input.email, + role: "member", + organizationId: org?.id ?? "", + }, + }); + } +} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..1edbf95 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { PrismaService } from "./prisma.service"; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..456eb7d --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; +import { PrismaClient } from "db/client"; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit(): Promise { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 9f4606e..f6a6484 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -8,7 +8,11 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true, "skipLibCheck": true, - "outDir": "dist" + "outDir": "dist", + "paths": { + "db/client": ["../../packages/db/generated/prisma/client.ts"], + "db": ["../../packages/db/src/index.ts"] + } }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c96144..81039b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,12 @@ importers: '@nestjs/core': specifier: ^11.1.21 version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': specifier: ^11.1.21 version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) @@ -48,6 +54,18 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-google-oauth20: + specifier: ^2.0.0 + version: 2.0.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 zod: specifier: ^4.4.3 version: 4.4.3 @@ -70,9 +88,21 @@ importers: '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^22.15.3 version: 22.15.3 + '@types/passport': + specifier: ^1.0.17 + version: 1.0.17 + '@types/passport-google-oauth20': + specifier: ^2.0.17 + version: 2.0.17 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 eslint: specifier: ^9.39.1 version: 9.39.1(jiti@2.7.0) @@ -1009,6 +1039,17 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + '@nestjs/platform-express@11.1.21': resolution: {integrity: sha512-lA3ViycOnz4Df3EstIKpuAVFhqxQixTnjAVk0M+LRyNBlGM6VSCaNJaAIrb9Pcry39T4hTHpNVbRqGLSvhL8gA==} peerDependencies: @@ -1353,9 +1394,33 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.15.3': resolution: {integrity: sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==} + '@types/oauth@0.9.6': + resolution: {integrity: sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==} + + '@types/passport-google-oauth20@2.0.17': + resolution: {integrity: sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==} + + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-oauth2@1.8.0': + resolution: {integrity: sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1790,6 +1855,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} + baseline-browser-mapping@2.10.31: resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} engines: {node: '>=6.0.0'} @@ -1831,6 +1900,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2115,6 +2187,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2977,10 +3052,20 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3011,12 +3096,33 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -3206,6 +3312,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + oauth@0.10.2: + resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3295,6 +3404,25 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + passport-google-oauth20@2.0.0: + resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} + engines: {node: '>= 0.4.0'} + + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-oauth2@1.8.0: + resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==} + engines: {node: '>= 0.4.0'} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3328,6 +3456,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -3564,10 +3695,6 @@ packages: semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - semver@7.8.0: resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} @@ -3964,6 +4091,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uid2@0.0.4: + resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -4002,6 +4132,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -5003,6 +5137,17 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/passport@11.0.5(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + '@nestjs/platform-express@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)': dependencies: '@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5351,10 +5496,47 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.15.3 + + '@types/ms@2.1.0': {} + '@types/node@22.15.3': dependencies: undici-types: 6.21.0 + '@types/oauth@0.9.6': + dependencies: + '@types/node': 22.15.3 + + '@types/passport-google-oauth20@2.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 + + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-oauth2@1.8.0': + dependencies: + '@types/express': 5.0.6 + '@types/oauth': 0.9.6 + '@types/passport': 1.0.17 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -5452,7 +5634,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.50.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.8.0 tinyglobby: 0.2.15 ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 @@ -5844,6 +6026,8 @@ snapshots: base64-js@1.5.1: {} + base64url@3.0.1: {} + baseline-browser-mapping@2.10.31: {} better-result@2.9.2: {} @@ -5901,6 +6085,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -6146,6 +6332,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.20.0: @@ -6575,7 +6765,7 @@ snapshots: minimatch: 3.1.2 node-abort-controller: 3.1.1 schema-utils: 3.3.0 - semver: 7.7.3 + semver: 7.8.0 tapable: 2.3.3 typescript: 5.9.3 webpack: 5.106.0 @@ -7318,6 +7508,19 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.0 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -7325,6 +7528,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7350,10 +7564,24 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.18.1: {} log-symbols@4.1.0: @@ -7519,6 +7747,8 @@ snapshots: dependencies: path-key: 3.1.1 + oauth@0.10.2: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -7629,6 +7859,31 @@ snapshots: parseurl@1.3.3: {} + passport-google-oauth20@2.0.0: + dependencies: + passport-oauth2: 1.8.0 + + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-oauth2@1.8.0: + dependencies: + base64url: 3.0.1 + oauth: 0.10.2 + passport-strategy: 1.0.0 + uid2: 0.0.4 + utils-merge: 1.0.1 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -7653,6 +7908,8 @@ snapshots: pathe@2.0.3: {} + pause@0.0.1: {} + perfect-debounce@2.1.0: {} picocolors@1.1.1: {} @@ -7896,8 +8153,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} - semver@7.8.0: {} send@1.2.1: @@ -8328,6 +8583,8 @@ snapshots: uglify-js@3.19.3: optional: true + uid2@0.0.4: {} + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -8386,6 +8643,8 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 diff --git a/turbo.json b/turbo.json index 846878b..ec876c5 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,12 @@ { "$schema": "https://turborepo.dev/schema.json", "ui": "tui", + "globalEnv": [ + "JWT_SECRET", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "GOOGLE_CALLBACK_URL" + ], "tasks": { "build": { "dependsOn": ["^build"], From 56972a204a0fc2fa0719c48f48d7da6541cabcc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 13 Jun 2026 10:43:41 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(api):=20address=20CodeRabbit=20review?= =?UTF-8?q?=20=E2=80=94=20security=20hardening=20+=20contract=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jwt-auth.guard.ts: replace err?.message with a stable public message ("Missing or invalid authentication token") and log the raw error server-side with NestJS Logger to prevent internal detail leakage - auth.module.ts: switch to JwtModule.registerAsync; throw at startup when JWT_SECRET is absent instead of falling back to a hard-coded value - jwt.strategy.ts: read JWT_SECRET eagerly in constructor and throw when it is missing; remove the "changeme-dev-secret" fallback - google.strategy.ts: require a verified email (emails[].verified===true) before forwarding the profile; unverified addresses are now rejected - user.service.ts: * validate email format (throw if no '@' or empty local-part) * throw UnauthorizedException when no Organization is provisioned for the email domain — eliminates the empty-string organizationId that would cause FK constraint failures and self-invalidating JWTs * remove the org?.id ?? "" sentinel that masked missing org assignment - auth.controller.ts: update jsdoc to reflect that JWT issuance is now guarded by the service-level org check - app.module.spec.ts: set JWT_SECRET in beforeAll so JwtModule.registerAsync and JwtStrategy can initialise without a real secret in tests - jwt.strategy.spec.ts: add test asserting constructor throws when JWT_SECRET is unset - user.service.spec.ts: new test suite (6 tests) covering findOrCreate — returns existing user, creates with valid org, rejects missing org, rejects malformed email (no '@', leading '@'), correct domain extraction All 50 tests pass. Co-authored-by: Andrea Mazzucchelli --- apps/api/src/app.module.spec.ts | 13 +- apps/api/src/auth/auth.controller.ts | 4 +- apps/api/src/auth/auth.module.ts | 11 +- apps/api/src/auth/guards/jwt-auth.guard.ts | 10 +- .../src/auth/strategies/google.strategy.ts | 15 +- .../src/auth/strategies/jwt.strategy.spec.ts | 9 ++ apps/api/src/auth/strategies/jwt.strategy.ts | 7 +- apps/api/src/auth/user.service.spec.ts | 132 ++++++++++++++++++ apps/api/src/auth/user.service.ts | 31 ++-- 9 files changed, 207 insertions(+), 25 deletions(-) create mode 100644 apps/api/src/auth/user.service.spec.ts diff --git a/apps/api/src/app.module.spec.ts b/apps/api/src/app.module.spec.ts index 1c8d179..f84ce15 100644 --- a/apps/api/src/app.module.spec.ts +++ b/apps/api/src/app.module.spec.ts @@ -18,6 +18,15 @@ class MockPrismaService { } describe("AppModule", () => { + beforeAll(() => { + // AuthModule.registerAsync and JwtStrategy both require JWT_SECRET at startup. + process.env["JWT_SECRET"] = "test-secret-for-app-module-spec"; + }); + + afterAll(() => { + delete process.env["JWT_SECRET"]; + }); + it("should be defined and compile successfully", async () => { const module = await Test.createTestingModule({ imports: [AppModule], @@ -77,6 +86,8 @@ describe("AppModule", () => { query: "integration check", }); - expect(response.answer).toBe("Based on company standards: integration check"); + expect(response.answer).toBe( + "Based on company standards: integration check", + ); }); }); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 7d915f0..18f9792 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -38,7 +38,9 @@ export class AuthController { /** * Google calls back here after the user grants consent. - * We upsert the user, issue a JWT, and return it. + * findOrCreate throws UnauthorizedException when no organization is + * provisioned for the user's email domain, so a JWT is only issued after + * a valid organizationId is confirmed. */ @Get("google/callback") @UseGuards(GoogleAuthGuard) diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 0d0c0d4..458d6ff 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -10,9 +10,14 @@ import { JwtStrategy } from "./strategies/jwt.strategy"; @Module({ imports: [ PassportModule.register({ defaultStrategy: "jwt" }), - JwtModule.register({ - secret: process.env["JWT_SECRET"] ?? "changeme-dev-secret", - signOptions: { expiresIn: "8h" }, + JwtModule.registerAsync({ + useFactory: () => { + const secret = process.env["JWT_SECRET"]; + if (!secret) { + throw new Error("JWT_SECRET environment variable is required"); + } + return { secret, signOptions: { expiresIn: "8h" } }; + }, }), ], controllers: [AuthController, MeController], diff --git a/apps/api/src/auth/guards/jwt-auth.guard.ts b/apps/api/src/auth/guards/jwt-auth.guard.ts index ae975f5..6114d8d 100644 --- a/apps/api/src/auth/guards/jwt-auth.guard.ts +++ b/apps/api/src/auth/guards/jwt-auth.guard.ts @@ -2,20 +2,24 @@ import { Injectable, ExecutionContext, UnauthorizedException, + Logger, } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; @Injectable() export class JwtAuthGuard extends AuthGuard("jwt") { + private readonly logger = new Logger(JwtAuthGuard.name); + override canActivate(context: ExecutionContext) { return super.canActivate(context); } override handleRequest(err: Error | null, user: TUser): TUser { if (err || !user) { - throw new UnauthorizedException( - err?.message ?? "Missing or invalid authentication token", - ); + if (err) { + this.logger.error(err.message, err.stack); + } + throw new UnauthorizedException("Missing or invalid authentication token"); } return user; } diff --git a/apps/api/src/auth/strategies/google.strategy.ts b/apps/api/src/auth/strategies/google.strategy.ts index 2e7874d..d809f92 100644 --- a/apps/api/src/auth/strategies/google.strategy.ts +++ b/apps/api/src/auth/strategies/google.strategy.ts @@ -24,19 +24,20 @@ export class GoogleStrategy extends PassportStrategy(Strategy, "google") { profile: Profile, done: VerifyCallback, ): void { - const email = profile.emails?.[0]?.value; + // Require a verified email address – unverified emails must not be used + // for identity or domain-based org matching. + const verifiedEmail = profile.emails?.find((e) => e.verified === true) + ?.value; - if (!email) { - done(new Error("No email returned from Google"), undefined); + if (!verifiedEmail) { + done(new Error("No verified email returned from Google"), undefined); return; } - // The actual user lookup / provisioning happens in the controller after - // the strategy succeeds. We forward the raw profile so the controller - // can call the DB and issue a JWT. + // Forward the raw profile to the controller for DB upsert + JWT issuance. const user: AuthenticatedUser & { googleSub: string } = { id: "", - email, + email: verifiedEmail, googleSub: profile.id, organizationId: "", role: "member", diff --git a/apps/api/src/auth/strategies/jwt.strategy.spec.ts b/apps/api/src/auth/strategies/jwt.strategy.spec.ts index 7e77f6e..42dcd46 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.spec.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.spec.ts @@ -14,6 +14,15 @@ describe("JwtStrategy", () => { delete process.env["JWT_SECRET"]; }); + describe("constructor", () => { + it("throws when JWT_SECRET is missing", () => { + delete process.env["JWT_SECRET"]; + expect(() => new JwtStrategy()).toThrow( + "JWT_SECRET environment variable is required", + ); + }); + }); + describe("validate", () => { const validPayload: JwtPayload = { sub: "user-1", diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index af95df9..8a22a80 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -6,10 +6,15 @@ import type { JwtPayload, AuthenticatedUser } from "../auth.types"; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, "jwt") { constructor() { + const jwtSecret = process.env["JWT_SECRET"]; + if (!jwtSecret) { + throw new Error("JWT_SECRET environment variable is required"); + } + super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: process.env["JWT_SECRET"] ?? "changeme-dev-secret", + secretOrKey: jwtSecret, }); } diff --git a/apps/api/src/auth/user.service.spec.ts b/apps/api/src/auth/user.service.spec.ts new file mode 100644 index 0000000..eaf089e --- /dev/null +++ b/apps/api/src/auth/user.service.spec.ts @@ -0,0 +1,132 @@ +import { Test, type TestingModule } from "@nestjs/testing"; +import { UnauthorizedException } from "@nestjs/common"; +import { UserService } from "./user.service"; +import { PrismaService } from "../prisma/prisma.service"; + +const mockUser = { + id: "user-1", + email: "alice@acme.com", + googleSub: "google-sub-123", + organizationId: "org-1", + role: "member", + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockOrg = { id: "org-1", name: "acme.com" }; + +const mockPrisma = { + user: { + findUnique: jest.fn(), + create: jest.fn(), + }, + organization: { + findFirst: jest.fn(), + }, +}; + +describe("UserService", () => { + let service: UserService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(UserService); + }); + + describe("findOrCreate", () => { + it("returns an existing user without creating a new one", async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + + const result = await service.findOrCreate({ + googleSub: "google-sub-123", + email: "alice@acme.com", + }); + + expect(result).toBe(mockUser); + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + }); + + it("creates a new user when org is found for the email domain", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.organization.findFirst.mockResolvedValue(mockOrg); + mockPrisma.user.create.mockResolvedValue(mockUser); + + const result = await service.findOrCreate({ + googleSub: "google-sub-123", + email: "alice@acme.com", + }); + + expect(result).toBe(mockUser); + expect(mockPrisma.user.create).toHaveBeenCalledWith({ + data: { + googleSub: "google-sub-123", + email: "alice@acme.com", + role: "member", + organizationId: "org-1", + }, + }); + }); + + it("throws UnauthorizedException when no org matches the email domain", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.organization.findFirst.mockResolvedValue(null); + + await expect( + service.findOrCreate({ + googleSub: "google-sub-123", + email: "alice@unknown.com", + }), + ).rejects.toThrow(UnauthorizedException); + + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + }); + + it("throws Error for email without @ symbol", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + service.findOrCreate({ + googleSub: "google-sub-123", + email: "notanemail", + }), + ).rejects.toThrow("Invalid email format"); + + expect(mockPrisma.organization.findFirst).not.toHaveBeenCalled(); + }); + + it("throws Error for email starting with @ (empty local part)", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + service.findOrCreate({ + googleSub: "google-sub-123", + email: "@domain.com", + }), + ).rejects.toThrow("Invalid email format"); + }); + + it("queries org by the domain portion of the email", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.organization.findFirst.mockResolvedValue(null); + + await expect( + service.findOrCreate({ + googleSub: "google-sub-123", + email: "bob@example.org", + }), + ).rejects.toThrow(UnauthorizedException); + + expect(mockPrisma.organization.findFirst).toHaveBeenCalledWith({ + where: { name: "example.org" }, + }); + }); + }); +}); diff --git a/apps/api/src/auth/user.service.ts b/apps/api/src/auth/user.service.ts index 277f7f2..832c08a 100644 --- a/apps/api/src/auth/user.service.ts +++ b/apps/api/src/auth/user.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import type { User } from "db/client"; @@ -21,11 +21,11 @@ export class UserService { /** * Look up an existing user by Google subject ID. - * If the user does not exist yet, provision a placeholder record. + * If the user does not exist yet, provision a new record. * - * Note: A real implementation should derive the organizationId from the - * verified domain or an invite flow. For now we defer that to post-MVP work - * and store an empty sentinel value that signals "pending org assignment". + * The user's email domain must match a pre-registered Organization. + * Sign-in is rejected when no matching organization is found so that tokens + * are never issued with an empty or invalid organizationId. */ async findOrCreate(input: UpsertUserInput): Promise { const existing = await this.findByGoogleSub(input.googleSub); @@ -33,19 +33,32 @@ export class UserService { return existing; } - // Resolve organization by email domain (best-effort; will be null for - // domains that have not yet been provisioned). - const domain = input.email.split("@")[1] ?? ""; + const atIndex = input.email.indexOf("@"); + if (atIndex <= 0) { + throw new Error(`Invalid email format: ${input.email}`); + } + const domain = input.email.slice(atIndex + 1); + if (!domain) { + throw new Error(`Invalid email format: ${input.email}`); + } + const org = await this.prisma.organization.findFirst({ where: { name: domain }, }); + if (!org) { + throw new UnauthorizedException( + `No organization provisioned for email domain "${domain}". ` + + "Contact your administrator to register your domain.", + ); + } + return this.prisma.user.create({ data: { googleSub: input.googleSub, email: input.email, role: "member", - organizationId: org?.id ?? "", + organizationId: org.id, }, }); } From 30d8d42fb455c43048b1dcf7ac481ad6b9ce8652 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 13 Jun 2026 10:59:19 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(api):=20address=20second=20CodeRabbit?= =?UTF-8?q?=20review=20=E2=80=94=20atomicity,=20PII,=20org=20uniqueness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organization.name uniqueness (schema + migration): - Add @unique to Organization.name in packages/db/prisma/schema.prisma so domain-based org lookup is unambiguous and free of multi-row races - Add migration 0002_organization_name_unique with the corresponding CREATE UNIQUE INDEX statement - Regenerate Prisma client (findUnique now accepts { where: { name } }) user.service.ts — three fixes: 1. Race-safe findOrCreate: attempt prisma.user.create; on P2002 unique- constraint collision (concurrent OAuth callback), fall back to prisma.user.upsert({ update: {} }) which atomically returns the row already inserted by the racing request 2. BadRequestException instead of raw Error for malformed email format; error message contains no PII (email value removed) 3. Replace prisma.organization.findFirst with prisma.organization.findUnique (correct since Organization.name is now @unique) db-client.mock.ts: - Add user.upsert and organization.findUnique to the Jest mock so new code paths are exercisable in unit tests user.service.spec.ts: - Update organization lookup assertions to use findUnique - Add test: existing user skips org lookup and create/upsert entirely - Add test: P2002 race recovery resolves via upsert - Add test: non-P2002 DB errors are re-thrown unchanged - Add test: BadRequestException message contains no PII All 53 tests pass. Co-authored-by: Andrea Mazzucchelli --- apps/api/src/__mocks__/db-client.mock.ts | 2 + apps/api/src/auth/user.service.spec.ts | 105 +++++++++++++++--- apps/api/src/auth/user.service.ts | 84 +++++++++++--- .../migration.sql | 5 + packages/db/prisma/schema.prisma | 2 +- 5 files changed, 162 insertions(+), 36 deletions(-) create mode 100644 packages/db/prisma/migrations/0002_organization_name_unique/migration.sql diff --git a/apps/api/src/__mocks__/db-client.mock.ts b/apps/api/src/__mocks__/db-client.mock.ts index b774a4b..2a5f5e7 100644 --- a/apps/api/src/__mocks__/db-client.mock.ts +++ b/apps/api/src/__mocks__/db-client.mock.ts @@ -11,8 +11,10 @@ export const mockPrismaClient = { user: { findUnique: jest.fn(), create: jest.fn(), + upsert: jest.fn(), }, organization: { + findUnique: jest.fn(), findFirst: jest.fn(), }, }; diff --git a/apps/api/src/auth/user.service.spec.ts b/apps/api/src/auth/user.service.spec.ts index eaf089e..2e34c59 100644 --- a/apps/api/src/auth/user.service.spec.ts +++ b/apps/api/src/auth/user.service.spec.ts @@ -1,5 +1,5 @@ import { Test, type TestingModule } from "@nestjs/testing"; -import { UnauthorizedException } from "@nestjs/common"; +import { UnauthorizedException, BadRequestException } from "@nestjs/common"; import { UserService } from "./user.service"; import { PrismaService } from "../prisma/prisma.service"; @@ -19,9 +19,10 @@ const mockPrisma = { user: { findUnique: jest.fn(), create: jest.fn(), + upsert: jest.fn(), }, organization: { - findFirst: jest.fn(), + findUnique: jest.fn(), }, }; @@ -42,7 +43,7 @@ describe("UserService", () => { }); describe("findOrCreate", () => { - it("returns an existing user without creating a new one", async () => { + it("returns an existing user without touching org or create", async () => { mockPrisma.user.findUnique.mockResolvedValue(mockUser); const result = await service.findOrCreate({ @@ -51,12 +52,14 @@ describe("UserService", () => { }); expect(result).toBe(mockUser); + expect(mockPrisma.organization.findUnique).not.toHaveBeenCalled(); expect(mockPrisma.user.create).not.toHaveBeenCalled(); + expect(mockPrisma.user.upsert).not.toHaveBeenCalled(); }); it("creates a new user when org is found for the email domain", async () => { mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.organization.findFirst.mockResolvedValue(mockOrg); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); mockPrisma.user.create.mockResolvedValue(mockUser); const result = await service.findOrCreate({ @@ -75,9 +78,25 @@ describe("UserService", () => { }); }); + it("queries org using findUnique on the domain (Organization.name is @unique)", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.organization.findUnique.mockResolvedValue(null); + + await expect( + service.findOrCreate({ + googleSub: "google-sub-123", + email: "bob@example.org", + }), + ).rejects.toThrow(UnauthorizedException); + + expect(mockPrisma.organization.findUnique).toHaveBeenCalledWith({ + where: { name: "example.org" }, + }); + }); + it("throws UnauthorizedException when no org matches the email domain", async () => { mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.organization.findFirst.mockResolvedValue(null); + mockPrisma.organization.findUnique.mockResolvedValue(null); await expect( service.findOrCreate({ @@ -89,7 +108,7 @@ describe("UserService", () => { expect(mockPrisma.user.create).not.toHaveBeenCalled(); }); - it("throws Error for email without @ symbol", async () => { + it("throws BadRequestException for email without @ symbol", async () => { mockPrisma.user.findUnique.mockResolvedValue(null); await expect( @@ -97,12 +116,12 @@ describe("UserService", () => { googleSub: "google-sub-123", email: "notanemail", }), - ).rejects.toThrow("Invalid email format"); + ).rejects.toThrow(BadRequestException); - expect(mockPrisma.organization.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.organization.findUnique).not.toHaveBeenCalled(); }); - it("throws Error for email starting with @ (empty local part)", async () => { + it("throws BadRequestException for email starting with @ (empty local part)", async () => { mockPrisma.user.findUnique.mockResolvedValue(null); await expect( @@ -110,23 +129,75 @@ describe("UserService", () => { googleSub: "google-sub-123", email: "@domain.com", }), - ).rejects.toThrow("Invalid email format"); + ).rejects.toThrow(BadRequestException); }); - it("queries org by the domain portion of the email", async () => { + it("does not include PII in BadRequestException message", async () => { mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.organization.findFirst.mockResolvedValue(null); + + let caughtMessage = ""; + try { + await service.findOrCreate({ + googleSub: "google-sub-123", + email: "bademailformat", + }); + } catch (err) { + if (err instanceof BadRequestException) { + caughtMessage = err.message; + } + } + + expect(caughtMessage).toBe("Invalid email format"); + expect(caughtMessage).not.toContain("bademailformat"); + }); + + it("recovers from a concurrent-creation race via upsert on P2002", async () => { + mockPrisma.user.findUnique + .mockResolvedValueOnce(null) // pre-check returns null + .mockResolvedValue(undefined); // not called in upsert path + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + + const p2002 = Object.assign(new Error("Unique constraint failed"), { + code: "P2002", + }); + mockPrisma.user.create.mockRejectedValue(p2002); + mockPrisma.user.upsert.mockResolvedValue(mockUser); + + const result = await service.findOrCreate({ + googleSub: "google-sub-123", + email: "alice@acme.com", + }); + + expect(result).toBe(mockUser); + expect(mockPrisma.user.upsert).toHaveBeenCalledWith({ + where: { googleSub: "google-sub-123" }, + update: {}, + create: { + googleSub: "google-sub-123", + email: "alice@acme.com", + role: "member", + organizationId: "org-1", + }, + }); + }); + + it("re-throws non-P2002 database errors", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); + + const dbError = Object.assign(new Error("Connection refused"), { + code: "P1001", + }); + mockPrisma.user.create.mockRejectedValue(dbError); await expect( service.findOrCreate({ googleSub: "google-sub-123", - email: "bob@example.org", + email: "alice@acme.com", }), - ).rejects.toThrow(UnauthorizedException); + ).rejects.toThrow("Connection refused"); - expect(mockPrisma.organization.findFirst).toHaveBeenCalledWith({ - where: { name: "example.org" }, - }); + expect(mockPrisma.user.upsert).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/api/src/auth/user.service.ts b/apps/api/src/auth/user.service.ts index 832c08a..443e40b 100644 --- a/apps/api/src/auth/user.service.ts +++ b/apps/api/src/auth/user.service.ts @@ -1,4 +1,8 @@ -import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { + Injectable, + UnauthorizedException, + BadRequestException, +} from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import type { User } from "db/client"; @@ -7,6 +11,19 @@ interface UpsertUserInput { email: string; } +/** + * Narrows an unknown thrown value to a Prisma unique-constraint error (P2002). + * Avoids importing Prisma runtime types into the CommonJS API workspace. + */ +function isPrismaUniqueConstraintError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2002" + ); +} + @Injectable() export class UserService { constructor(private readonly prisma: PrismaService) {} @@ -20,29 +37,40 @@ export class UserService { } /** - * Look up an existing user by Google subject ID. - * If the user does not exist yet, provision a new record. + * Return an existing user (fast path) or provision a new one. + * + * New-user provisioning requires a pre-registered Organization whose + * `name` matches the email domain. The lookup uses `findUnique` because + * `Organization.name` carries a `@unique` constraint, making domain + * resolution unambiguous. * - * The user's email domain must match a pre-registered Organization. - * Sign-in is rejected when no matching organization is found so that tokens - * are never issued with an empty or invalid organizationId. + * Creation is made race-safe with a create → upsert fallback: if two + * concurrent OAuth callbacks race for the same `googleSub`, the second + * hits the unique constraint (P2002) and falls through to upsert, which + * no-ops on the existing row and returns it. */ async findOrCreate(input: UpsertUserInput): Promise { - const existing = await this.findByGoogleSub(input.googleSub); + // Fast path: returning users skip the org lookup entirely. + const existing = await this.prisma.user.findUnique({ + where: { googleSub: input.googleSub }, + }); if (existing) { return existing; } + // Validate email format – no PII in the error message. const atIndex = input.email.indexOf("@"); if (atIndex <= 0) { - throw new Error(`Invalid email format: ${input.email}`); + throw new BadRequestException("Invalid email format"); } const domain = input.email.slice(atIndex + 1); if (!domain) { - throw new Error(`Invalid email format: ${input.email}`); + throw new BadRequestException("Invalid email format"); } - const org = await this.prisma.organization.findFirst({ + // Resolve organization by domain. Organization.name is @unique so + // findUnique is safe and semantically correct here. + const org = await this.prisma.organization.findUnique({ where: { name: domain }, }); @@ -53,13 +81,33 @@ export class UserService { ); } - return this.prisma.user.create({ - data: { - googleSub: input.googleSub, - email: input.email, - role: "member", - organizationId: org.id, - }, - }); + // Atomic create with race-condition recovery. + // If a concurrent request already created this user, the unique + // constraint on googleSub raises P2002; we upsert to return the + // existing row without a second round-trip failure. + try { + return await this.prisma.user.create({ + data: { + googleSub: input.googleSub, + email: input.email, + role: "member", + organizationId: org.id, + }, + }); + } catch (err) { + if (isPrismaUniqueConstraintError(err)) { + return await this.prisma.user.upsert({ + where: { googleSub: input.googleSub }, + update: {}, + create: { + googleSub: input.googleSub, + email: input.email, + role: "member", + organizationId: org.id, + }, + }); + } + throw err; + } } } diff --git a/packages/db/prisma/migrations/0002_organization_name_unique/migration.sql b/packages/db/prisma/migrations/0002_organization_name_unique/migration.sql new file mode 100644 index 0000000..4ee1a0a --- /dev/null +++ b/packages/db/prisma/migrations/0002_organization_name_unique/migration.sql @@ -0,0 +1,5 @@ +-- Add unique constraint to organizations.name +-- Domain-based org lookup in user provisioning requires unambiguous resolution. + +-- CreateIndex +CREATE UNIQUE INDEX "organizations_name_key" ON "organizations"("name"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 9bd2b2d..60af888 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -17,7 +17,7 @@ datasource db { /// A tenant boundary representing one customer company. model Organization { id String @id @default(uuid()) - name String + name String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 4513920a898a4b4c437a36887b0e7e02d1b3c85d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 13 Jun 2026 11:00:27 +0000 Subject: [PATCH 4/4] ci: trigger CI on develop branch as well as main The PR targets the develop branch but ci.yml only fired on push/PR events targeting main, so CI was silently skipped on all feature PRs merging into develop. Add develop to both push and pull_request branch filters. Co-authored-by: Andrea Mazzucchelli --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8c28c8..68297b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] concurrency: group: ${{ github.workflow }}-${{ github.ref }}