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:
13
.env.example
13
.env.example
@@ -60,6 +60,19 @@ JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_SECRET=CHANGE_ME
|
||||
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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"ioredis": "^5.4.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.20.0",
|
||||
@@ -61,6 +62,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/pg": "^8.20.0",
|
||||
|
||||
@@ -12,10 +12,14 @@ import { REFRESH_TOKEN_REPOSITORY } from './domain/repositories/refresh-token.re
|
||||
import { USER_REPOSITORY } from './domain/repositories/user.repository';
|
||||
import { PrismaRefreshTokenRepository } from './infrastructure/repositories/prisma-refresh-token.repository';
|
||||
import { PrismaUserRepository } from './infrastructure/repositories/prisma-user.repository';
|
||||
import { OAuthService } from './infrastructure/services/oauth.service';
|
||||
import { TokenService } from './infrastructure/services/token.service';
|
||||
import { GoogleOAuthStrategy } from './infrastructure/strategies/google-oauth.strategy';
|
||||
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './infrastructure/strategies/local.strategy';
|
||||
import { ZaloOAuthStrategy } from './infrastructure/strategies/zalo-oauth.strategy';
|
||||
import { AuthController } from './presentation/controllers/auth.controller';
|
||||
import { OAuthController } from './presentation/controllers/oauth.controller';
|
||||
|
||||
const CommandHandlers = [
|
||||
RegisterUserHandler,
|
||||
@@ -41,7 +45,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
|
||||
signOptions: { expiresIn: '15m', audience: 'goodgo-api', issuer: 'goodgo-platform' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
controllers: [AuthController, OAuthController],
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: USER_REPOSITORY, useClass: PrismaUserRepository },
|
||||
@@ -50,14 +54,17 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
|
||||
// Strategies
|
||||
JwtStrategy,
|
||||
LocalStrategy,
|
||||
GoogleOAuthStrategy,
|
||||
ZaloOAuthStrategy,
|
||||
|
||||
// Services
|
||||
TokenService,
|
||||
OAuthService,
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [TokenService, USER_REPOSITORY],
|
||||
exports: [TokenService, OAuthService, USER_REPOSITORY],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export { GoogleOAuthStrategy } from './google-oauth.strategy';
|
||||
export { JwtStrategy } from './jwt.strategy';
|
||||
export { LocalStrategy } from './local.strategy';
|
||||
export { ZaloOAuthStrategy } from './zalo-oauth.strategy';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleOAuthGuard extends AuthGuard('google') {}
|
||||
@@ -22,6 +22,10 @@ const REQUIRED_WHEN_USED: ReadonlyMap<string, string> = new Map([
|
||||
['ZALOPAY_KEY2', 'ZaloPay payments'],
|
||||
['MINIO_ACCESS_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 {
|
||||
|
||||
67
pnpm-lock.yaml
generated
67
pnpm-lock.yaml
generated
@@ -150,6 +150,9 @@ importers:
|
||||
passport:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
passport-google-oauth20:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
passport-jwt:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -208,6 +211,9 @@ importers:
|
||||
'@types/nodemailer':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
'@types/passport-google-oauth20':
|
||||
specifier: ^2.0.17
|
||||
version: 2.0.17
|
||||
'@types/passport-jwt':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -2440,12 +2446,21 @@ packages:
|
||||
'@types/nodemailer@8.0.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
|
||||
|
||||
'@types/passport-local@1.0.38':
|
||||
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':
|
||||
resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==}
|
||||
|
||||
@@ -2926,6 +2941,10 @@ packages:
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
base64url@3.0.1:
|
||||
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
baseline-browser-mapping@2.10.16:
|
||||
resolution: {integrity: sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -4529,6 +4548,9 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
oauth@0.10.2:
|
||||
resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4597,6 +4619,10 @@ packages:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
passport-google-oauth20@2.0.0:
|
||||
resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
passport-jwt@4.0.1:
|
||||
resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==}
|
||||
|
||||
@@ -4604,6 +4630,10 @@ packages:
|
||||
resolution: {integrity: sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==}
|
||||
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:
|
||||
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
@@ -5520,6 +5550,9 @@ packages:
|
||||
engines: {node: '>=0.8.0'}
|
||||
hasBin: true
|
||||
|
||||
uid2@0.0.4:
|
||||
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
|
||||
|
||||
uid@2.0.2:
|
||||
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -8441,6 +8474,16 @@ snapshots:
|
||||
dependencies:
|
||||
'@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':
|
||||
dependencies:
|
||||
'@types/jsonwebtoken': 9.0.10
|
||||
@@ -8452,6 +8495,12 @@ snapshots:
|
||||
'@types/passport': 1.0.17
|
||||
'@types/passport-strategy': 0.2.38
|
||||
|
||||
'@types/passport-oauth2@1.8.0':
|
||||
dependencies:
|
||||
'@types/express': 5.0.6
|
||||
'@types/oauth': 0.9.6
|
||||
'@types/passport': 1.0.17
|
||||
|
||||
'@types/passport-strategy@0.2.38':
|
||||
dependencies:
|
||||
'@types/express': 5.0.6
|
||||
@@ -8965,6 +9014,8 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
base64url@3.0.1: {}
|
||||
|
||||
baseline-browser-mapping@2.10.16: {}
|
||||
|
||||
bcrypt@6.0.0:
|
||||
@@ -10654,6 +10705,8 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
tinyexec: 1.1.1
|
||||
|
||||
oauth@0.10.2: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-hash@3.0.0: {}
|
||||
@@ -10726,6 +10779,10 @@ snapshots:
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
passport-google-oauth20@2.0.0:
|
||||
dependencies:
|
||||
passport-oauth2: 1.8.0
|
||||
|
||||
passport-jwt@4.0.1:
|
||||
dependencies:
|
||||
jsonwebtoken: 9.0.3
|
||||
@@ -10735,6 +10792,14 @@ snapshots:
|
||||
dependencies:
|
||||
passport-strategy: 1.0.0
|
||||
|
||||
passport-oauth2@1.8.0:
|
||||
dependencies:
|
||||
base64url: 3.0.1
|
||||
oauth: 0.10.2
|
||||
passport-strategy: 1.0.0
|
||||
uid2: 0.0.4
|
||||
utils-merge: 1.0.1
|
||||
|
||||
passport-strategy@1.0.0: {}
|
||||
|
||||
passport@0.7.0:
|
||||
@@ -11719,6 +11784,8 @@ snapshots:
|
||||
uglify-js@3.19.3:
|
||||
optional: true
|
||||
|
||||
uid2@0.0.4: {}
|
||||
|
||||
uid@2.0.2:
|
||||
dependencies:
|
||||
'@lukeed/csprng': 1.1.0
|
||||
|
||||
Reference in New Issue
Block a user