diff --git a/apps/backend/.env.example b/apps/backend/.env.example index ad9b515..560bb98 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -18,3 +18,13 @@ SERVICE_ACCOUNT={...} PORT=3000 NODE_ENV=development + +# Google OAuth (optional - for Google identity provider) +GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-google-client-secret + +# Self-issued JWT (RS256) - run `pnpm --filter backend generate:keys` to create keys +JWT_ISSUER=https://api.yourapp.com +JWT_PRIVATE_KEY_PATH=./keys/private.pem +JWT_PUBLIC_KEY_PATH=./keys/public.pem +JWT_EXPIRES_IN=1h diff --git a/apps/backend/package.json b/apps/backend/package.json index 2285e2c..77fccd8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -17,7 +17,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "generate:keys": "bash scripts/generate-keys.sh" }, "dependencies": { "@auth0/auth0-spa-js": "^2.1.3", @@ -33,6 +34,8 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.7", + "google-auth-library": "^10.5.0", + "jsonwebtoken": "^9.0.3", "jwks-rsa": "^3.2.0", "nestjs-cls": "^5.4.1", "passport": "^0.7.0", @@ -55,7 +58,7 @@ "@swc/core": "^1.10.7", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", - "@types/jsonwebtoken": "^9.0.9", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.10.7", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", diff --git a/apps/backend/scripts/generate-keys.sh b/apps/backend/scripts/generate-keys.sh new file mode 100644 index 0000000..95e62b7 --- /dev/null +++ b/apps/backend/scripts/generate-keys.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Generate RSA key pair for RS256 JWT signing +# This script creates private and public keys for self-issued JWT tokens + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KEYS_DIR="$SCRIPT_DIR/../keys" + +# Create keys directory if it doesn't exist +mkdir -p "$KEYS_DIR" + +# Check if keys already exist +if [ -f "$KEYS_DIR/private.pem" ] && [ -f "$KEYS_DIR/public.pem" ]; then + echo "Keys already exist in $KEYS_DIR" + echo "To regenerate, delete the existing keys first." + exit 0 +fi + +echo "Generating RSA key pair..." + +# Generate 2048-bit RSA private key +openssl genrsa -out "$KEYS_DIR/private.pem" 2048 + +# Extract public key from private key +openssl rsa -in "$KEYS_DIR/private.pem" -pubout -out "$KEYS_DIR/public.pem" + +# Set appropriate permissions +chmod 600 "$KEYS_DIR/private.pem" +chmod 644 "$KEYS_DIR/public.pem" + +echo "" +echo "RSA key pair generated successfully!" +echo " Private key: $KEYS_DIR/private.pem" +echo " Public key: $KEYS_DIR/public.pem" +echo "" +echo "Add these to your .env file:" +echo " JWT_PRIVATE_KEY_PATH=./keys/private.pem" +echo " JWT_PUBLIC_KEY_PATH=./keys/public.pem" +echo "" +echo "IMPORTANT: Never commit the private key to version control!" diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index 5a5cd62..06b4fac 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -2,12 +2,33 @@ import { Module, Global } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { JwtStrategy } from './jwt.strategy'; import { UsersModule } from 'src/modules/users/users.module'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { GoogleProvider } from './providers'; +import { JwtTokenService, SelfIssuedJwtStrategy } from './jwt'; @Global() @Module({ - imports: [ConfigModule, PassportModule.register({ defaultStrategy: 'jwt' }), UsersModule], - providers: [JwtStrategy], - exports: [PassportModule, UsersModule], + imports: [ + ConfigModule, + PassportModule.register({ defaultStrategy: 'jwt' }), + UsersModule, + ], + providers: [ + JwtStrategy, + SelfIssuedJwtStrategy, + JwtTokenService, + { + provide: GoogleProvider, + useFactory: (configService: ConfigService) => { + const googleClientId = configService.get('GOOGLE_CLIENT_ID'); + if (googleClientId) { + return new GoogleProvider(configService); + } + return null; + }, + inject: [ConfigService], + }, + ], + exports: [PassportModule, UsersModule, JwtTokenService, GoogleProvider], }) export class AuthModule {} diff --git a/apps/backend/src/auth/jwt/index.ts b/apps/backend/src/auth/jwt/index.ts new file mode 100644 index 0000000..9321cfb --- /dev/null +++ b/apps/backend/src/auth/jwt/index.ts @@ -0,0 +1,2 @@ +export * from './jwt.service'; +export * from './self-issued-jwt.strategy'; diff --git a/apps/backend/src/auth/jwt/jwt.service.spec.ts b/apps/backend/src/auth/jwt/jwt.service.spec.ts new file mode 100644 index 0000000..81ea621 --- /dev/null +++ b/apps/backend/src/auth/jwt/jwt.service.spec.ts @@ -0,0 +1,429 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { JwtTokenService, JwtPayload } from './jwt.service'; +import { Role } from '../enums/roles.enum'; +import * as jwt from 'jsonwebtoken'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; + +// Generate test RSA keys +const { publicKey: testPublicKey, privateKey: testPrivateKey } = + crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + +jest.mock('fs'); +const mockedFs = fs as jest.Mocked; + +describe('JwtTokenService', () => { + let service: JwtTokenService; + let mockConfigService: Partial; + + const mockUser = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', + status: 'accepted', + providerIds: [], + isSuperAdmin: false, + profileImage: null, + } as any; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockConfigService = { + get: jest.fn().mockImplementation((key: string) => { + const config: Record = { + JWT_ISSUER: 'https://test-api.example.com', + JWT_EXPIRES_IN: '1h', + JWT_PRIVATE_KEY_PATH: './keys/private.pem', + JWT_PUBLIC_KEY_PATH: './keys/public.pem', + }; + return config[key]; + }), + }; + + // Mock file reads to return test keys + mockedFs.readFileSync.mockImplementation((path: any) => { + if (path.includes('private')) return testPrivateKey; + if (path.includes('public')) return testPublicKey; + throw new Error('File not found'); + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtTokenService, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(JwtTokenService); + service.onModuleInit(); // Load keys + }); + + describe('onModuleInit', () => { + it('should load keys when paths are configured', () => { + expect(mockedFs.readFileSync).toHaveBeenCalledTimes(2); + expect(service.isAvailable()).toBe(true); + }); + + it('should not throw when keys are missing', async () => { + mockedFs.readFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file'); + }); + + const module = await Test.createTestingModule({ + providers: [ + JwtTokenService, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + const serviceWithoutKeys = module.get(JwtTokenService); + expect(() => serviceWithoutKeys.onModuleInit()).not.toThrow(); + expect(serviceWithoutKeys.isAvailable()).toBe(false); + }); + }); + + describe('isAvailable', () => { + it('should return true when keys are loaded', () => { + expect(service.isAvailable()).toBe(true); + }); + + it('should return false when keys are not loaded', async () => { + const configWithoutPaths = { + get: jest.fn().mockReturnValue(undefined), + }; + + const module = await Test.createTestingModule({ + providers: [ + JwtTokenService, + { provide: ConfigService, useValue: configWithoutPaths }, + ], + }).compile(); + + const serviceWithoutKeys = module.get(JwtTokenService); + serviceWithoutKeys.onModuleInit(); + expect(serviceWithoutKeys.isAvailable()).toBe(false); + }); + }); + + describe('getPublicKey', () => { + it('should return public key when loaded', () => { + const publicKey = service.getPublicKey(); + expect(publicKey).toBe(testPublicKey); + }); + + it('should throw when public key not loaded', async () => { + const configWithoutPaths = { + get: jest.fn().mockReturnValue(undefined), + }; + + const module = await Test.createTestingModule({ + providers: [ + JwtTokenService, + { provide: ConfigService, useValue: configWithoutPaths }, + ], + }).compile(); + + const serviceWithoutKeys = module.get(JwtTokenService); + serviceWithoutKeys.onModuleInit(); + expect(() => serviceWithoutKeys.getPublicKey()).toThrow( + 'Public key not loaded', + ); + }); + }); + + describe('generateAccessToken', () => { + it('should generate a valid JWT token', () => { + const token = service.generateAccessToken( + mockUser, + 'account-123', + Role.ADMIN, + ); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); // JWT format + }); + + it('should include correct claims in token', () => { + const token = service.generateAccessToken( + mockUser, + 'account-123', + Role.MANAGER, + ); + + const decoded = jwt.verify(token, testPublicKey, { + algorithms: ['RS256'], + }) as JwtPayload; + + expect(decoded.sub).toBe(mockUser.id); + expect(decoded.email).toBe(mockUser.email); + expect(decoded.accountId).toBe('account-123'); + expect(decoded.role).toBe(Role.MANAGER); + expect(decoded.iss).toBe('https://test-api.example.com'); + }); + + it('should throw when private key not loaded', async () => { + const configWithoutPaths = { + get: jest.fn().mockReturnValue(undefined), + }; + + const module = await Test.createTestingModule({ + providers: [ + JwtTokenService, + { provide: ConfigService, useValue: configWithoutPaths }, + ], + }).compile(); + + const serviceWithoutKeys = module.get(JwtTokenService); + serviceWithoutKeys.onModuleInit(); + + expect(() => + serviceWithoutKeys.generateAccessToken( + mockUser, + 'account-123', + Role.ADMIN, + ), + ).toThrow('Private key not loaded'); + }); + + it('should use RS256 algorithm', () => { + const token = service.generateAccessToken( + mockUser, + 'account-123', + Role.ADMIN, + ); + + const decoded = jwt.decode(token, { complete: true }); + expect(decoded?.header.alg).toBe('RS256'); + }); + }); + + describe('generateRefreshToken', () => { + it('should generate a valid refresh token', () => { + const token = service.generateRefreshToken(mockUser); + + expect(token).toBeDefined(); + expect(token.split('.')).toHaveLength(3); + }); + + it('should include type:refresh in payload', () => { + const token = service.generateRefreshToken(mockUser); + + const decoded = jwt.verify(token, testPublicKey, { + algorithms: ['RS256'], + }) as any; + + expect(decoded.type).toBe('refresh'); + expect(decoded.sub).toBe(mockUser.id); + }); + + it('should have longer expiration than access token', () => { + const accessToken = service.generateAccessToken( + mockUser, + 'account-123', + Role.ADMIN, + ); + const refreshToken = service.generateRefreshToken(mockUser); + + const accessDecoded = jwt.decode(accessToken) as any; + const refreshDecoded = jwt.decode(refreshToken) as any; + + // Refresh should expire later than access + expect(refreshDecoded.exp).toBeGreaterThan(accessDecoded.exp); + }); + }); + + describe('generateTokenPair', () => { + it('should return both access and refresh tokens', () => { + const pair = service.generateTokenPair( + mockUser, + 'account-123', + Role.ADMIN, + ); + + expect(pair.accessToken).toBeDefined(); + expect(pair.refreshToken).toBeDefined(); + expect(pair.expiresIn).toBeDefined(); + }); + + it('should include expiresIn in seconds', () => { + const pair = service.generateTokenPair( + mockUser, + 'account-123', + Role.ADMIN, + ); + + // 1h = 3600 seconds + expect(pair.expiresIn).toBe(3600); + }); + }); + + describe('verifyToken', () => { + it('should verify and decode valid token', () => { + const token = service.generateAccessToken( + mockUser, + 'account-123', + Role.ADMIN, + ); + + const payload = service.verifyToken(token); + + expect(payload.sub).toBe(mockUser.id); + expect(payload.email).toBe(mockUser.email); + }); + + it('should throw for expired token', () => { + // Create token that's already expired + const expiredToken = jwt.sign( + { sub: mockUser.id, email: mockUser.email }, + testPrivateKey, + { + algorithm: 'RS256', + expiresIn: '-1h', // Already expired + issuer: 'https://test-api.example.com', + }, + ); + + expect(() => service.verifyToken(expiredToken)).toThrow(); + }); + + it('should throw for token with wrong issuer', () => { + const wrongIssuerToken = jwt.sign( + { sub: mockUser.id, email: mockUser.email }, + testPrivateKey, + { + algorithm: 'RS256', + expiresIn: '1h', + issuer: 'https://wrong-issuer.com', + }, + ); + + expect(() => service.verifyToken(wrongIssuerToken)).toThrow(); + }); + + it('should throw for tampered token', () => { + const token = service.generateAccessToken( + mockUser, + 'account-123', + Role.ADMIN, + ); + + // Tamper with the token + const tamperedToken = token.slice(0, -5) + 'xxxxx'; + + expect(() => service.verifyToken(tamperedToken)).toThrow(); + }); + }); + + describe('decodeToken', () => { + it('should decode token without verification', () => { + const token = service.generateAccessToken( + mockUser, + 'account-123', + Role.ADMIN, + ); + + const decoded = service.decodeToken(token); + + expect(decoded?.sub).toBe(mockUser.id); + }); + + it('should return null for invalid token format', () => { + const decoded = service.decodeToken('not-a-valid-jwt'); + + expect(decoded).toBeNull(); + }); + }); + + describe('parseExpiresIn', () => { + it('should parse seconds correctly', async () => { + const configWith30s = { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'JWT_EXPIRES_IN') return '30s'; + if (key === 'JWT_PRIVATE_KEY_PATH') return './keys/private.pem'; + if (key === 'JWT_PUBLIC_KEY_PATH') return './keys/public.pem'; + return undefined; + }), + }; + + const module = await Test.createTestingModule({ + providers: [ + JwtTokenService, + { provide: ConfigService, useValue: configWith30s }, + ], + }).compile(); + + const serviceWith30s = module.get(JwtTokenService); + serviceWith30s.onModuleInit(); + + const pair = serviceWith30s.generateTokenPair( + mockUser, + 'account-123', + Role.ADMIN, + ); + expect(pair.expiresIn).toBe(30); + }); + + it('should parse minutes correctly', async () => { + const configWith5m = { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'JWT_EXPIRES_IN') return '5m'; + if (key === 'JWT_PRIVATE_KEY_PATH') return './keys/private.pem'; + if (key === 'JWT_PUBLIC_KEY_PATH') return './keys/public.pem'; + return undefined; + }), + }; + + const module = await Test.createTestingModule({ + providers: [ + JwtTokenService, + { provide: ConfigService, useValue: configWith5m }, + ], + }).compile(); + + const serviceWith5m = module.get(JwtTokenService); + serviceWith5m.onModuleInit(); + + const pair = serviceWith5m.generateTokenPair( + mockUser, + 'account-123', + Role.ADMIN, + ); + expect(pair.expiresIn).toBe(300); // 5 * 60 + }); + + it('should parse days correctly', async () => { + const configWith2d = { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'JWT_EXPIRES_IN') return '2d'; + if (key === 'JWT_PRIVATE_KEY_PATH') return './keys/private.pem'; + if (key === 'JWT_PUBLIC_KEY_PATH') return './keys/public.pem'; + return undefined; + }), + }; + + const module = await Test.createTestingModule({ + providers: [ + JwtTokenService, + { provide: ConfigService, useValue: configWith2d }, + ], + }).compile(); + + const serviceWith2d = module.get(JwtTokenService); + serviceWith2d.onModuleInit(); + + const pair = serviceWith2d.generateTokenPair( + mockUser, + 'account-123', + Role.ADMIN, + ); + expect(pair.expiresIn).toBe(172800); // 2 * 86400 + }); + }); +}); diff --git a/apps/backend/src/auth/jwt/jwt.service.ts b/apps/backend/src/auth/jwt/jwt.service.ts new file mode 100644 index 0000000..e2e8076 --- /dev/null +++ b/apps/backend/src/auth/jwt/jwt.service.ts @@ -0,0 +1,188 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; +import * as fs from 'fs'; +import * as path from 'path'; +import { User } from '../../entities/user.entity'; +import { Role } from '../enums/roles.enum'; + +export interface JwtPayload { + sub: string; + email: string; + accountId: string; + role: Role; + iss: string; + iat: number; + exp: number; +} + +export interface TokenPair { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +@Injectable() +export class JwtTokenService implements OnModuleInit { + private privateKey: string | null = null; + private publicKey: string | null = null; + private readonly issuer: string; + private readonly expiresIn: string; + + constructor(private config: ConfigService) { + this.issuer = config.get('JWT_ISSUER') || 'https://api.boilerplate.local'; + this.expiresIn = config.get('JWT_EXPIRES_IN') || '1h'; + } + + onModuleInit() { + this.loadKeys(); + } + + private loadKeys(): void { + const privateKeyPath = this.config.get('JWT_PRIVATE_KEY_PATH'); + const publicKeyPath = this.config.get('JWT_PUBLIC_KEY_PATH'); + + if (privateKeyPath && publicKeyPath) { + try { + const basePath = process.cwd(); + this.privateKey = fs.readFileSync( + path.resolve(basePath, privateKeyPath), + 'utf8', + ); + this.publicKey = fs.readFileSync( + path.resolve(basePath, publicKeyPath), + 'utf8', + ); + } catch (error) { + console.warn( + 'RSA keys not found. Self-issued JWT will not be available.', + 'Run `npm run generate:keys` to create RSA key pair.', + ); + } + } + } + + /** + * Check if self-issued JWT is available (keys loaded) + */ + isAvailable(): boolean { + return this.privateKey !== null && this.publicKey !== null; + } + + /** + * Get the public key for external verification + */ + getPublicKey(): string { + if (!this.publicKey) { + throw new Error('Public key not loaded. Run `npm run generate:keys`.'); + } + return this.publicKey; + } + + /** + * Generate a JWT access token for a user + * + * @param user - The user to generate token for + * @param accountId - The account context + * @param role - User's role in this account + * @returns Signed JWT access token + */ + generateAccessToken(user: User, accountId: string, role: Role): string { + if (!this.privateKey) { + throw new Error('Private key not loaded. Run `npm run generate:keys`.'); + } + + const payload = { + sub: user.id, + email: user.email, + accountId, + role, + }; + + return jwt.sign(payload, this.privateKey!, { + algorithm: 'RS256', + expiresIn: this.expiresIn as jwt.SignOptions['expiresIn'], + issuer: this.issuer, + }); + } + + /** + * Generate a refresh token (longer lived) + * + * @param user - The user to generate token for + * @returns Signed JWT refresh token + */ + generateRefreshToken(user: User): string { + if (!this.privateKey) { + throw new Error('Private key not loaded. Run `npm run generate:keys`.'); + } + + const payload = { + sub: user.id, + type: 'refresh', + }; + + return jwt.sign(payload, this.privateKey!, { + algorithm: 'RS256', + expiresIn: '7d' as jwt.SignOptions['expiresIn'], + issuer: this.issuer, + }); + } + + /** + * Generate both access and refresh tokens + */ + generateTokenPair(user: User, accountId: string, role: Role): TokenPair { + return { + accessToken: this.generateAccessToken(user, accountId, role), + refreshToken: this.generateRefreshToken(user), + expiresIn: this.parseExpiresIn(this.expiresIn), + }; + } + + /** + * Verify a JWT token signed by this service + * + * @param token - JWT to verify + * @returns Decoded payload + * @throws Error if token is invalid or expired + */ + verifyToken(token: string): JwtPayload { + if (!this.publicKey) { + throw new Error('Public key not loaded. Run `npm run generate:keys`.'); + } + + return jwt.verify(token, this.publicKey, { + algorithms: ['RS256'], + issuer: this.issuer, + }) as JwtPayload; + } + + /** + * Decode a JWT without verification (for debugging) + */ + decodeToken(token: string): JwtPayload | null { + return jwt.decode(token) as JwtPayload | null; + } + + private parseExpiresIn(expiresIn: string): number { + const match = expiresIn.match(/^(\d+)([smhd])$/); + if (!match) return 3600; // default 1h + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case 's': + return value; + case 'm': + return value * 60; + case 'h': + return value * 3600; + case 'd': + return value * 86400; + default: + return 3600; + } + } +} diff --git a/apps/backend/src/auth/jwt/self-issued-jwt.strategy.spec.ts b/apps/backend/src/auth/jwt/self-issued-jwt.strategy.spec.ts new file mode 100644 index 0000000..ed86728 --- /dev/null +++ b/apps/backend/src/auth/jwt/self-issued-jwt.strategy.spec.ts @@ -0,0 +1,162 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { SelfIssuedJwtStrategy } from './self-issued-jwt.strategy'; +import { JwtPayload } from './jwt.service'; +import { Role } from '../enums/roles.enum'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; + +// Generate test RSA keys +const { publicKey: testPublicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, +}); + +jest.mock('fs'); +const mockedFs = fs as jest.Mocked; + +describe('SelfIssuedJwtStrategy', () => { + let strategy: SelfIssuedJwtStrategy; + let mockConfigService: Partial; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockConfigService = { + get: jest.fn().mockImplementation((key: string) => { + const config: Record = { + JWT_ISSUER: 'https://test-api.example.com', + JWT_PUBLIC_KEY_PATH: './keys/public.pem', + }; + return config[key]; + }), + }; + + mockedFs.readFileSync.mockReturnValue(testPublicKey); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SelfIssuedJwtStrategy, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + strategy = module.get(SelfIssuedJwtStrategy); + }); + + describe('constructor', () => { + it('should read public key from configured path', () => { + expect(mockedFs.readFileSync).toHaveBeenCalled(); + }); + + it('should use default issuer when not configured', async () => { + const configWithoutIssuer = { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'JWT_PUBLIC_KEY_PATH') return './keys/public.pem'; + return undefined; + }), + }; + + const module = await Test.createTestingModule({ + providers: [ + SelfIssuedJwtStrategy, + { provide: ConfigService, useValue: configWithoutIssuer }, + ], + }).compile(); + + // Should not throw - uses default issuer + expect(module.get(SelfIssuedJwtStrategy)).toBeDefined(); + }); + + it('should not throw when key file is missing', async () => { + mockedFs.readFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file'); + }); + + // Should not throw - uses placeholder key + const module = await Test.createTestingModule({ + providers: [ + SelfIssuedJwtStrategy, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + expect(module.get(SelfIssuedJwtStrategy)).toBeDefined(); + }); + }); + + describe('validate', () => { + it('should return ValidatedUser from JWT payload', () => { + const payload: JwtPayload = { + sub: 'user-123', + email: 'test@example.com', + accountId: 'account-456', + role: Role.ADMIN, + iss: 'https://test-api.example.com', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + const result = strategy.validate(payload); + + expect(result).toEqual({ + userId: 'user-123', + email: 'test@example.com', + accountId: 'account-456', + role: Role.ADMIN, + }); + }); + + it('should extract userId from sub claim', () => { + const payload: JwtPayload = { + sub: 'specific-user-id', + email: 'user@test.com', + accountId: 'acc-1', + role: Role.VIEWER, + iss: 'issuer', + iat: 0, + exp: 0, + }; + + const result = strategy.validate(payload); + + expect(result.userId).toBe('specific-user-id'); + }); + + it('should preserve all role values', () => { + const roles = [Role.ADMIN, Role.MANAGER, Role.AUTHOR, Role.VIEWER]; + + roles.forEach((role) => { + const payload: JwtPayload = { + sub: 'user', + email: 'test@test.com', + accountId: 'acc', + role, + iss: 'issuer', + iat: 0, + exp: 0, + }; + + const result = strategy.validate(payload); + expect(result.role).toBe(role); + }); + }); + + it('should handle payload with special characters in email', () => { + const payload: JwtPayload = { + sub: 'user-123', + email: 'user+tag@example.com', + accountId: 'account-456', + role: Role.ADMIN, + iss: 'issuer', + iat: 0, + exp: 0, + }; + + const result = strategy.validate(payload); + + expect(result.email).toBe('user+tag@example.com'); + }); + }); +}); diff --git a/apps/backend/src/auth/jwt/self-issued-jwt.strategy.ts b/apps/backend/src/auth/jwt/self-issued-jwt.strategy.ts new file mode 100644 index 0000000..5b49e8d --- /dev/null +++ b/apps/backend/src/auth/jwt/self-issued-jwt.strategy.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import * as fs from 'fs'; +import * as path from 'path'; +import { JwtPayload } from './jwt.service'; + +interface ValidatedUser { + userId: string; + email: string; + accountId: string; + role: string; +} + +/** + * Passport strategy for validating self-issued JWTs (RS256). + * Used when the application manages its own tokens instead of Auth0. + */ +@Injectable() +export class SelfIssuedJwtStrategy extends PassportStrategy( + Strategy, + 'self-jwt', +) { + constructor(private readonly configService: ConfigService) { + const publicKeyPath = configService.get('JWT_PUBLIC_KEY_PATH'); + const issuer = + configService.get('JWT_ISSUER') || + 'https://api.boilerplate.local'; + + let publicKey: string | undefined; + if (publicKeyPath) { + try { + const basePath = process.cwd(); + publicKey = fs.readFileSync( + path.resolve(basePath, publicKeyPath), + 'utf8', + ); + } catch { + // Keys not available - strategy will fail validation + } + } + + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: publicKey || 'placeholder-key-will-fail', + algorithms: ['RS256'], + issuer, + }); + } + + /** + * Validate the decoded JWT payload and return the user object + * that will be attached to the request. + */ + validate(payload: JwtPayload): ValidatedUser { + return { + userId: payload.sub, + email: payload.email, + accountId: payload.accountId, + role: payload.role, + }; + } +} diff --git a/apps/backend/src/auth/providers/google.provider.spec.ts b/apps/backend/src/auth/providers/google.provider.spec.ts new file mode 100644 index 0000000..231de46 --- /dev/null +++ b/apps/backend/src/auth/providers/google.provider.spec.ts @@ -0,0 +1,202 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { GoogleProvider } from './google.provider'; + +// Mock google-auth-library +const mockVerifyIdToken = jest.fn(); +jest.mock('google-auth-library', () => ({ + OAuth2Client: jest.fn().mockImplementation(() => ({ + verifyIdToken: mockVerifyIdToken, + })), +})); + +describe('GoogleProvider', () => { + let provider: GoogleProvider; + let mockConfigService: Partial; + + const mockGoogleClientId = 'test-google-client-id.apps.googleusercontent.com'; + + beforeEach(async () => { + // Reset mocks + jest.clearAllMocks(); + + mockConfigService = { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'GOOGLE_CLIENT_ID') return mockGoogleClientId; + return undefined; + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GoogleProvider, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + provider = module.get(GoogleProvider); + }); + + describe('constructor', () => { + it('should throw error when GOOGLE_CLIENT_ID is not set', async () => { + const configWithoutClientId = { + get: jest.fn().mockReturnValue(undefined), + }; + + await expect( + Test.createTestingModule({ + providers: [ + GoogleProvider, + { provide: ConfigService, useValue: configWithoutClientId }, + ], + }).compile(), + ).rejects.toThrow('GOOGLE_CLIENT_ID is required'); + }); + + it('should create OAuth2Client with correct client ID', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { OAuth2Client } = require('google-auth-library'); + expect(OAuth2Client).toHaveBeenCalledWith(mockGoogleClientId); + }); + }); + + describe('providerName', () => { + it('should return "google"', () => { + expect(provider.providerName).toBe('google'); + }); + }); + + describe('verifyToken', () => { + const validPayload = { + sub: '123456789', + email: 'user@example.com', + name: 'Test User', + picture: 'https://example.com/photo.jpg', + email_verified: true, + }; + + it('should return IdentityProviderUser for valid token', async () => { + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => validPayload, + }); + + const result = await provider.verifyToken('valid-id-token'); + + expect(result).toEqual({ + providerId: 'google|123456789', + email: 'user@example.com', + name: 'Test User', + picture: 'https://example.com/photo.jpg', + emailVerified: true, + }); + }); + + it('should call verifyIdToken with correct parameters', async () => { + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => validPayload, + }); + + await provider.verifyToken('test-token'); + + expect(mockVerifyIdToken).toHaveBeenCalledWith({ + idToken: 'test-token', + audience: mockGoogleClientId, + }); + }); + + it('should use email prefix as name when name is not provided', async () => { + const payloadWithoutName = { + ...validPayload, + name: undefined, + }; + + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => payloadWithoutName, + }); + + const result = await provider.verifyToken('valid-token'); + + expect(result.name).toBe('user'); // email prefix + }); + + it('should default emailVerified to false when not provided', async () => { + const payloadWithoutVerified = { + ...validPayload, + email_verified: undefined, + }; + + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => payloadWithoutVerified, + }); + + const result = await provider.verifyToken('valid-token'); + + expect(result.emailVerified).toBe(false); + }); + + it('should throw error when payload is null', async () => { + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => null, + }); + + await expect(provider.verifyToken('invalid-token')).rejects.toThrow( + 'Invalid Google ID token: no payload', + ); + }); + + it('should throw error when email is missing from payload', async () => { + const payloadWithoutEmail = { + ...validPayload, + email: undefined, + }; + + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => payloadWithoutEmail, + }); + + await expect(provider.verifyToken('token-without-email')).rejects.toThrow( + 'Invalid Google ID token: no email', + ); + }); + + it('should propagate errors from verifyIdToken', async () => { + mockVerifyIdToken.mockRejectedValue( + new Error('Token verification failed'), + ); + + await expect(provider.verifyToken('invalid-token')).rejects.toThrow( + 'Token verification failed', + ); + }); + + it('should handle picture being undefined', async () => { + const payloadWithoutPicture = { + ...validPayload, + picture: undefined, + }; + + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => payloadWithoutPicture, + }); + + const result = await provider.verifyToken('valid-token'); + + expect(result.picture).toBeUndefined(); + }); + + it('should format providerId correctly with google prefix', async () => { + const payloadWithDifferentSub = { + ...validPayload, + sub: 'abc123xyz', + }; + + mockVerifyIdToken.mockResolvedValue({ + getPayload: () => payloadWithDifferentSub, + }); + + const result = await provider.verifyToken('valid-token'); + + expect(result.providerId).toBe('google|abc123xyz'); + }); + }); +}); diff --git a/apps/backend/src/auth/providers/google.provider.ts b/apps/backend/src/auth/providers/google.provider.ts new file mode 100644 index 0000000..c248708 --- /dev/null +++ b/apps/backend/src/auth/providers/google.provider.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { OAuth2Client } from 'google-auth-library'; +import { + IdentityProvider, + IdentityProviderUser, +} from './identity-provider.interface'; + +@Injectable() +export class GoogleProvider implements IdentityProvider { + readonly providerName = 'google'; + private client: OAuth2Client; + + constructor(private config: ConfigService) { + const clientId = config.get('GOOGLE_CLIENT_ID'); + if (!clientId) { + throw new Error( + 'GOOGLE_CLIENT_ID is required when using GoogleProvider', + ); + } + this.client = new OAuth2Client(clientId); + } + + /** + * Verify a Google ID token and extract user information. + * + * @param idToken - Google ID token from client-side sign-in + * @returns Standardized user information + */ + async verifyToken(idToken: string): Promise { + const ticket = await this.client.verifyIdToken({ + idToken, + audience: this.config.get('GOOGLE_CLIENT_ID'), + }); + + const payload = ticket.getPayload(); + if (!payload) { + throw new Error('Invalid Google ID token: no payload'); + } + + if (!payload.email) { + throw new Error('Invalid Google ID token: no email in payload'); + } + + return { + providerId: `google|${payload.sub}`, + email: payload.email, + name: payload.name || payload.email.split('@')[0], + picture: payload.picture, + emailVerified: payload.email_verified ?? false, + }; + } +} diff --git a/apps/backend/src/auth/providers/identity-provider.interface.ts b/apps/backend/src/auth/providers/identity-provider.interface.ts new file mode 100644 index 0000000..879ddc5 --- /dev/null +++ b/apps/backend/src/auth/providers/identity-provider.interface.ts @@ -0,0 +1,59 @@ +/** + * Standardized user info returned from any identity provider + */ +export interface IdentityProviderUser { + /** Provider-specific ID (e.g., 'google|123456', 'auth0|abc123') */ + providerId: string; + email: string; + name: string; + picture?: string; + emailVerified: boolean; +} + +/** + * Identity provider abstraction interface. + * Allows the application to work with multiple OAuth/identity providers + * (Auth0, Google, GitHub, etc.) through a common interface. + */ +export interface IdentityProvider { + /** Unique identifier for this provider (e.g., 'google', 'auth0') */ + readonly providerName: string; + + /** + * Verify an external token (ID token or access token) and return user info. + * This is the primary method for authenticating users who sign in via OAuth. + * + * @param token - The ID token or access token from the provider + * @returns User information extracted from the token + * @throws Error if token verification fails + */ + verifyToken(token: string): Promise; + + /** + * Optional: Send an invitation to create an account. + * Primarily used for providers like Auth0 that support user management. + * + * @param email - Email address to invite + * @param name - Name of the user being invited + * @returns The created user ID and optional invitation ticket/URL + */ + sendInvitation?( + email: string, + name: string, + ): Promise<{ userId: string; ticket?: string }>; + + /** + * Optional: Look up a user by email in the provider. + * + * @param email - Email to search for + * @returns User info if found, null otherwise + */ + getUserByEmail?(email: string): Promise; + + /** + * Optional: Revoke user's access/logout from provider. + * + * @param userId - Provider-specific user ID + */ + revokeAccess?(userId: string): Promise; +} diff --git a/apps/backend/src/auth/providers/index.ts b/apps/backend/src/auth/providers/index.ts new file mode 100644 index 0000000..c27662f --- /dev/null +++ b/apps/backend/src/auth/providers/index.ts @@ -0,0 +1,2 @@ +export * from './identity-provider.interface'; +export * from './google.provider'; diff --git a/apps/backend/src/modules/users/dto/google-oauth.dto.ts b/apps/backend/src/modules/users/dto/google-oauth.dto.ts new file mode 100644 index 0000000..c6bbc78 --- /dev/null +++ b/apps/backend/src/modules/users/dto/google-oauth.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class GoogleOAuthDto { + @ApiProperty({ + description: 'Google ID token from client-side sign-in', + example: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsString() + @IsNotEmpty() + idToken: string; +} + +export class OAuthTokenResponse { + @ApiProperty({ description: 'JWT access token' }) + accessToken: string; + + @ApiProperty({ description: 'JWT refresh token' }) + refreshToken: string; + + @ApiProperty({ description: 'Token expiration time in seconds' }) + expiresIn: number; + + @ApiProperty({ description: 'Authenticated user' }) + user: { + id: string; + email: string; + name: string; + }; +} diff --git a/apps/backend/src/modules/users/users.controller.ts b/apps/backend/src/modules/users/users.controller.ts index 55e181e..1f10e1e 100644 --- a/apps/backend/src/modules/users/users.controller.ts +++ b/apps/backend/src/modules/users/users.controller.ts @@ -15,12 +15,13 @@ import { import { UsersService } from './users.service'; import { UserDto } from './dto/user.dto'; import { UserAccountDto } from './dto/user-account.dto'; -import { ApiTags, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { ApiTags, ApiResponse, ApiBody, ApiOperation } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { MinRole } from '../../auth/decorators/min-role.decorator'; import { Role } from '../../auth/enums/roles.enum'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { LoginDto } from './dto/login.dto'; +import { GoogleOAuthDto, OAuthTokenResponse } from './dto/google-oauth.dto'; import { PaginationQueryDto } from 'src/utils'; @ApiTags('users') @@ -60,6 +61,23 @@ export class UsersController { return await this.usersService.login(userLogin, request.user); } + @Post('/oauth/google') + @UseGuards() // Remove JWT guard for this endpoint + @UsePipes(new ValidationPipe()) + @ApiOperation({ summary: 'Authenticate with Google OAuth' }) + @ApiResponse({ + status: 201, + description: 'Successfully authenticated with Google', + type: OAuthTokenResponse, + }) + @ApiResponse({ status: 400, description: 'Invalid Google token.' }) + @ApiResponse({ status: 403, description: 'User not found or not allowed.' }) + async googleOAuth( + @Body() googleOAuthDto: GoogleOAuthDto, + ): Promise { + return await this.usersService.loginWithGoogle(googleOAuthDto.idToken); + } + @Post('/accounts') @MinRole(Role.MANAGER) @UsePipes(new ValidationPipe()) diff --git a/apps/backend/src/modules/users/users.service.ts b/apps/backend/src/modules/users/users.service.ts index 07bb640..1c4cf42 100644 --- a/apps/backend/src/modules/users/users.service.ts +++ b/apps/backend/src/modules/users/users.service.ts @@ -2,6 +2,7 @@ import { Injectable, ForbiddenException, BadRequestException, + Optional, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { ClsService } from 'nestjs-cls'; @@ -11,7 +12,10 @@ import { Repository } from 'typeorm'; import { UserDto } from './dto/user.dto'; import { UserAccountDto } from './dto/user-account.dto'; import { LoginDto } from './dto/login.dto'; +import { OAuthTokenResponse } from './dto/google-oauth.dto'; import { Auth0Provider } from './providers/auth0.provider'; +import { GoogleProvider } from '../../auth/providers'; +import { JwtTokenService } from '../../auth/jwt'; import { AccountsService } from '../accounts/accounts.service'; import { Role } from 'src/auth/enums/roles.enum'; import { v7 as uuidv7 } from 'uuid'; @@ -27,6 +31,8 @@ export class UsersService { private readonly cls: ClsService, private readonly auth0Provider: Auth0Provider, private readonly accountsService: AccountsService, + @Optional() private readonly googleProvider: GoogleProvider | null, + @Optional() private readonly jwtTokenService: JwtTokenService | null, ) {} async find() { @@ -309,6 +315,103 @@ export class UsersService { return user; } + /** + * Authenticate a user via Google OAuth. + * Verifies the Google ID token, finds or creates the user, + * and returns self-issued JWT tokens. + */ + async loginWithGoogle(idToken: string): Promise { + if (!this.googleProvider) { + throw new BadRequestException( + 'Google OAuth is not configured. Set GOOGLE_CLIENT_ID in environment.', + ); + } + + if (!this.jwtTokenService || !this.jwtTokenService.isAvailable()) { + throw new BadRequestException( + 'JWT token service is not available. Run `npm run generate:keys`.', + ); + } + + // Verify the Google ID token + const googleUser = await this.googleProvider.verifyToken(idToken); + + // Set transaction ID for audit + this.cls.set('transactionId', uuidv7()); + + // Find user by provider ID or email + let user = await this.findByProviderId(googleUser.providerId); + + if (!user) { + // Try to find by email (user might exist from Auth0 or invitation) + user = await this.userRepository.findOne({ + where: { email: googleUser.email }, + relations: ['userAccounts'], + }); + } + + if (!user) { + throw new ForbiddenException( + 'User not found. Please contact an administrator to create your account.', + ); + } + + // Update user status and add Google provider ID if not already present + if (user.status !== 'accepted') { + user.status = 'accepted'; + } + + if (!user.providerIds.includes(googleUser.providerId)) { + user.addProvider(googleUser.providerId); + } + + // Update profile image if not set + if (!user.profileImage && googleUser.picture) { + user.profileImage = googleUser.picture; + } + + await this.userRepository.save(user); + + // Reload user with relations + user = await this.userRepository.findOne({ + where: { id: user.id }, + relations: ['userAccounts', 'userAccounts.account'], + }); + + if (!user) { + throw new ForbiddenException('User not found after save.'); + } + + // Determine account and role for token + let accountId: string; + let role: Role; + + if (user.isSuperAdmin) { + const accounts = await this.accountsService.findAll(user); + accountId = accounts[0]?.id || 'no-account'; + role = Role.ADMIN; + } else if (user.userAccounts?.length) { + accountId = user.userAccounts[0].accountId; + role = user.userAccounts[0].role; + } else { + throw new ForbiddenException('User has no account access.'); + } + + // Generate tokens + const tokens = this.jwtTokenService.generateTokenPair(user, accountId, role); + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: tokens.expiresIn, + user: { + id: user.id, + email: user.email || googleUser.email, + name: user.name, + }, + }; + } + async createUserAccounts(userAccounts: UserAccountDto[]) { for (const userAccount of userAccounts) { await this.userAccountRepository.save(userAccount as UserAccount); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db6de3e..b66b457 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,12 @@ importers: dotenv: specifier: ^16.4.7 version: 16.6.1 + google-auth-library: + specifier: ^10.5.0 + version: 10.5.0 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 jwks-rsa: specifier: ^3.2.0 version: 3.2.0 @@ -124,7 +130,7 @@ importers: specifier: ^29.5.14 version: 29.5.14 '@types/jsonwebtoken': - specifier: ^9.0.9 + specifier: ^9.0.10 version: 9.0.10 '@types/node': specifier: ^22.10.7 @@ -6195,6 +6201,11 @@ packages: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true + /data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + dev: false + /data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -7512,6 +7523,14 @@ packages: dependencies: picomatch: 4.0.3 + /fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + dev: false + /fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -7716,6 +7735,13 @@ packages: hasown: 2.0.2 mime-types: 2.1.35 + /formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + dependencies: + fetch-blob: 3.2.0 + dev: false + /formidable@3.5.4: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} @@ -7835,6 +7861,18 @@ packages: - supports-color dev: false + /gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + dev: false + /gcp-metadata@6.1.1: resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} engines: {node: '>=14'} @@ -7847,6 +7885,17 @@ packages: - supports-color dev: false + /gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -8005,6 +8054,21 @@ packages: slash: 3.0.0 dev: true + /google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /google-auth-library@9.15.1: resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} engines: {node: '>=14'} @@ -8025,6 +8089,11 @@ packages: engines: {node: '>=14'} dev: false + /google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + dev: false + /gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -8064,6 +8133,16 @@ packages: - supports-color dev: false + /gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + dependencies: + gaxios: 7.1.3 + jws: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -9273,11 +9352,11 @@ packages: optionalDependencies: graceful-fs: 4.2.11 - /jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + /jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} dependencies: - jws: 3.2.2 + jws: 4.0.1 lodash.includes: 4.3.0 lodash.isboolean: 3.0.3 lodash.isinteger: 4.0.4 @@ -9299,14 +9378,6 @@ packages: object.values: 1.2.1 dev: true - /jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - dev: false - /jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} dependencies: @@ -9329,15 +9400,15 @@ packages: - supports-color dev: false - /jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + /jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} dependencies: - jwa: 1.4.2 + jwa: 2.0.1 safe-buffer: 5.2.1 dev: false - /jws@4.0.0: - resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + /jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} dependencies: jwa: 2.0.1 safe-buffer: 5.2.1 @@ -10106,6 +10177,12 @@ packages: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} dev: false + /node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + dev: false + /node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} dependencies: @@ -10124,6 +10201,15 @@ packages: whatwg-url: 5.0.0 dev: false + /node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + dev: false + /node-gyp@8.4.1: resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==} engines: {node: '>= 10.12.0'} @@ -10456,7 +10542,7 @@ packages: /passport-jwt@4.0.1: resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} dependencies: - jsonwebtoken: 9.0.2 + jsonwebtoken: 9.0.3 passport-strategy: 1.0.0 dev: false @@ -11101,6 +11187,13 @@ packages: dependencies: glob: 7.2.3 + /rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + dependencies: + glob: 10.4.5 + dev: false + /rollup@4.52.4: resolution: {integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -13147,6 +13240,11 @@ packages: defaults: 1.0.4 dev: true + /web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + dev: false + /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: false