Пошук уроків, статей та іншого контенту
Підключите OAuth-автентифікацію через зовнішнього провайдера та обробите callback і профіль користувача.
OAuth дає змогу автентифікувати користувача через зовнішнього провайдера, наприклад Google. Застосунок не отримує пароль користувача, а отримує:
ідентифікатор користувача у провайдера;
ім’я та електронну пошту;
аватар;
OAuth-токени для взаємодії з API провайдера, якщо це дозволено scope.
У цьому уроці реалізуємо такий потік:
Користувач переходить на GET /auth/google.
NestJS перенаправляє його на Google.
Google повертає користувача на GET /auth/google/callback.
Passport обмінює authorization code на токени.
Стратегія отримує профіль користувача.
Застосунок знаходить або створює локальний обліковий запис.
Застосунок видає власний JWT для подальшої авторизації API.
OAuth-токен Google і токен вашого застосунку — це різні токени. Для доступу до власного API клієнт має використовувати токен вашого застосунку.
У консолі OAuth-провайдера потрібно створити застосунок і вказати callback URL.
Для локального середовища callback URL може мати такий вигляд:
http://localhost:3000/auth/google/callbackДля production потрібно використовувати HTTPS, наприклад:
https://api.example.com/auth/google/callbackCallback URL у коді та в налаштуваннях провайдера мають збігатися повністю. Навіть відмінність у протоколі, порту або кінцевому слеші може спричинити помилку.
Для Google знадобляться:
Client ID;
Client Secret;
дозволений callback URL;
scope profile та email.
Для інтеграції використаємо Passport і Google OAuth 2.0 strategy:
npm install @nestjs/passport passport passport-google-oauth20 @nestjs/jwt
npm install @nestjs/config
npm install -D @types/passport-google-oauth20@nestjs/passport інтегрує Passport із guard-механізмом NestJS, а passport-google-oauth20 реалізує OAuth-потік для Google.
Створіть файл .env:
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback
JWT_SECRET=replace-this-with-a-long-random-secretСекрети не потрібно зберігати в репозиторії. Файл .env має бути доданий до .gitignore.
Створимо AuthModule, який містить:
Google strategy;
OAuth-контролер;
сервіс пошуку або створення користувача;
JwtModule для видачі локального токена.
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
@Module({
imports: [
ConfigModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
expiresIn: '15m',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, GoogleStrategy],
exports: [AuthService],
})
export class AuthModule {}У головному модулі потрібно зробити конфігурацію глобальною та підключити AuthModule:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
AuthModule,
],
})
export class AppModule {}Strategy описує, як Passport має взаємодіяти з OAuth-провайдером.
// src/auth/google.strategy.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy } from 'passport-google-oauth20';
import { AuthService } from './auth.service';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
clientID: configService.getOrThrow<string>('GOOGLE_CLIENT_ID'),
clientSecret: configService.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
callbackURL: configService.getOrThrow<string>('GOOGLE_CALLBACK_URL'),
scope: ['profile', 'email'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
) {
// Токени можна зберігати лише тоді, коли вони справді потрібні застосунку.
return this.authService.findOrCreateOAuthUser({
provider: 'google',
providerUserId: profile.id,
email: profile.emails?.[0]?.value,
name: profile.displayName,
avatarUrl: profile.photos?.[0]?.value,
accessToken,
refreshToken,
});
}
}Метод validate() викликається після успішного OAuth-обміну.
Його результат Passport передає в req.user. У NestJS це означає, що метод контролера після AuthGuard отримає вже оброблений локальний профіль користувача, а не необроблену відповідь Google.
OAuth-провайдер повертає стабільний ідентифікатор користувача — profile.id. Email може змінитися, бути відсутнім або бути непідтвердженим залежно від провайдера.
Для зв’язку локального облікового запису з Google потрібно використовувати пару:
provider + providerUserIdНаприклад:
google + 103948572039485720394Email можна використовувати як атрибут користувача або для пошуку під час контрольованого linking-процесу, але не як єдиний ідентифікатор OAuth-акаунта.
Для прикладу використаємо сховище в пам’яті. У реальному застосунку замість Map має бути репозиторій бази даних із унікальним індексом на provider і providerUserId.
// src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
export interface OAuthUserInput {
provider: string;
providerUserId: string;
email?: string;
name?: string;
avatarUrl?: string;
accessToken?: string;
refreshToken?: string;
}
export interface AuthUser {
id: string;
provider: string;
providerUserId: string;
email: string | null;
name: string;
avatarUrl: string | null;
}
@Injectable()
export class AuthService {
private readonly users = new Map<string, AuthUser>();
constructor(private readonly jwtService: JwtService) {}
async findOrCreateOAuthUser(
input: OAuthUserInput,
): Promise<AuthUser> {
const userKey = `${input.provider}:${input.providerUserId}`;
const existingUser = this.users.get(userKey);
if (existingUser) {
return existingUser;
}
const user: AuthUser = {
id: crypto.randomUUID(),
provider: input.provider,
providerUserId: input.providerUserId,
email: input.email ?? null,
name: input.name ?? 'OAuth user',
avatarUrl: input.avatarUrl ?? null,
};
this.users.set(userKey, user);
return user;
}
async createAccessToken(user: AuthUser): Promise<string> {
return this.jwtService.signAsync({
sub: user.id,
email: user.email,
});
}
}У Node.js crypto.randomUUID() доступний без додаткової залежності. Якщо застосунок запускається в середовищі, де глобальний crypto недоступний, його можна імпортувати:
import { randomUUID } from 'node:crypto';і використовувати randomUUID().
У production не слід зберігати access token або refresh token у відкритому вигляді. Якщо застосунку потрібно викликати API Google від імені користувача:
зберігайте токени зашифрованими;
обмежуйте scope;
зберігайте refresh token лише за необхідності;
продумайте відкликання та оновлення токенів.
Створимо окремий guard, який посилається на strategy з іменем google:
// src/auth/google-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {}Використання окремого класу замість AuthGuard('google') безпосередньо в контролері спрощує розширення логіки надалі.
Контролер має два основні маршрути:
/auth/google — початок авторизації;
/auth/google/callback — callback після відповіді Google.
// src/auth/auth.controller.ts
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { AuthService, AuthUser } from './auth.service';
import { GoogleAuthGuard } from './google-auth.guard';
type OAuthRequest = Request & {
user: AuthUser;
};
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Get('google')
@UseGuards(GoogleAuthGuard)
googleLogin(): void {
// Guard перенаправляє користувача на сторінку Google.
}
@Get('google/callback')
@UseGuards(GoogleAuthGuard)
async googleCallback(@Req() request: OAuthRequest) {
const user = request.user;
const accessToken = await this.authService.createAccessToken(user);
return {
accessToken,
user: {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl,
},
};
}
}Під час запиту до /auth/google тіло методу googleLogin() не виконується у звичному сенсі. Спочатку спрацьовує guard, який формує redirect на Google.
Після повернення на /auth/google/callback guard:
читає code та інші параметри callback;
виконує обмін code на OAuth-токени;
викликає GoogleStrategy.validate();
записує результат у request.user;
передає керування методу googleCallback().
Запустіть NestJS:
npm run start:devВідкрийте в браузері:
http://localhost:3000/auth/googleПісля автентифікації Google перенаправить браузер на callback. У відповіді застосунок поверне приблизно такий JSON:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "c0f8c642-3c33-4b56-a95e-4a7802f8189e",
"email": "user@example.com",
"name": "User Name",
"avatarUrl": "https://lh3.googleusercontent.com/..."
}
}Тестовий приклад є runnable після додавання файлів до стандартного NestJS-проєкту та заповнення .env.
У production не варто просто повертати access token у JSON браузерного callback-маршруту. Безпечніші варіанти:
встановити токен у захищену cookie з прапорцями HttpOnly, Secure і відповідним SameSite;
перенаправити користувача на frontend із короткоживучим одноразовим кодом;
обміняти одноразовий код на токен через backend-to-backend запит.
Не передавайте довгоживучі токени в query string, тому що вони можуть потрапити в історію браузера, access logs або заголовок Referer.
Користувач може:
скасувати дозвіл;
не надати потрібний scope;
повернутися з некоректним code;
звернутися до callback без попереднього OAuth-запиту;
використати прострочений authorization code.
У такому випадку Passport зазвичай передає помилку до NestJS exception layer. Для production потрібно додати власну стратегію обробки:
не показувати користувачеві stack trace;
перенаправляти на frontend із безпечним кодом помилки;
логувати технічну причину на backend;
не записувати в логи authorization code і токени.
OAuth callback потрібно захищати від підміни запиту. Для цього використовують параметр state.
state пов’язує початок OAuth-потоку з конкретним callback. Backend генерує непередбачуване значення, зберігає його в сесії або тимчасовому сховищі, а після callback перевіряє, що значення збігається.
Для stateful-архітектури це може виглядати так:
backend створює state;
зберігає його в серверній сесії;
передає користувача до провайдера;
отримує state у callback;
порівнює його з тим, що збережено в сесії;
продовжує автентифікацію лише після успішної перевірки.
Якщо застосунок не використовує сесії, state можна зберігати в зовнішньому сховищі з коротким TTL, наприклад у Redis. Значення має бути одноразовим і видалятися після перевірки.
Не слід використовувати email, user ID або інше передбачуване значення як state.
Профіль Google — це зовнішня відповідь провайдера. Локальний користувач — це сутність вашої системи.
Зазвичай ці дані зберігають окремо:
users
- id
- email
- name
- avatar_url
oauth_accounts
- id
- user_id
- provider
- provider_user_id
- encrypted_access_token
- encrypted_refresh_tokenТака структура дає змогу:
підключити кілька провайдерів до одного користувача;
не дублювати локальний профіль;
змінювати email у провайдера без створення нового користувача;
від’єднувати OAuth-акаунт;
підтримувати локальну автентифікацію окремо від OAuth.
Під час першого входу потрібно створити і users, і oauth_accounts. Під час наступних входів достатньо знайти oauth_accounts за парою provider та providerUserId.
Перевірте всі частини URL:
http або https;
домен;
порт;
шлях;
кінцевий слеш.
Значення в .env має збігатися з URL, зареєстрованим у Google.
GoogleStrategy має бути у списку providers того модуля, де використовується guard:
@Module({
providers: [AuthService, GoogleStrategy],
})
export class AuthModule {}Якщо strategy оголошена так:
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {}guard також має використовувати саме 'google':
AuthGuard('google')Email не повинен бути єдиним ключем для OAuth-акаунта. Використовуйте provider і стабільний providerUserId.
Не виводьте в лог:
accessToken;
refreshToken;
authorization code;
повний callback URL із чутливими параметрами.
email у профіліOAuth-провайдер не завжди повертає email. Код має коректно обробляти відсутність profile.emails і, якщо email обов’язковий для вашої системи, явно відхиляти такий профіль.
Не підписуйте JWT безпосередньо з даних, отриманих від Google. Спочатку знайдіть або створіть локального користувача, а вже потім сформуйте токен із локальним user.id.
Не використовуйте конструкцію на кшталт:
https://frontend.example.com/oauth/callback?token=...Токен у URL може витекти через історію браузера, серверні логи або сторонні системи аналітики.
OAuth у NestJS зручно підключати через @nestjs/passport і Passport strategy.
Guard запускає redirect до провайдера та обробляє callback.
Метод validate() отримує OAuth-профіль і має перетворити його на локального користувача.
Локальний акаунт потрібно зв’язувати з провайдером за парою provider і providerUserId.
Після успішного OAuth-входу застосунок має створити власну сесію або видати власний access token.
OAuth-токени провайдера та JWT застосунку мають різне призначення.
Callback URL потрібно точно зареєструвати у провайдера.
Для production необхідно захищати OAuth-потік через state, безпечне зберігання токенів і коректну обробку помилок.