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<User> {
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<User> {
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<User, UserProfile> {
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":"<google-id-token>"}'
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.
Top comments (0)