feat(auth): implement Google and Zalo OAuth backend strategies

Add complete OAuth2 authentication flow for Google and Zalo providers:
- OAuthService: handles account linking (by email/phone), new user
  creation for OAuth-only accounts, and JWT token generation
- GoogleOAuthStrategy: passport-google-oauth20 integration
- ZaloOAuthStrategy: custom OAuth2 implementation using Zalo's API
  (authorization URL generation, code exchange, user info fetch)
- OAuthController: redirect and callback endpoints for both providers
  with httpOnly cookie-based token management
- Unit tests for OAuthService (7 tests), GoogleOAuthStrategy (4 tests),
  and ZaloOAuthStrategy (7 tests)
- OAuth env vars added to .env.example and env-validation warnings

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 14:14:02 +07:00
parent bac3313873
commit 23bb380d34
14 changed files with 1068 additions and 2 deletions

View File

@@ -60,6 +60,19 @@ JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=CHANGE_ME JWT_REFRESH_SECRET=CHANGE_ME
JWT_REFRESH_EXPIRES_IN=7d JWT_REFRESH_EXPIRES_IN=7d
# -----------------------------------------------------------------------------
# OAuth Providers
# -----------------------------------------------------------------------------
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:3001/auth/google/callback
ZALO_APP_ID=
ZALO_APP_SECRET=
ZALO_CALLBACK_URL=http://localhost:3001/auth/zalo/callback
FRONTEND_URL=http://localhost:3000
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Next.js Web # Next.js Web
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -40,6 +40,7 @@
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"nodemailer": "^8.0.5", "nodemailer": "^8.0.5",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg": "^8.20.0", "pg": "^8.20.0",
@@ -61,6 +62,7 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^25.5.2", "@types/node": "^25.5.2",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",

View File

@@ -12,10 +12,14 @@ import { REFRESH_TOKEN_REPOSITORY } from './domain/repositories/refresh-token.re
import { USER_REPOSITORY } from './domain/repositories/user.repository'; import { USER_REPOSITORY } from './domain/repositories/user.repository';
import { PrismaRefreshTokenRepository } from './infrastructure/repositories/prisma-refresh-token.repository'; import { PrismaRefreshTokenRepository } from './infrastructure/repositories/prisma-refresh-token.repository';
import { PrismaUserRepository } from './infrastructure/repositories/prisma-user.repository'; import { PrismaUserRepository } from './infrastructure/repositories/prisma-user.repository';
import { OAuthService } from './infrastructure/services/oauth.service';
import { TokenService } from './infrastructure/services/token.service'; import { TokenService } from './infrastructure/services/token.service';
import { GoogleOAuthStrategy } from './infrastructure/strategies/google-oauth.strategy';
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy'; import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
import { LocalStrategy } from './infrastructure/strategies/local.strategy'; import { LocalStrategy } from './infrastructure/strategies/local.strategy';
import { ZaloOAuthStrategy } from './infrastructure/strategies/zalo-oauth.strategy';
import { AuthController } from './presentation/controllers/auth.controller'; import { AuthController } from './presentation/controllers/auth.controller';
import { OAuthController } from './presentation/controllers/oauth.controller';
const CommandHandlers = [ const CommandHandlers = [
RegisterUserHandler, RegisterUserHandler,
@@ -41,7 +45,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
signOptions: { expiresIn: '15m', audience: 'goodgo-api', issuer: 'goodgo-platform' }, signOptions: { expiresIn: '15m', audience: 'goodgo-api', issuer: 'goodgo-platform' },
}), }),
], ],
controllers: [AuthController], controllers: [AuthController, OAuthController],
providers: [ providers: [
// Repositories // Repositories
{ provide: USER_REPOSITORY, useClass: PrismaUserRepository }, { provide: USER_REPOSITORY, useClass: PrismaUserRepository },
@@ -50,14 +54,17 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
// Strategies // Strategies
JwtStrategy, JwtStrategy,
LocalStrategy, LocalStrategy,
GoogleOAuthStrategy,
ZaloOAuthStrategy,
// Services // Services
TokenService, TokenService,
OAuthService,
// CQRS // CQRS
...CommandHandlers, ...CommandHandlers,
...QueryHandlers, ...QueryHandlers,
], ],
exports: [TokenService, USER_REPOSITORY], exports: [TokenService, OAuthService, USER_REPOSITORY],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -0,0 +1,103 @@
import { type OAuthService } from '../services/oauth.service';
import { type TokenPair } from '../services/token.service';
// Mock passport-google-oauth20 before importing GoogleOAuthStrategy
vi.mock('passport-google-oauth20', () => {
return {
Strategy: class MockStrategy {
constructor(_options: unknown, verify: (...args: unknown[]) => void) {
// Store verify callback so we can call it in tests
(this as any)._verify = verify;
}
},
};
});
// Mock @nestjs/passport
vi.mock('@nestjs/passport', () => {
return {
PassportStrategy: (StrategyClass: any, _name?: string) => {
return class extends StrategyClass {};
},
};
});
describe('GoogleOAuthStrategy', () => {
const mockTokenPair: TokenPair = {
accessToken: 'test-jwt',
refreshToken: 'family.token',
expiresIn: 900,
};
let mockOAuthService: { authenticateOAuth: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.stubEnv('GOOGLE_CLIENT_ID', 'test-client-id');
vi.stubEnv('GOOGLE_CLIENT_SECRET', 'test-client-secret');
vi.stubEnv('GOOGLE_CALLBACK_URL', 'http://localhost:3001/auth/google/callback');
mockOAuthService = {
authenticateOAuth: vi.fn().mockResolvedValue(mockTokenPair),
};
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('throws if GOOGLE_CLIENT_ID is missing', () => {
vi.stubEnv('GOOGLE_CLIENT_ID', '');
// Reset module to pick up new env
expect(async () => {
const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy');
new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService);
}).rejects.toThrow('GOOGLE_CLIENT_ID');
});
it('creates strategy with correct config', async () => {
const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy');
const strategy = new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService);
expect(strategy).toBeDefined();
});
it('validate() calls oauthService.authenticateOAuth with correct profile', async () => {
const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy');
const strategy = new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService);
const done = vi.fn();
const googleProfile = {
id: 'google-user-123',
displayName: 'Test User',
emails: [{ value: 'test@gmail.com', verified: true }],
photos: [{ value: 'https://photo.url/pic.jpg' }],
name: { givenName: 'Test', familyName: 'User' },
_json: { sub: 'google-user-123', email: 'test@gmail.com' },
};
await strategy.validate('access-token', 'refresh-token', googleProfile as any, done);
expect(mockOAuthService.authenticateOAuth).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'GOOGLE',
providerUserId: 'google-user-123',
email: 'test@gmail.com',
fullName: 'Test User',
accessToken: 'access-token',
refreshToken: 'refresh-token',
}),
);
expect(done).toHaveBeenCalledWith(null, mockTokenPair);
});
it('validate() passes error to done callback on failure', async () => {
mockOAuthService.authenticateOAuth.mockRejectedValue(new Error('OAuth failed'));
const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy');
const strategy = new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService);
const done = vi.fn();
await strategy.validate('token', 'refresh', { id: '1', displayName: 'X', _json: {} } as any, done);
expect(done).toHaveBeenCalledWith(expect.any(Error), undefined);
});
});

View File

@@ -0,0 +1,181 @@
import { type OAuthProvider } from '@prisma/client';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { UserEntity } from '../../domain/entities/user.entity';
import { Email } from '../../domain/value-objects/email.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
import { OAuthService, type OAuthUserProfile } from '../services/oauth.service';
import { type TokenPair } from '../services/token.service';
describe('OAuthService', () => {
let service: OAuthService;
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
let mockTokenService: { generateTokenPair: ReturnType<typeof vi.fn> };
let mockPrisma: {
oAuthAccount: {
findUnique: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
};
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
const mockTokenPair: TokenPair = {
accessToken: 'test-jwt',
refreshToken: 'family.token',
expiresIn: 900,
};
const googleProfile: OAuthUserProfile = {
provider: 'GOOGLE' as OAuthProvider,
providerUserId: 'google-123',
email: 'user@gmail.com',
fullName: 'Test User',
avatarUrl: 'https://photo.url',
accessToken: 'google-access-token',
refreshToken: 'google-refresh-token',
rawProfile: { sub: 'google-123' },
};
const makeUserEntity = (id: string, phone: string, email?: string): UserEntity => {
return new UserEntity(id, {
email: email ? Email.create(email).unwrap() : null,
phone: Phone.create(phone).unwrap(),
passwordHash: null,
fullName: 'Existing User',
avatarUrl: null,
role: 'BUYER',
kycStatus: 'NONE',
kycData: null,
isActive: true,
});
};
beforeEach(() => {
mockUserRepo = {
findById: vi.fn().mockResolvedValue(null),
findByPhone: vi.fn().mockResolvedValue(null),
findByEmail: vi.fn().mockResolvedValue(null),
save: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
};
mockTokenService = {
generateTokenPair: vi.fn().mockResolvedValue(mockTokenPair),
};
mockPrisma = {
oAuthAccount: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
},
};
mockEventBus = { publish: vi.fn() };
service = new OAuthService(
mockUserRepo as any,
mockTokenService as any,
mockPrisma as any,
mockEventBus as any,
);
});
describe('authenticateOAuth', () => {
it('returns tokens for existing OAuth account', async () => {
mockPrisma.oAuthAccount.findUnique.mockResolvedValue({
id: 'oauth-1',
userId: 'user-1',
provider: 'GOOGLE',
accessToken: 'old-token',
refreshToken: null,
expiresAt: null,
profile: null,
user: { id: 'user-1', phone: '0912345678', role: 'BUYER', isActive: true },
});
const result = await service.authenticateOAuth(googleProfile);
expect(result).toEqual(mockTokenPair);
expect(mockPrisma.oAuthAccount.update).toHaveBeenCalled();
expect(mockUserRepo.save).not.toHaveBeenCalled();
});
it('throws for deactivated user with existing OAuth account', async () => {
mockPrisma.oAuthAccount.findUnique.mockResolvedValue({
id: 'oauth-1',
userId: 'user-1',
user: { id: 'user-1', phone: '0912345678', role: 'BUYER', isActive: false },
});
await expect(service.authenticateOAuth(googleProfile)).rejects.toThrow(
'Tài khoản đã bị vô hiệu hóa',
);
});
it('links OAuth account to existing user by email', async () => {
const existingUser = makeUserEntity('user-2', '+84912345678', 'user@gmail.com');
mockUserRepo.findByEmail.mockResolvedValue(existingUser);
const result = await service.authenticateOAuth(googleProfile);
expect(result).toEqual(mockTokenPair);
expect(mockPrisma.oAuthAccount.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: 'user-2',
provider: 'GOOGLE',
providerUserId: 'google-123',
}),
}),
);
expect(mockUserRepo.save).not.toHaveBeenCalled();
});
it('links OAuth account to existing user by phone', async () => {
const profileWithPhone: OAuthUserProfile = {
...googleProfile,
email: undefined,
phone: '+84912345678',
};
const existingUser = makeUserEntity('user-3', '+84912345678');
mockUserRepo.findByPhone.mockResolvedValue(existingUser);
const result = await service.authenticateOAuth(profileWithPhone);
expect(result).toEqual(mockTokenPair);
expect(mockPrisma.oAuthAccount.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ userId: 'user-3' }),
}),
);
});
it('creates new user when no existing account matches', async () => {
const result = await service.authenticateOAuth(googleProfile);
expect(result).toEqual(mockTokenPair);
expect(mockUserRepo.save).toHaveBeenCalled();
expect(mockPrisma.oAuthAccount.create).toHaveBeenCalled();
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('creates new user without password hash for OAuth accounts', async () => {
await service.authenticateOAuth(googleProfile);
const savedUser = mockUserRepo.save.mock.calls[0][0] as UserEntity;
expect(savedUser.passwordHash).toBeNull();
expect(savedUser.fullName).toBe('Test User');
});
it('throws for deactivated user found by email link', async () => {
const deactivatedUser = makeUserEntity('user-4', '+84912345678', 'user@gmail.com');
deactivatedUser.deactivate();
mockUserRepo.findByEmail.mockResolvedValue(deactivatedUser);
await expect(service.authenticateOAuth(googleProfile)).rejects.toThrow(
'Tài khoản đã bị vô hiệu hóa',
);
});
});
});

View File

@@ -0,0 +1,183 @@
import { type OAuthService } from '../services/oauth.service';
import { ZaloOAuthStrategy } from '../strategies/zalo-oauth.strategy';
import { type TokenPair } from '../services/token.service';
describe('ZaloOAuthStrategy', () => {
const mockTokenPair: TokenPair = {
accessToken: 'test-jwt',
refreshToken: 'family.token',
expiresIn: 900,
};
let mockOAuthService: { authenticateOAuth: ReturnType<typeof vi.fn> };
let strategy: ZaloOAuthStrategy;
beforeEach(() => {
vi.stubEnv('ZALO_APP_ID', 'test-zalo-app-id');
vi.stubEnv('ZALO_APP_SECRET', 'test-zalo-secret');
vi.stubEnv('ZALO_CALLBACK_URL', 'http://localhost:3001/auth/zalo/callback');
mockOAuthService = {
authenticateOAuth: vi.fn().mockResolvedValue(mockTokenPair),
};
strategy = new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('throws if ZALO_APP_ID is missing', () => {
vi.stubEnv('ZALO_APP_ID', '');
expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService))
.toThrow('ZALO_APP_ID');
});
it('throws if ZALO_APP_SECRET is missing', () => {
vi.stubEnv('ZALO_APP_SECRET', '');
expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService))
.toThrow('ZALO_APP_SECRET');
});
describe('getAuthorizationUrl', () => {
it('returns correct Zalo authorization URL', () => {
const url = strategy.getAuthorizationUrl('test-state');
expect(url).toContain('https://oauth.zaloapp.com/v4/permission');
expect(url).toContain('app_id=test-zalo-app-id');
expect(url).toContain('redirect_uri=');
expect(url).toContain('state=test-state');
});
it('works without state parameter', () => {
const url = strategy.getAuthorizationUrl();
expect(url).toContain('https://oauth.zaloapp.com/v4/permission');
expect(url).toContain('app_id=test-zalo-app-id');
});
});
describe('handleCallback', () => {
it('exchanges code, fetches user info, and returns tokens', async () => {
// Mock fetch for token exchange
const mockFetch = vi.fn()
.mockResolvedValueOnce({
json: () => Promise.resolve({
access_token: 'zalo-access-token',
refresh_token: 'zalo-refresh-token',
expires_in: 3600,
}),
})
// Mock fetch for user info
.mockResolvedValueOnce({
json: () => Promise.resolve({
id: 'zalo-user-123',
name: 'Nguyen Van A',
picture: { data: { url: 'https://zalo.me/avatar.jpg' } },
}),
});
vi.stubGlobal('fetch', mockFetch);
const result = await strategy.handleCallback('auth-code-123');
expect(result.tokens).toEqual(mockTokenPair);
expect(mockOAuthService.authenticateOAuth).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'ZALO',
providerUserId: 'zalo-user-123',
fullName: 'Nguyen Van A',
accessToken: 'zalo-access-token',
refreshToken: 'zalo-refresh-token',
}),
);
// Verify token exchange request
expect(mockFetch).toHaveBeenCalledWith(
'https://oauth.zaloapp.com/v4/access_token',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'secret_key': 'test-zalo-secret',
}),
}),
);
// Verify user info request
expect(mockFetch).toHaveBeenCalledWith(
'https://graph.zalo.me/v2.0/me?fields=id,name,picture',
expect.objectContaining({
headers: expect.objectContaining({
'access_token': 'zalo-access-token',
}),
}),
);
});
it('throws on Zalo token exchange error', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: () => Promise.resolve({
error: -216,
error_description: 'Invalid authorization code',
}),
}));
await expect(strategy.handleCallback('bad-code')).rejects.toThrow(
'Zalo OAuth error: Invalid authorization code',
);
});
it('throws when Zalo returns no access token', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: () => Promise.resolve({}),
}));
await expect(strategy.handleCallback('code')).rejects.toThrow(
'No access token',
);
});
it('throws on Zalo user info error', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce({
json: () => Promise.resolve({
access_token: 'token',
expires_in: 3600,
}),
})
.mockResolvedValueOnce({
json: () => Promise.resolve({
error: -401,
message: 'Unauthorized',
}),
});
vi.stubGlobal('fetch', mockFetch);
await expect(strategy.handleCallback('code')).rejects.toThrow(
'Zalo OAuth error: Unauthorized',
);
});
it('passes code_verifier when provided', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce({
json: () => Promise.resolve({
access_token: 'token',
expires_in: 3600,
}),
})
.mockResolvedValueOnce({
json: () => Promise.resolve({ id: 'z-1', name: 'User' }),
});
vi.stubGlobal('fetch', mockFetch);
await strategy.handleCallback('code', 'pkce-verifier');
const tokenBody = mockFetch.mock.calls[0][1].body as string;
expect(tokenBody).toContain('code_verifier=pkce-verifier');
});
});
});

View File

@@ -0,0 +1,179 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type EventBus } from '@nestjs/cqrs';
import { type OAuthProvider, type Prisma } from '@prisma/client';
import { createId } from '@paralleldrive/cuid2';
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
import { UserEntity } from '../../domain/entities/user.entity';
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
import { Email } from '../../domain/value-objects/email.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
import { type TokenService, type TokenPair } from './token.service';
export interface OAuthUserProfile {
provider: OAuthProvider;
providerUserId: string;
email?: string;
phone?: string;
fullName: string;
avatarUrl?: string;
accessToken?: string;
refreshToken?: string;
expiresAt?: Date;
rawProfile?: Record<string, unknown>;
}
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly tokenService: TokenService,
private readonly prisma: PrismaService,
private readonly eventBus: EventBus,
) {}
/**
* Authenticate via OAuth: find existing linked account, link to existing user,
* or create a new user. Returns JWT token pair.
*/
async authenticateOAuth(profile: OAuthUserProfile): Promise<TokenPair> {
// 1. Check if this OAuth account already exists
const existingOAuth = await this.prisma.oAuthAccount.findUnique({
where: {
provider_providerUserId: {
provider: profile.provider,
providerUserId: profile.providerUserId,
},
},
include: { user: true },
});
if (existingOAuth) {
// Update tokens
await this.prisma.oAuthAccount.update({
where: { id: existingOAuth.id },
data: {
accessToken: profile.accessToken ?? existingOAuth.accessToken,
refreshToken: profile.refreshToken ?? existingOAuth.refreshToken,
expiresAt: profile.expiresAt ?? existingOAuth.expiresAt,
profile: (profile.rawProfile ?? existingOAuth.profile) as Prisma.InputJsonValue,
},
});
if (!existingOAuth.user.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa');
}
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`);
return this.generateTokensForUser(existingOAuth.user);
}
// 2. Try to link to existing user by email
if (profile.email) {
const existingUser = await this.userRepo.findByEmail(profile.email);
if (existingUser) {
if (!existingUser.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa');
}
await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`);
return this.generateTokensForUser({
id: existingUser.id,
phone: existingUser.phone.value,
role: existingUser.role,
});
}
}
// 3. Try to link to existing user by phone
if (profile.phone) {
const phoneVo = Phone.create(profile.phone);
if (phoneVo.isOk) {
const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value);
if (existingUser) {
if (!existingUser.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa');
}
await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`);
return this.generateTokensForUser({
id: existingUser.id,
phone: existingUser.phone.value,
role: existingUser.role,
});
}
}
}
// 4. Create new user + OAuth account
const userId = createId();
const phone = this.resolvePhone(profile.phone);
const email = profile.email ? Email.create(profile.email) : null;
const user = new UserEntity(userId, {
email: email?.isOk ? email.unwrap() : null,
phone,
passwordHash: null, // OAuth users have no password
fullName: profile.fullName,
avatarUrl: profile.avatarUrl ?? null,
role: 'BUYER',
kycStatus: 'NONE',
kycData: null,
isActive: true,
});
await this.userRepo.save(user);
await this.createOAuthAccount(userId, profile);
// Publish domain event
this.eventBus.publish(new UserRegisteredEvent(userId, phone.value, 'BUYER'));
this.logger.log(`OAuth register: new user created via ${profile.provider}`);
return this.generateTokensForUser({
id: userId,
phone: phone.value,
role: 'BUYER',
});
}
private async createOAuthAccount(userId: string, profile: OAuthUserProfile): Promise<void> {
await this.prisma.oAuthAccount.create({
data: {
id: createId(),
userId,
provider: profile.provider,
providerUserId: profile.providerUserId,
accessToken: profile.accessToken,
refreshToken: profile.refreshToken,
expiresAt: profile.expiresAt,
profile: (profile.rawProfile as Prisma.InputJsonValue) ?? undefined,
},
});
}
private generateTokensForUser(user: { id: string; phone: string; role: string }): Promise<TokenPair> {
return this.tokenService.generateTokenPair({
sub: user.id,
phone: user.phone,
role: user.role,
});
}
/**
* Generate a placeholder phone for OAuth users who don't provide one.
* Vietnamese phone format required by the schema — uses 09xx prefix
* to satisfy validation. User should update their phone later.
*/
private resolvePhone(phone?: string): Phone {
if (phone) {
const result = Phone.create(phone);
if (result.isOk) return result.unwrap();
}
// Generate a valid Vietnamese placeholder: +849XXXXXXXX (10 digits after +84)
const random7 = Math.floor(1000000 + Math.random() * 9000000).toString();
const placeholder = `+8490${random7}`;
return Phone.create(placeholder).unwrap();
}
}

View File

@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, type Profile, type VerifyCallback } from 'passport-google-oauth20';
import { type OAuthService, type OAuthUserProfile } from '../services/oauth.service';
@Injectable()
export class GoogleOAuthStrategy extends PassportStrategy(Strategy, 'google') {
constructor(private readonly oauthService: OAuthService) {
const clientID = process.env['GOOGLE_CLIENT_ID'];
const clientSecret = process.env['GOOGLE_CLIENT_SECRET'];
const callbackURL = process.env['GOOGLE_CALLBACK_URL'] ?? '/auth/google/callback';
if (!clientID || !clientSecret) {
throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables are required');
}
super({
clientID,
clientSecret,
callbackURL,
scope: ['email', 'profile'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback,
): Promise<void> {
const email = profile.emails?.[0]?.value;
const photo = profile.photos?.[0]?.value;
const fullName = profile.displayName || [profile.name?.givenName, profile.name?.familyName].filter(Boolean).join(' ') || 'Google User';
const oauthProfile: OAuthUserProfile = {
provider: 'GOOGLE',
providerUserId: profile.id,
email,
fullName,
avatarUrl: photo,
accessToken,
refreshToken,
rawProfile: profile._json as Record<string, unknown>,
};
try {
const tokens = await this.oauthService.authenticateOAuth(oauthProfile);
done(null, tokens);
} catch (err) {
done(err as Error, undefined);
}
}
}

View File

@@ -1,2 +1,4 @@
export { GoogleOAuthStrategy } from './google-oauth.strategy';
export { JwtStrategy } from './jwt.strategy'; export { JwtStrategy } from './jwt.strategy';
export { LocalStrategy } from './local.strategy'; export { LocalStrategy } from './local.strategy';
export { ZaloOAuthStrategy } from './zalo-oauth.strategy';

View File

@@ -0,0 +1,155 @@
import { Injectable, Logger } from '@nestjs/common';
import { type OAuthService, type OAuthUserProfile } from '../services/oauth.service';
/**
* Zalo OAuth2 integration.
*
* Zalo does not have a passport strategy npm package, so this service
* handles the OAuth2 flow directly:
* 1. Generate authorization URL → redirect user
* 2. Exchange authorization code for access token
* 3. Fetch user profile from Zalo Graph API
* 4. Delegate to OAuthService for account linking/creation
*
* Zalo OAuth2 endpoints:
* - Authorization: https://oauth.zaloapp.com/v4/permission
* - Token: https://oauth.zaloapp.com/v4/access_token
* - User Info: https://graph.zalo.me/v2.0/me
*/
interface ZaloTokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
error?: number;
error_description?: string;
}
interface ZaloUserInfo {
id: string;
name: string;
picture?: { data?: { url?: string } };
error?: number;
message?: string;
}
@Injectable()
export class ZaloOAuthStrategy {
private readonly logger = new Logger(ZaloOAuthStrategy.name);
private readonly appId: string;
private readonly appSecret: string;
private readonly callbackUrl: string;
constructor(private readonly oauthService: OAuthService) {
const appId = process.env['ZALO_APP_ID'];
const appSecret = process.env['ZALO_APP_SECRET'];
if (!appId || !appSecret) {
throw new Error('ZALO_APP_ID and ZALO_APP_SECRET environment variables are required');
}
this.appId = appId;
this.appSecret = appSecret;
this.callbackUrl = process.env['ZALO_CALLBACK_URL'] ?? '/auth/zalo/callback';
}
/**
* Generate Zalo authorization URL for redirecting the user.
*/
getAuthorizationUrl(state?: string): string {
const params = new URLSearchParams({
app_id: this.appId,
redirect_uri: this.callbackUrl,
state: state ?? '',
});
return `https://oauth.zaloapp.com/v4/permission?${params.toString()}`;
}
/**
* Handle the OAuth callback: exchange code for tokens, fetch profile,
* and authenticate via OAuthService.
*/
async handleCallback(code: string, codeVerifier?: string): Promise<{ tokens: import('../services/token.service').TokenPair }> {
// 1. Exchange authorization code for access token
const tokenData = await this.exchangeCode(code, codeVerifier);
// 2. Fetch user profile
const userInfo = await this.fetchUserInfo(tokenData.access_token);
// 3. Build OAuthUserProfile and authenticate
const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + tokenData.expires_in);
const oauthProfile: OAuthUserProfile = {
provider: 'ZALO',
providerUserId: userInfo.id,
fullName: userInfo.name || 'Zalo User',
avatarUrl: userInfo.picture?.data?.url,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt,
rawProfile: userInfo as unknown as Record<string, unknown>,
};
const tokens = await this.oauthService.authenticateOAuth(oauthProfile);
return { tokens };
}
private async exchangeCode(code: string, codeVerifier?: string): Promise<ZaloTokenResponse> {
const body = new URLSearchParams({
code,
app_id: this.appId,
grant_type: 'authorization_code',
});
if (codeVerifier) {
body.set('code_verifier', codeVerifier);
}
const response = await fetch('https://oauth.zaloapp.com/v4/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'secret_key': this.appSecret,
},
body: body.toString(),
});
const data = (await response.json()) as ZaloTokenResponse;
if (data.error) {
this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`);
throw new Error(`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`);
}
if (!data.access_token) {
throw new Error('Zalo OAuth error: No access token in response');
}
return data;
}
private async fetchUserInfo(accessToken: string): Promise<ZaloUserInfo> {
const response = await fetch(
'https://graph.zalo.me/v2.0/me?fields=id,name,picture',
{
headers: {
'access_token': accessToken,
},
},
);
const data = (await response.json()) as ZaloUserInfo;
if (data.error) {
this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`);
throw new Error(`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`);
}
if (!data.id) {
throw new Error('Zalo OAuth error: No user ID in response');
}
return data;
}
}

View File

@@ -0,0 +1,112 @@
import {
Controller,
Get,
Query,
Req,
Res,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { type TokenPair } from '../../infrastructure/services/token.service';
import { ZaloOAuthStrategy } from '../../infrastructure/strategies/zalo-oauth.strategy';
import { GoogleOAuthGuard } from '../guards/google-oauth.guard';
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000;
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
const FRONTEND_URL = process.env['FRONTEND_URL'] ?? 'http://localhost:3000';
function setAuthCookies(res: Response, tokens: TokenPair): void {
res.cookie('access_token', tokens.accessToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'strict',
path: '/',
maxAge: ACCESS_TOKEN_MAX_AGE,
});
res.cookie('refresh_token', tokens.refreshToken, {
httpOnly: true,
secure: IS_PRODUCTION,
sameSite: 'strict',
path: '/auth',
maxAge: REFRESH_TOKEN_MAX_AGE,
});
res.cookie('goodgo_authenticated', '1', {
httpOnly: false,
secure: IS_PRODUCTION,
sameSite: 'lax',
path: '/',
maxAge: AUTH_COOKIE_MAX_AGE,
});
}
@ApiTags('auth')
@Controller('auth')
export class OAuthController {
constructor(private readonly zaloStrategy: ZaloOAuthStrategy) {}
// ─── Google OAuth ──────────────────────────────────────────────────
@Get('google')
@UseGuards(GoogleOAuthGuard)
@ApiOperation({ summary: 'Initiate Google OAuth2 login' })
@ApiResponse({ status: 302, description: 'Redirect to Google consent screen' })
googleLogin(): void {
// Guard handles redirect to Google
}
@Throttle({ default: { ttl: 3_600_000, limit: 10 } })
@Get('google/callback')
@UseGuards(GoogleOAuthGuard)
@ApiOperation({ summary: 'Google OAuth2 callback' })
@ApiResponse({ status: 302, description: 'Redirect to frontend with auth cookies' })
async googleCallback(
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const tokens = req.user as TokenPair | undefined;
if (!tokens?.accessToken) {
throw new UnauthorizedException('Google authentication failed');
}
setAuthCookies(res, tokens);
res.redirect(`${FRONTEND_URL}/auth/callback?provider=google`);
}
// ─── Zalo OAuth ────────────────────────────────────────────────────
@Get('zalo')
@ApiOperation({ summary: 'Initiate Zalo OAuth2 login' })
@ApiResponse({ status: 302, description: 'Redirect to Zalo consent screen' })
zaloLogin(@Res() res: Response): void {
const authUrl = this.zaloStrategy.getAuthorizationUrl();
res.redirect(authUrl);
}
@Throttle({ default: { ttl: 3_600_000, limit: 10 } })
@Get('zalo/callback')
@ApiOperation({ summary: 'Zalo OAuth2 callback' })
@ApiResponse({ status: 302, description: 'Redirect to frontend with auth cookies' })
async zaloCallback(
@Query('code') code: string,
@Query('code_verifier') codeVerifier: string | undefined,
@Res() res: Response,
): Promise<void> {
if (!code) {
throw new UnauthorizedException('Zalo authorization code missing');
}
try {
const result = await this.zaloStrategy.handleCallback(code, codeVerifier);
setAuthCookies(res, result.tokens);
res.redirect(`${FRONTEND_URL}/auth/callback?provider=zalo`);
} catch {
res.redirect(`${FRONTEND_URL}/auth/callback?error=zalo_auth_failed`);
}
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GoogleOAuthGuard extends AuthGuard('google') {}

View File

@@ -22,6 +22,10 @@ const REQUIRED_WHEN_USED: ReadonlyMap<string, string> = new Map([
['ZALOPAY_KEY2', 'ZaloPay payments'], ['ZALOPAY_KEY2', 'ZaloPay payments'],
['MINIO_ACCESS_KEY', 'Media storage'], ['MINIO_ACCESS_KEY', 'Media storage'],
['MINIO_SECRET_KEY', 'Media storage'], ['MINIO_SECRET_KEY', 'Media storage'],
['GOOGLE_CLIENT_ID', 'Google OAuth'],
['GOOGLE_CLIENT_SECRET', 'Google OAuth'],
['ZALO_APP_ID', 'Zalo OAuth'],
['ZALO_APP_SECRET', 'Zalo OAuth'],
]); ]);
export function validateEnv(): void { export function validateEnv(): void {

67
pnpm-lock.yaml generated
View File

@@ -150,6 +150,9 @@ importers:
passport: passport:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
passport-google-oauth20:
specifier: ^2.0.0
version: 2.0.0
passport-jwt: passport-jwt:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -208,6 +211,9 @@ importers:
'@types/nodemailer': '@types/nodemailer':
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0 version: 8.0.0
'@types/passport-google-oauth20':
specifier: ^2.0.17
version: 2.0.17
'@types/passport-jwt': '@types/passport-jwt':
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -2440,12 +2446,21 @@ packages:
'@types/nodemailer@8.0.0': '@types/nodemailer@8.0.0':
resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==}
'@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': '@types/passport-jwt@4.0.1':
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
'@types/passport-local@1.0.38': '@types/passport-local@1.0.38':
resolution: {integrity: sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==} resolution: {integrity: sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==}
'@types/passport-oauth2@1.8.0':
resolution: {integrity: sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==}
'@types/passport-strategy@0.2.38': '@types/passport-strategy@0.2.38':
resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==}
@@ -2926,6 +2941,10 @@ packages:
base64-js@1.5.1: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} 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.16: baseline-browser-mapping@2.10.16:
resolution: {integrity: sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==} resolution: {integrity: sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -4529,6 +4548,9 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
oauth@0.10.2:
resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -4597,6 +4619,10 @@ packages:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} 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: passport-jwt@4.0.1:
resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==}
@@ -4604,6 +4630,10 @@ packages:
resolution: {integrity: sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==} resolution: {integrity: sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
passport-oauth2@1.8.0:
resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==}
engines: {node: '>= 0.4.0'}
passport-strategy@1.0.0: passport-strategy@1.0.0:
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
@@ -5520,6 +5550,9 @@ packages:
engines: {node: '>=0.8.0'} engines: {node: '>=0.8.0'}
hasBin: true hasBin: true
uid2@0.0.4:
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
uid@2.0.2: uid@2.0.2:
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -8441,6 +8474,16 @@ snapshots:
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.5.2
'@types/oauth@0.9.6':
dependencies:
'@types/node': 25.5.2
'@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': '@types/passport-jwt@4.0.1':
dependencies: dependencies:
'@types/jsonwebtoken': 9.0.10 '@types/jsonwebtoken': 9.0.10
@@ -8452,6 +8495,12 @@ snapshots:
'@types/passport': 1.0.17 '@types/passport': 1.0.17
'@types/passport-strategy': 0.2.38 '@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': '@types/passport-strategy@0.2.38':
dependencies: dependencies:
'@types/express': 5.0.6 '@types/express': 5.0.6
@@ -8965,6 +9014,8 @@ snapshots:
base64-js@1.5.1: {} base64-js@1.5.1: {}
base64url@3.0.1: {}
baseline-browser-mapping@2.10.16: {} baseline-browser-mapping@2.10.16: {}
bcrypt@6.0.0: bcrypt@6.0.0:
@@ -10654,6 +10705,8 @@ snapshots:
pathe: 2.0.3 pathe: 2.0.3
tinyexec: 1.1.1 tinyexec: 1.1.1
oauth@0.10.2: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-hash@3.0.0: {} object-hash@3.0.0: {}
@@ -10726,6 +10779,10 @@ snapshots:
parseurl@1.3.3: {} parseurl@1.3.3: {}
passport-google-oauth20@2.0.0:
dependencies:
passport-oauth2: 1.8.0
passport-jwt@4.0.1: passport-jwt@4.0.1:
dependencies: dependencies:
jsonwebtoken: 9.0.3 jsonwebtoken: 9.0.3
@@ -10735,6 +10792,14 @@ snapshots:
dependencies: dependencies:
passport-strategy: 1.0.0 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-strategy@1.0.0: {}
passport@0.7.0: passport@0.7.0:
@@ -11719,6 +11784,8 @@ snapshots:
uglify-js@3.19.3: uglify-js@3.19.3:
optional: true optional: true
uid2@0.0.4: {}
uid@2.0.2: uid@2.0.2:
dependencies: dependencies:
'@lukeed/csprng': 1.1.0 '@lukeed/csprng': 1.1.0