Refactoring NestJS Auth Built with brkpt-auth: How Easy Is It, Really?

typescript dev.to

The last post built a password + Google sign-in MVP with brkpt-auth. This one makes two changes to that same project, the kind that come up naturally as a product grows, and checks what moved.

Adding a unique username, without giving up email sign-in

Say the product now needs a public, unique handle, something people can share and search for, that isn't tied to an email address they might change later. username is that field: unique, and usable for sign-in alongside email, which stays unique too and stays valid for signing in as well.

Worth being upfront here: credentials is meant to be a plain username-and-password flow, it was only using email as the identifier earlier in this series for convenience. It still collects email in this signup form, for the same reason, keeping the focus on the identifier and OAuth changes below. That does leave a real gap: nothing here verifies a user actually owns the email they typed in. On its own, that's harmless. Combined with OAuth, it isn't: register with a stranger's email through credentials, and when the real owner later signs in with Google using that same address, OAuth attaches their identity to the account someone else created. brkpt-auth's verify-email feature closes this by restricting access until the address is confirmed; another option is collecting email through otp instead, verified before it's ever attached to an account. Neither is added here, to stay focused on the changes below.

Update the schema:

// prisma/schema.prisma
model User {
  id       Int    @id @default(autoincrement())
  username String @unique
  name     String
  email    String @unique
  password String
}
Enter fullscreen mode Exit fullscreen mode

If your database already has users, adding a required, unique username column isn't a migration you can run as-is: existing rows have no value for it, and nothing generated one for them when they signed up with just an email and a password. A real migration needs a plan for backfilling those rows first. This post resets the database instead, to keep the focus on the adapter changes, not a migration script.

The DTOs change to match:

// src/brkpt-auth/features/credentials/dto/sign-up.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';

export class SignUpDto {
  @IsString()
  @MinLength(3)
  username!: string;

  @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 { IsString } from 'class-validator';

export class SignInDto {
  @IsString()
  identifier!: string;

  @IsString()
  password!: string;
}
Enter fullscreen mode Exit fullscreen mode

CredentialsAdapter.findUserByDto is the one method that actually encodes "how a user gets looked up." It now accepts either username or email as the sign-in identifier, and for sign-up, checks both fields for a conflict, since either one being taken should block the new account:

// src/brkpt-auth/adapters/credentials.adapter.ts
findUserByDto(dto: SignInDto | SignUpDto) {
  if ('identifier' in dto) {
    return this.prisma.user.findFirst({
      where: {
        OR: [{ username: dto.identifier }, { email: dto.identifier }],
      },
    });
  }
  return this.prisma.user.findFirst({
    where: {
      OR: [{ username: dto.username }, { email: dto.email }],
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

SignInDto calls the field identifier rather than username, because a real product usually lets people sign in with either: username and email are both unique, so either one reliably points at exactly one user.

createUser needs the same field added, since it's building the record findUserByDto will later look up:

// src/brkpt-auth/adapters/credentials.adapter.ts
async createUser(dto: SignUpDto) {
  const password = await bcrypt.hash(dto.password, 10);
  return this.prisma.user.create({
    data: {
      username: dto.username,
      name: dto.name,
      email: dto.email,
      password,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

CredentialsService still calls both methods the same way it always did. It has no idea the lookup field changed, because it was never told what the field was in the first place, just that one method returns a user or null and the other returns a created one.

The JWT payload is worth a second look too, not because email stopped being valid, sub alone has always been enough to identify a user, but because it's a convenient place to carry the field the frontend is most likely to want to display without an extra request:

// src/brkpt-auth/adapters/types.ts
export type AuthJwtPayload = { sub: number; username: string };
Enter fullscreen mode Exit fullscreen mode
// src/brkpt-auth/adapters/core.adapter.ts
mapUserToJwtPayload(user: User): AuthJwtPayload {
  return {
    sub: user.id,
    username: user.username,
  };
}
Enter fullscreen mode Exit fullscreen mode

Two methods, in the two adapters that were always the place this kind of decision was supposed to live. CoreService, CredentialsService, the guards, the controllers: still untouched.

OAuth sign-in needs to match on something other than email too

Right now, an account's email is also its identifier for OAuth: if two providers ever returned the same address, they'd both resolve to the same user, and that's as far as the current mapping goes. What it can't do is record that relationship, so there's no clean way to see or manage which providers a given account has actually connected. It also breaks in a simple, concrete case: a user changes their email on this app, then signs in with the Google account tied to their original address. A lookup by email finds nothing, and a second account gets created for the same person.

Tracking the real relationship needs a record of which provider identity belongs to which user, independent of whatever email happens to be on file at the time. The standard way to model that is a separate table: one User can have many LinkedAccount rows, one per provider they've connected, and each LinkedAccount belongs to exactly one User.

// prisma/schema.prisma
model User {
  id            Int             @id @default(autoincrement())
  username      String          @unique
  name          String
  email         String          @unique
  password      String
  linkedAccounts LinkedAccount[]
}

model LinkedAccount {
  id         Int    @id @default(autoincrement())
  provider   String
  providerId String
  userId     Int
  user       User   @relation(fields: [userId], references: [id])

  @@unique([provider, providerId])
}
Enter fullscreen mode Exit fullscreen mode

UserProfile also needs to carry provider and providerId now, alongside the name and email fields the last post already added to it:

// src/brkpt-auth/adapters/types.ts
export interface UserProfile {
  provider: string;
  providerId: string;
  name: string;
  email: string;
}
Enter fullscreen mode Exit fullscreen mode

findOrCreateUserByProfile is where this logic has always lived, and it's the only place that changes. It still matches on email, not username, because that's the only identifier an OAuth profile actually carries; Google and GitHub have no idea what username a person picked on this app, so email is the only field there is to match against. It now checks three things in order: has this exact provider identity signed in before, does a user with this email already exist to attach it to, or is this a genuinely new user.

// src/brkpt-auth/adapters/oauth.adapter.ts
async findOrCreateUserByProfile(profile: UserProfile) {
  const linkedAccount = await this.prisma.linkedAccount.findUnique({
    where: {
      provider_providerId: {
        provider: profile.provider,
        providerId: profile.providerId,
      },
    },
    include: { user: true },
  });
  if (linkedAccount) {
    return { user: linkedAccount.user, created: false };
  }

  const existingUser = await this.prisma.user.findUnique({
    where: { email: profile.email },
  });
  if (existingUser) {
    await this.prisma.linkedAccount.create({
      data: {
        provider: profile.provider,
        providerId: profile.providerId,
        userId: existingUser.id,
      },
    });
    return { user: existingUser, created: false };
  }

  const user = await this.prisma.user.create({
    data: {
      username: await this.generateUniqueUsername(profile.email),
      name: profile.name,
      email: profile.email,
      password: '',
      linkedAccounts: {
        create: { provider: profile.provider, providerId: profile.providerId },
      },
    },
  });
  return { user, created: true };
}
Enter fullscreen mode Exit fullscreen mode

Signing up with a password means choosing a username up front; signing up through Google doesn't go through that form at all, so this method still has to decide one for a genuinely new user. It's a placeholder, generated from the email prefix, with the same uniqueness rule as any other username: if it collides with one that already exists, it's not usable, and a suffix gets added until it is. Letting the user change it later is a separate feature, not something this method needs to solve.

// src/brkpt-auth/adapters/oauth.adapter.ts
private async generateUniqueUsername(email: string): Promise<string> {
  const base = email.split('@')[0];
  let candidate = base;
  let suffix = 0;
  while (await this.prisma.user.findUnique({ where: { username: candidate } })) {
    suffix += 1;
    candidate = `${base}${suffix}`;
  }
  return candidate;
}
Enter fullscreen mode Exit fullscreen mode

mapRawToProfile is what fills in provider and providerId in the first place:

// 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 || !r.sub) {
          throw new BadRequestException(
            'Google profile is missing required fields',
          );
        }
        return {
          provider,
          providerId: r.sub,
          name: r.name,
          email: r.email,
        };
      }
    }
  }

  // findOrCreateUserByProfile and generateUniqueUsername as above

  extractUserIdFromUser(user: User) {
    return user.id;
  }
}
Enter fullscreen mode Exit fullscreen mode

OAuthPort itself hasn't changed: mapRawToProfile still returns a profile shape, findOrCreateUserByProfile still returns { user, created }. Every file brkpt-auth's CLI installed is untouched.

Adding GitHub sign-in on top of this

With the account-linking table already in place, adding a second provider is a smaller change than it looks. brkpt auth add oauth --driver github installs GithubOAuthDriver, which talks to a completely different protocol than Google's: it exchanges an authorization code for an access token, then calls GitHub's /user endpoint, returning whatever JSON GitHub sends back.

That's also a different request shape than Google's: the controller needs code, not idToken. OAuthDto has to accept either, since both providers share this DTO:

// src/brkpt-auth/features/oauth/dto/oauth.dto.ts
import { IsOptional, IsString } from 'class-validator';

export class OAuthDto {
  @IsOptional()
  @IsString()
  idToken?: string;

  @IsOptional()
  @IsString()
  code?: string;
}
Enter fullscreen mode Exit fullscreen mode

GitHub's response JSON doesn't look anything like a Google ID token's claims, and it has its own reliability quirk: GitHub's email field is null if the user hasn't made an email public on their account. Add the credentials, add a second case to the same switch, and nothing else in the file moves:

# .env, add:
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-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'),
  },
  github: {
    clientId: config.getOrThrow('GITHUB_CLIENT_ID'),
    clientSecret: config.getOrThrow('GITHUB_CLIENT_SECRET'),
  },
},
Enter fullscreen mode Exit fullscreen mode
// src/brkpt-auth/adapters/oauth.adapter.ts
interface GitHubUser {
  id: number;
  login: string;
  name: string | null;
  email: string | null;
}

// ...

mapRawToProfile(provider: string, raw: unknown): UserProfile | undefined {
  switch (provider) {
    case 'google': {
      const r = raw as GoogleUser;
      if (!r.email || !r.sub) {
        throw new BadRequestException('Google profile is missing required fields');
      }
      return {
        provider,
        providerId: r.sub,
        name: r.name,
        email: r.email,
      };
    }
    case 'github': {
      const r = raw as GitHubUser;
      if (!r.email) {
        throw new BadRequestException('GitHub profile does not include a public email address');
      }
      return {
        provider,
        providerId: String(r.id),
        name: r.name ?? r.login,
        email: r.email,
      };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Both branches end at the same UserProfile shape, provider, providerId, name, email, which is the only thing findOrCreateUserByProfile ever sees. It doesn't know or care whether that profile came from a Google ID token or a GitHub REST response.

// src/brkpt-auth/features.ts
oauthFeature(OAuthAdapter, GoogleOAuthDriver, GithubOAuthDriver),
Enter fullscreen mode Exit fullscreen mode

What actually moved

Two real requirements, three adapters, two DTOs, and one new table touched, zero files owned by brkpt-auth edited. The username change reached into a JWT payload type and two CredentialsAdapter methods. The OAuth change added LinkedAccount, extended UserProfile, and rewrote findOrCreateUserByProfile's internals, including how it handles generating a username no signup form was there to provide. Adding a second provider on top of that touched the same switch statement again, plus the DTO. CredentialsService, CoreService, OAuthService, and everything wired into BrkptAuthModule stayed exactly as they were after the last post.

What rolling your own would look like

Rolling your own usually means reaching for Passport. Here's roughly what the same three changes touch: LocalStrategy for username/email sign-in, JwtStrategy and JwtRefreshStrategy for the two token types, GoogleStrategy and GithubStrategy for the two providers, a JwtAuthGuard and a JwtRefreshAuthGuard extending Passport's AuthGuard, and a shared AuthService all of it calls into.

// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: process.env.JWT_SECRET });
  }
  async validate(payload: any) {
    return payload;
  }
}

// jwt-refresh.strategy.ts
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor() {
    super({ jwtFromRequest: ExtractJwt.fromExtractors([(req) => req?.cookies?.refresh_token]), secretOrKey: process.env.JWT_REFRESH_SECRET });
  }
  async validate(payload: any) {
    return payload;
  }
}

// jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard() {} // default strategy name, no argument needed

// jwt-refresh-auth.guard.ts
@Injectable()
export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}

// local.strategy.ts
async validate(identifier: string, password: string) {
  const user = await this.authService.validateUser(identifier, password);
  if (!user) throw new UnauthorizedException();
  return user;
}

// google.strategy.ts
async validate(accessToken: string, refreshToken: string, profile: any, done: any) {
  const user = await this.authService.validateOAuthUser('google', profile);
  done(null, user);
}

// github.strategy.ts
async validate(accessToken: string, refreshToken: string, profile: any, done: any) {
  const user = await this.authService.validateOAuthUser('github', profile);
  done(null, user);
}

// auth.service.ts
async validateUser(identifier: string, password: string) {
  const user = await this.usersService.findByUsernameOrEmail(identifier);
  if (!user || !(await bcrypt.compare(password, user.password))) return null;
  return user;
}

async validateOAuthUser(provider: string, profile: any) {
  const linkedAccount = await this.usersService.findLinkedAccount(provider, profile.id);
  if (linkedAccount) return linkedAccount.user;

  const email = provider === 'google' ? profile.emails[0].value : profile.email;
  const existingUser = await this.usersService.findByEmail(email);
  if (existingUser) {
    await this.usersService.attachLinkedAccount(existingUser.id, provider, profile.id);
    return existingUser;
  }

  if (provider === 'google') {
    return this.usersService.createFromGoogle(profile);
  } else if (provider === 'github') {
    return this.usersService.createFromGithub(profile);
  }
}
Enter fullscreen mode Exit fullscreen mode

Seven files just to get sign-in and refresh authenticated: two strategies and two guards for the two token types, three more strategies for the three ways to sign in, all funneling into one AuthService with two entry points, validateOAuthUser alone branching by provider three times, once to read the email off a differently-shaped profile, once to decide who created it, once to decide how. Following a single Google sign-in means opening google.strategy.ts to see what gets passed in, auth.service.ts to see what happens to it, and github.strategy.ts to confirm the other provider calls the same method the same way. A fourth provider is a fourth strategy file and a fourth branch inside a method already doing three jobs at once.

Side-by-side: what actually had to change

Change Rolling your own (Passport) brkpt-auth
Add unique username, keep email sign-in LocalStrategy, AuthService.validateUser, JWT payload construction, possibly JwtStrategy SignUpDto / SignInDto, two methods in CredentialsAdapter, one method in CoreAdapter
Proper account linking (LinkedAccount table) AuthService.validateOAuthUser rewritten, plus helpers in UsersService One method in OAuthAdapter (findOrCreateUserByProfile)
Add GitHub provider New GithubStrategy file + more branches inside AuthService.validateOAuthUser One extra case in mapRawToProfile + register the driver
Services / Guards / Controllers Frequently touched Untouched

The difference is not that the business logic disappears. It is that the decisions stay inside the adapters you already own, while the generated services, guards, and controllers stay out of the way.

Source: dev.to

arrow_back Back to Tutorials