DEV Community

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

Posted on AI-assisted

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This needs a PostgreSQL database. Add the connection string to .env:

# .env
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/DB_NAME?schema=public"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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 });
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/prisma/prisma.module.ts
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode
// 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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)];
Enter fullscreen mode Exit fullscreen mode
// 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.
}
Enter fullscreen mode Exit fullscreen mode
// 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 {}
Enter fullscreen mode Exit fullscreen mode
# .env
JWT_ACCESS_SECRET="dev-secret-change-me"
JWT_REFRESH_SECRET="dev-secret-change-me-too"
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode
npm run start:dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode

Enable the global pipe once, in main.ts:

// src/main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    transform: true,
  }),
);
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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),
];
Enter fullscreen mode Exit fullscreen mode
curl -X POST localhost:3000/auth/sign-up \
  -H "Content-Type: application/json" \
  -d '{"name":"Kevin","email":"kevin@example.com","password":"password123"}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# .env, add:
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
Enter fullscreen mode Exit fullscreen mode
// src/app.module.ts, inside BrkptAuthModule.forRootAsync's useFactory, add:
oauth: {
  google: {
    clientId: config.getOrThrow('GOOGLE_CLIENT_ID'),
    clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'),
  },
},
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
// 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;
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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),
];
Enter fullscreen mode Exit fullscreen mode

Run it

npm run start:dev
Enter fullscreen mode Exit fullscreen mode
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>"}'
Enter fullscreen mode Exit fullscreen mode

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)