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/.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 }} 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..2a5f5e7 --- /dev/null +++ b/apps/api/src/__mocks__/db-client.mock.ts @@ -0,0 +1,29 @@ +/** + * 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(), + upsert: jest.fn(), + }, + organization: { + findUnique: jest.fn(), + 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..f84ce15 100644 --- a/apps/api/src/app.module.spec.ts +++ b/apps/api/src/app.module.spec.ts @@ -1,12 +1,41 @@ 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", () => { + 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], - }).compile(); + }) + .overrideProvider(GoogleStrategy) + .useClass(MockGoogleStrategy) + .overrideProvider(PrismaService) + .useClass(MockPrismaService) + .compile(); expect(module).toBeDefined(); }); @@ -15,14 +44,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 +71,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({ @@ -42,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", + ); }); -}); \ 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..18f9792 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,77 @@ +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. + * 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) + 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..458d6ff --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,27 @@ +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.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], + 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..6114d8d --- /dev/null +++ b/apps/api/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,26 @@ +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) { + 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 new file mode 100644 index 0000000..d809f92 --- /dev/null +++ b/apps/api/src/auth/strategies/google.strategy.ts @@ -0,0 +1,48 @@ +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 { + // 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 (!verifiedEmail) { + done(new Error("No verified email returned from Google"), undefined); + return; + } + + // Forward the raw profile to the controller for DB upsert + JWT issuance. + const user: AuthenticatedUser & { googleSub: string } = { + id: "", + email: verifiedEmail, + 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..42dcd46 --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.spec.ts @@ -0,0 +1,62 @@ +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("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", + 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..8a22a80 --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,33 @@ +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() { + const jwtSecret = process.env["JWT_SECRET"]; + if (!jwtSecret) { + throw new Error("JWT_SECRET environment variable is required"); + } + + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: jwtSecret, + }); + } + + 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.spec.ts b/apps/api/src/auth/user.service.spec.ts new file mode 100644 index 0000000..2e34c59 --- /dev/null +++ b/apps/api/src/auth/user.service.spec.ts @@ -0,0 +1,203 @@ +import { Test, type TestingModule } from "@nestjs/testing"; +import { UnauthorizedException, BadRequestException } 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(), + upsert: jest.fn(), + }, + organization: { + findUnique: 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 touching org or create", 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.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.findUnique.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("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.findUnique.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 BadRequestException for email without @ symbol", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + service.findOrCreate({ + googleSub: "google-sub-123", + email: "notanemail", + }), + ).rejects.toThrow(BadRequestException); + + expect(mockPrisma.organization.findUnique).not.toHaveBeenCalled(); + }); + + it("throws BadRequestException 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(BadRequestException); + }); + + it("does not include PII in BadRequestException message", async () => { + mockPrisma.user.findUnique.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: "alice@acme.com", + }), + ).rejects.toThrow("Connection refused"); + + expect(mockPrisma.user.upsert).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/auth/user.service.ts b/apps/api/src/auth/user.service.ts new file mode 100644 index 0000000..443e40b --- /dev/null +++ b/apps/api/src/auth/user.service.ts @@ -0,0 +1,113 @@ +import { + Injectable, + UnauthorizedException, + BadRequestException, +} from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import type { User } from "db/client"; + +interface UpsertUserInput { + googleSub: string; + 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) {} + + async findByGoogleSub(googleSub: string): Promise { + return this.prisma.user.findUnique({ where: { googleSub } }); + } + + async findById(id: string): Promise { + return this.prisma.user.findUnique({ where: { id } }); + } + + /** + * 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. + * + * 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 { + // 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 BadRequestException("Invalid email format"); + } + const domain = input.email.slice(atIndex + 1); + if (!domain) { + throw new BadRequestException("Invalid email format"); + } + + // 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 }, + }); + + if (!org) { + throw new UnauthorizedException( + `No organization provisioned for email domain "${domain}". ` + + "Contact your administrator to register your domain.", + ); + } + + // 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/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/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 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"],