Dev.to · 9 min read

Build a Password + Google OAuth MVP with NestJS and brkpt-auth (in 15 Minutes)

Build a Password + Google OAuth MVP with NestJS and brkpt-auth (in 15 Minutes)

The last post in this series looked at why hand-rolled NestJS auth tends to accumulate glue code as real requirements pile up, and introduced brkpt-auth: full source code installed into your project, structured around interfaces (ports) you implement (adapters), so your database and token logic stay separate from the auth logic itself. This post is the walkthrough: the same password + Google sign-in flow from the last post, built with brkpt-auth from scratch. Roughly, the shape of it is: install brkpt-auth's core feature, implement an adapter that connects it to Prisma, then add credentials and oauth the same way. This post was written against NestJS v11 (a default CommonJS project from nest new) and Prisma 7.10.0. If your versions differ, some commands or config may not match, especially Prisma's CLI, which has changed flags across major versions. Check the Prisma releases page if something here doesn't work as shown. Validation here uses class-validator, the same library NestJS's own docs default to, and what most NestJS tutorials still use. Worth noting up front: brkpt-auth doesn't require it. It doesn't ship with any validation library baked in, so class-validator, Zod, or nothing at all are all equally valid choices. class-validator is used here because it needs zero extra wiring with @Body(); Zod works too, but needs a small custom pipe to hook into NestJS's request pipeline, which would add a step this post doesn't need. Setup nest new my-app cd my-app npm install -g @brkpt/cli npm install @nestjs/config @nestjs/event-emitter cookie-parser npm install -D @types/cookie-parser brkpt auth init brkpt auth init installs the core feature and prints a checklist of anything else missing. Read it before moving on, it'll catch dependencies this post might not call out explicitly. Prisma npm install -D prisma@7.10.0 @types/pg npm install @prisma/client@7.10.0 @prisma/adapter-pg pg npx prisma@7.10.0 init --output ../generated/prisma This needs a PostgreSQL database. Add the connection string to .env: # .env DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/DB_NAME?schema=public" Define the user model: // prisma/schema.prisma generator client { provider = "prisma-client" output = "../generated/prisma" moduleFormat = "cjs" } datasource db { provider = "postgresql" } model User { id Int @id @default(autoincrement()) name String email String @unique password String } moduleFormat = "cjs" keeps the generated client compatible with a default NestJS CommonJS setup. npx prisma@7.10.0 migrate dev --name init npx prisma@7.10.0 generate // src/prisma/prisma.service.ts import { Injectable } from '@nestjs/common'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '../../generated/prisma/client'; @Injectable() export class PrismaService extends PrismaClient { constructor() { const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL as string, }); super({ adapter }); } } // src/prisma/prisma.module.ts import { Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; @Module({ providers: [PrismaService], exports: [PrismaService], }) export class PrismaModule {} core: the only place your database and brkpt-auth meet core depends on CorePort, an interface. You implement it once, and it never needs to change unless your schema does: // src/brkpt-auth/adapters/types.ts export type AuthJwtPayload = { sub: number; email: string }; // src/brkpt-auth/adapters/core.adapter.ts import { Injectable } from '@nestjs/common'; import { User } from '../../../generated/prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { CorePort } from '../features/core/core.port'; import { AuthJwtPayload } from './types'; @Injectable() export class CoreAdapter implements CorePort { constructor(private readonly prisma: PrismaService) {} mapUserToJwtPayload(user: User): AuthJwtPayload { return { sub: user.id, email: user.email }; } shrinkJwtPayload(payload: AuthJwtPayload) { return { sub: payload.sub }; } findUserByJwtPayload(payload: AuthJwtPayload) { return this.prisma.user.findUnique({ where: { id: payload.sub } }); } toSafeUser(user: User) { const { password: _password, ...safe } = user; return safe; } extractUserIdFromJwtPayload(payload: AuthJwtPayload) { return payload.sub; } } Every method is a one-line mapping or a direct Prisma call. Register it, then wire PrismaModule, EventEmitterModule, and BrkptAuthModule into AppModule: // src/brkpt-auth/features.ts import { CoreAdapter } from './adapters/core.adapter'; import { FeatureConfig } from './common/interfaces'; import { coreFeature } from './features/core/core.feature'; export const features: FeatureConfig[] = [coreFeature(CoreAdapter)]; // src/brkpt-auth/brkpt-auth.module.ts import { Module } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module'; import { features } from './features'; @Module({ imports: [PrismaModule], controllers: [...features.flatMap((f) => f.controllers)], providers: [...features.flatMap((f) => f.providers)], }) export class BrkptAuthModule { // forRoot / forRootAsync are generated by the CLI and configure JwtModule; see below for the options this post needs. } // src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { BrkptAuthModule } from './brkpt-auth/brkpt-auth.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), EventEmitterModule.forRoot({ global: true, wildcard: true }), BrkptAuthModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ jwt: { access: { secret: config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '5m', }, refresh: { secret: config.getOrThrow('JWT_REFRESH_SECRET'), expiresIn: '1h', transport: 'cookie', }, }, }), }), ], }) export class AppModule {} # .env JWT_ACCESS_SECRET="dev-secret-change-me" JWT_REFRESH_SECRET="dev-secret-change-me-too" transport: 'cookie' means refresh tokens are read from a cookie, which is why cookie-parser needs to be enabled in main.ts: // src/main.ts import cookieParser from 'cookie-parser'; // ... app.use(cookieParser()); npm run start:dev GET /auth/me should return 401 here, expected: there's no sign-up or sign-in yet. Password sign-in brkpt auth add credentials npm install class-validator class-transformer npm install bcrypt && npm install -D @types/bcrypt credentials ships with empty DTOs, brkpt-auth doesn't assume which fields a sign-up form uses: // src/brkpt-auth/features/credentials/dto/sign-up.dto.ts import { IsEmail, IsString, MinLength } from 'class-validator'; export class SignUpDto { @IsString() name!: string; @IsEmail() email!: string; @IsString() @MinLength(6) password!: string; } // src/brkpt-auth/features/credentials/dto/sign-in.dto.ts import { IsEmail, IsString } from 'class-validator'; export class SignInDto { @IsEmail() email!: string; @IsString() password!: string; } Enable the global pipe once, in main.ts: // src/main.ts app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true, }), ); Implement CredentialsAdapter: // src/brkpt-auth/adapters/credentials.adapter.ts import { Injectable } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { User } from '../../../generated/prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { CredentialsPort } from '../features/credentials/credentials.port'; import { SignInDto } from '../features/credentials/dto/sign-in.dto'; import { SignUpDto } from '../features/credentials/dto/sign-up.dto'; @Injectable() export class CredentialsAdapter implements CredentialsPort { constructor(private readonly prisma: PrismaService) {} findUserByDto(dto: SignInDto | SignUpDto) { return this.prisma.user.findUnique({ where: { email: dto.email } }); } validatePassword(user: User, dto: SignInDto) { return bcrypt.compare(dto.password, user.password); } async createUser(dto: SignUpDto) { const password = await bcrypt.hash(dto.password, 10); return this.prisma.user.create({ data: { name: dto.name, email: dto.email, password }, }); } extractUserIdFromUser(user: User) { return user.id; } } // src/brkpt-auth/features.ts import { CoreAdapter } from './adapters/core.adapter'; import { CredentialsAdapter } from './adapters/credentials.adapter'; import { FeatureConfig } from './common/interfaces'; import { coreFeature } from './features/core/core.feature'; import { credentialsFeature } from './features/credentials/credentials.feature'; export const features: FeatureConfig[] = [ coreFeature(CoreAdapter), credentialsFeature(CredentialsAdapter), ]; curl -X POST localhost:3000/auth/sign-up \ -H "Content-Type: application/json" \ -d '{"name":"Kevin","email":"kevin@example.com","password":"password123"}' This returns an accessToken and sets the refresh token as an HttpOnly cookie, httpOnly, secure, and sameSite configured by default. Google sign-in brkpt auth add oauth --driver google # .env, add: GOOGLE_CLIENT_ID="your-google-client-id" GOOGLE_CLIENT_SECRET="your-google-client-secret" // src/app.module.ts, inside BrkptAuthModule.forRootAsync's useFactory, add: oauth: { google: { clientId: config.getOrThrow('GOOGLE_CLIENT_ID'), clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'), }, }, The oauth feature's DTO just needs the field its controller reads: // src/brkpt-auth/features/oauth/dto/oauth.dto.ts import { IsString } from 'class-validator'; export class OAuthDto { @IsString() idToken!: string; } The adapter is the one place a Google profile turns into a row in your User table: // src/brkpt-auth/adapters/types.ts, add: export interface UserProfile { name: string; email: string; } // src/brkpt-auth/adapters/oauth.adapter.ts import { BadRequestException, Injectable } from '@nestjs/common'; import { TokenPayload } from 'google-auth-library'; import { User } from '../../../generated/prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { OAuthPort } from '../features/oauth/oauth.port'; import { UserProfile } from './types'; interface GoogleUser extends TokenPayload { name: string; email: string; } @Injectable() export class OAuthAdapter implements OAuthPort { constructor(private readonly prisma: PrismaService) {} mapRawToProfile(provider: string, raw: unknown): UserProfile | undefined { switch (provider) { case 'google': { const r = raw as GoogleUser; if (!r.email) { throw new BadRequestException( 'Google profile does not include an email address', ); } return { name: r.name, email: r.email }; } } } async findOrCreateUserByProfile(profile: UserProfile) { const existing = await this.prisma.user.findUnique({ where: { email: profile.email }, }); if (existing) return { user: existing, created: false }; const user = await this.prisma.user.create({ data: { name: profile.name, email: profile.email, password: '' }, }); return { user, created: true }; } extractUserIdFromUser(user: User) { return user.id; } } // src/brkpt-auth/features.ts import { CoreAdapter } from './adapters/core.adapter'; import { CredentialsAdapter } from './adapters/credentials.adapter'; import { OAuthAdapter } from './adapters/oauth.adapter'; import { FeatureConfig } from './common/interfaces'; import { coreFeature } from './features/core/core.feature'; import { credentialsFeature } from './features/credentials/credentials.feature'; import { GoogleOAuthDriver } from './features/oauth/drivers/google.driver'; import { oauthFeature } from './features/oauth/oauth.feature'; export const features: FeatureConfig[] = [ coreFeature(CoreAdapter), credentialsFeature(CredentialsAdapter), oauthFeature(OAuthAdapter, GoogleOAuthDriver), ]; Run it npm run start:dev Method Path Description POST /auth/sign-up Create a user POST /auth/sign-in Sign in POST /auth/oauth/google Sign in with Google GET /auth/me Return the current user POST /auth/refresh Issue a new access token POST /auth/sign-out Sign out curl -X POST localhost:3000/auth/oauth/google \ -H "Content-Type: application/json" \ -d '{"idToken":""}' No frontend yet? Google's OAuth Playground issues a real idToken you can paste straight in; brkpt-auth's recipe for this walks through getting one in about five minutes. What just happened Two sign-in methods, one user model, one token flow, zero hand-written glue between them. CoreAdapter and CredentialsAdapter are Prisma calls and one-line mappings. OAuthAdapter is the only place Google's response shape ever touches the database. None of this required learning what hexagonal architecture is first, the interfaces just happened to only ask for what a working sign-up and sign-in flow needs. For adding more sign-in methods or features the same way, the docs cover that ground. This series won't repeat it. The next post takes this exact project and puts a real claim to the test: how easy it actually is to change something that usually isn't, and what that costs in practice.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News