NestJS course Β· Module 4: Authentication

OAuth and Social Login - the alliances of the Empire

6 min read
In this lesson5

A citizen from the province of Gaul comes to the camp gate. You can make him create a new account: invent a password, confirm an address, go through the whole recruitment procedure. Or you can strike an alliance with Gaul: her officials will confirm the newcomer's identity, and you will accept that confirmation as your own.

The second way is called OAuth 2.0 - and it is what sits behind the "Sign in with Google" button you know from hundreds of sites. It is taught at this point for one reason: the citizen's password never enters your camp. You do not have to store it, hash it or protect it from leaking, because you never see it.

The four steps of the alliance

Before we look at code, let's follow what happens at that button - because this sequence is the heart of OAuth, and the code merely serves it.

  1. The citizen clicks "Sign in with Google" on your page.
  2. Your application redirects him to Google. From that moment he is talking to Google, not to you - and there, on Google's page, he enters his password.
  3. Google sends him back to an agreed address, attaching a one-time code. Your server exchanges that code for the citizen's data.
  4. Your application issues its own JWT - from now on the citizen moves around the camp on your pass, and Google's role ends.

Step four is the one most often forgotten. Google confirms identity once, at the entrance. All the application's later work rests on your own pass - exactly the kind you met in the lesson on JWT.

The strategy - handling the return from Gaul

The code for this exchange is taken on by a Passport strategy. You know the skeleton; only the body of validate is new:

1@Injectable()
2export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
3  constructor(
4    private configService: ConfigService,
5    private usersService: UsersService,
6  ) {
7    super({
8      clientID: configService.get('GOOGLE_CLIENT_ID'),
9      clientSecret: configService.get('GOOGLE_CLIENT_SECRET'),
10      callbackURL: configService.get('GOOGLE_CALLBACK_URL'),
11      scope: ['email', 'profile'],
12    });
13  }
14
15  async validate(accessToken: string, refreshToken: string, profile: any) {
16    const { name, emails } = profile;
17
18    return this.usersService.findOrCreateSocialUser({
19      email: emails[0].value,
20      firstName: name.givenName,
21      provider: 'google',
22      providerId: profile.id,
23    });
24  }
25}

In super() we describe the alliance itself. clientID and clientSecret are your application's papers issued by Google - read from configuration, never written into the code. callbackURL is the address from step three, where Google will send the citizen back; you must register that same address in the Google console, or the redirect will be rejected. scope states what you are asking for - here only an email address and a basic profile.

The rule about scope is short: ask for the minimum. Every extra permission is one more consent screen for the user and one more thing you have to protect.

The validate method runs only after the return from Google, with the citizen's profile ready. The returned object lands - as in every strategy - in request.user.

Two identifiers instead of a password

Note the provider and providerId pair in the data we pass on. That pair replaces the password.

providerId is the citizen's identifier in Google's system - unchanging, unlike an email address, which can be edited. But providerId alone is not enough, because a citizen with the same number may exist at another provider. So we remember the pair: who confirmed and whom they confirmed.

1async findOrCreateSocialUser(data: SocialUserDto): Promise<User> {
2  const existing = await this.usersRepository.findOne({
3    where: { provider: data.provider, providerId: data.providerId },
4  });
5
6  if (existing) {
7    return existing;
8  }
9
10  return this.usersRepository.save({
11    email: data.email,
12    firstName: data.firstName,
13    provider: data.provider,
14    providerId: data.providerId,
15  });
16}

The name findOrCreate describes the whole logic: on the first login we create the entry, on every later one we find it. Note what this entity lacks - a password column. A citizen admitted through the alliance never supplied a password, so there is nothing to store. That is in fact an easy trap to fall into: if your password column is required (nullable: false), saving such a user will fail.

Two endpoints closing the loop

On the controller's side the alliance comes down to two addresses:

1@Controller('auth')
2export class AuthController {
3  @Get('google')
4  @UseGuards(AuthGuard('google'))
5  googleLogin() {
6    // the redirect is performed by the guard
7  }
8
9  @Get('google/callback')
10  @UseGuards(AuthGuard('google'))
11  googleCallback(@Request() req) {
12    return this.authService.generateToken(req.user);
13  }
14}

The first endpoint has an empty body, and that is not a mistake - its only job is to trigger the guard, which redirects the user to Google. Code in this method would never run.

The second is the address from callbackURL. When the citizen returns, the guard exchanges the code for a profile, the strategy saves or finds the user, and at the end we issue our own JWT. And here step four closes: from this moment the application no longer needs Google.

Summary

The alliance is struck, the gate is open to citizens of neighbouring provinces:

  • OAuth 2.0 allows logging in through an external service, and the password never enters your application,
  • the flow has four steps: the click, the redirect to the provider, the return with a code, and issuing your own JWT,
  • the provider's role ends after login - from there your pass takes over,
  • clientID and clientSecret are read from configuration, callbackURL must match the address registered with the provider,
  • in scope, ask for the minimum data you need,
  • you remember identity as the provider + providerId pair - it replaces the password, because emails get changed,
  • findOrCreate creates the entry on the first login and finds it on every later one,
  • an OAuth user has no password, so the password column cannot be required,
  • the starting endpoint has an empty body - all the work is done by the guard.

In the next lesson we will turn to sessions and cookies, the other road to the fort - the stateful alternative to passes. For now remember: OAuth is an alliance in which a neighbour vouches for the newcomer; you accept that vouching once and immediately issue a document of your own.

Code for this lesson: src/auth/google.strategy.ts
1// OAuth and Social Login - Alliances of the Empire
2import { Injectable } from '@nestjs/common';
3import { PassportStrategy } from '@nestjs/passport';
4import { Strategy, VerifyCallback } from 'passport-google-oauth20';
5import { ConfigService } from '@nestjs/config';
6
7// TODO: Implement GoogleStrategy
8// 1. Extend PassportStrategy(Strategy, 'google')
9// 2. Configure clientID, clientSecret, callbackURL, scope
10// 3. Implement validate() to process the Google profile
11@Injectable()
12export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
13  constructor(private configService: ConfigService) {
14    super({
15      // TODO: Get clientID from configService
16      clientID: '',
17
18      // TODO: Get clientSecret from configService
19      clientSecret: '',
20
21      // Return URL after Google login
22      callbackURL: 'http://localhost:4000/auth/google/callback',
23
24      // Data scopes to fetch
25      scope: ['email', 'profile'],
26    });
27  }
28
29  // TODO: Implement validate
30  async validate(
31    accessToken: string,
32    refreshToken: string,
33    profile: any,
34    done: VerifyCallback,
35  ): Promise<any> {
36    const { name, emails, photos } = profile;
37
38    // TODO: Create a citizen object from the Google data
39    const citizen = {
40      email: '', // TODO: emails[0].value
41      firstName: '', // TODO: name.givenName
42      lastName: '', // TODO: name.familyName
43      picture: '', // TODO: photos[0].value
44      provider: 'google',
45      accessToken,
46    };
47
48    // TODO: Call done(null, citizen)
49    done(null, citizen);
50  }
51}
52
53// OAuth Controller
54import { Controller, Get, UseGuards, Request } from '@nestjs/common';
55import { AuthGuard } from '@nestjs/passport';
56
57@Controller('auth')
58export class OAuthController {
59  // Redirect to Google
60  @Get('google')
61  @UseGuards(AuthGuard('google'))
62  async googleAuth() {
63    // Passport automatically redirects to Google
64  }
65
66  // Callback after logging in
67  @Get('google/callback')
68  @UseGuards(AuthGuard('google'))
69  googleAuthCallback(@Request() req: any) {
70    // req.user contains the data from validate()
71    return { message: 'Citizen joined via Google!', user: req.user };
72  }
73}
74
75console.log('OAuth 2.0 - login via external services');
76console.log('Flow: App -> Google -> Callback -> JWT Token');
77

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does OAuth 2.0 enable in a web application?

Hands-on tasks in the game

  • Vertical ordering

    Arrange the steps of OAuth login:

Useful articles