Series Intro
This series will cover the full implementation of OAuth2.0 Authentication in NestJS for the following types of APIs:
- Express REST API;
- Fastify REST API;
- Apollo GraphQL API.
And it is divided in 5 parts:
- Configuration and operations;
- Express Local OAuth REST API;
- Fastify Local OAuth REST API;
- Apollo Local OAuth GraphQL API;
- Adding External OAuth Providers to our API;
Lets start an extra 6th part.
Tutorial Intro
Originally this series was made in a way that only works for Web Apps that use web browsers, however nowadays most people use smartphones to interact with the internet. For the most part, mobile apps do not support HTTP-only cookies; hence, we need to return information about the tokens, including the refresh token and the access token validity period, for better storage and management.
On this tutorial we will make the necessary changes to our API to make it mobile friendly.
- Returning
tokenType
,expiresIn
andrefreshToken
with the authentication response. - Add optional body with
refreshToken
parameter to the logout and refresh endpoints. - Add a token endpoint for external providers to remove the need for HTTP only cookies.
Local Mobile friendly API
For local authentication we need to make two changes:
- Update the response to have
tokenType
,expiresIn
andrefreshToken
. - Allow the user to pass the
refreshToken
on the logout and refresh access endpoints.
Updated Response Mapper
Start by fixing the IAuthResponse
interface and AuthResponseMapper
class:
-
src/auth/interfaces/auth-response.interface.ts
:
// ... export interface IAuthResponse { // ... // New fields refreshToken: string; tokenType: string; expiresIn: number; }
-
src/auth/mappers/auth-response.mapper.ts
:
// ... export class AuthResponseMapper implements IAuthResponse { // ... // New fields @ApiProperty({ description: 'Refresh token', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', type: String, }) public readonly refreshToken: string; @ApiProperty({ description: 'Token type', example: 'Bearer', type: String, }) public readonly tokenType: string; @ApiProperty({ description: 'Expiration period in seconds', example: 3600, type: Number, }) public readonly expiresIn: number; constructor(values: IAuthResponse) { Object.assign(this, values); } public static map(result: IAuthResult): AuthResponseMapper { return new AuthResponseMapper({ user: AuthResponseUserMapper.map(result.user), accessToken: result.accessToken, refreshToken: result.refreshToken, tokenType: 'Bearer', expiresIn: result.expiresIn, }); } }
Services Updates
To get the access token validity period we need to create a getter for it on jwt.service.ts
:
@Injectable()
export class JwtService {
// ...
public get accessTime(): number {
return this.jwtConfig.access.time;
}
}
Then on the auth.service.ts
update all methods that return Promise<IAuthResult>
with the new response format:
@Injectable()
export class AuthService {
// ...
public async confirmEmail(
dto: ConfirmEmailDto,
domain?: string,
): Promise<IAuthResult> {
// ...
return {
user,
accessToken,
refreshToken,
expiresIn: this.jwtService.accessTime,
};
}
// ...
public async signIn(dto: SignInDto, domain?: string): Promise<IAuthResult> {
// ...
return {
user,
accessToken,
refreshToken,
expiresIn: this.jwtService.accessTime,
};
}
// ...
public async refreshTokenAccess(
refreshToken: string,
domain?: string,
): Promise<IAuthResult> {
// ...
return {
user,
accessToken,
refreshToken: newRefreshToken,
expiresIn: this.jwtService.accessTime,
};
}
// ...
public async updatePassword(
userId: number,
dto: ChangePasswordDto,
domain?: string,
): Promise<IAuthResult> {
// ...
return {
user,
accessToken,
refreshToken,
expiresIn: this.jwtService.accessTime,
};
}
}
Refresh token in the body
Now for /refresh-access
and /logout
we need to be able to pass the refreshToken
both from the body and from the cookie.
Start by creating a new refresh-access.dto.ts
:
import { ApiProperty } from '@nestjs/swagger';
import { IsJWT, IsOptional, IsString } from 'class-validator';
export abstract class RefreshAccessDto {
@ApiProperty({
description: 'The JWT token sent to the user email',
example:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
type: String,
})
@IsOptional()
@IsString()
@IsJWT()
public refreshToken?: string;
}
And update the AuthController
's refreshTokenFromReq
method to take the dto:
// ...
import { RefreshAccessDto } from './dtos/refresh-access.dto';
// ...
@ApiTags('Auth')
@Controller('api/auth')
@UseGuards(FastifyThrottlerGuard)
export class AuthController {
// ...
private refreshTokenFromReq(
req: FastifyRequest,
dto?: RefreshAccessDto,
): string {
const token: string | undefined = req.cookies[this.cookieName];
if (isUndefined(token) || isNull(token)) {
if (!isUndefined(dto?.refreshToken)) {
return dto.refreshToken;
}
throw new UnauthorizedException();
}
const { valid, value } = req.unsignCookie(token);
if (!valid) {
throw new UnauthorizedException();
}
return value;
}
// ...
}
Finally, pass the dto to both endpoints:
// ...
@ApiTags('Auth')
@Controller('api/auth')
@UseGuards(FastifyThrottlerGuard)
export class AuthController {
// ...
@Public()
@Post('/refresh-access')
@ApiOkResponse({
type: AuthResponseMapper,
description: 'Refreshes and returns the access token',
})
@ApiUnauthorizedResponse({
description: 'Invalid token',
})
@ApiBadRequestResponse({
description:
'Something is invalid on the request body, or Token is invalid or expired',
})
public async refreshAccess(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@Body() refreshAccessDto?: RefreshAccessDto,
): Promise<void> {
const token = this.refreshTokenFromReq(req, refreshAccessDto);
const result = await this.authService.refreshTokenAccess(
token,
req.headers.origin,
);
this.saveRefreshCookie(res, result.refreshToken)
.status(HttpStatus.OK)
.send(AuthResponseMapper.map(result));
}
@Post('/logout')
@ApiOkResponse({
type: MessageMapper,
description: 'The user is logged out',
})
@ApiBadRequestResponse({
description: 'Something is invalid on the request body',
})
@ApiUnauthorizedResponse({
description: 'Invalid token',
})
public async logout(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@Body() refreshAccessDto?: RefreshAccessDto,
): Promise<void> {
const token = this.refreshTokenFromReq(req, refreshAccessDto);
const message = await this.authService.logout(token);
res
.clearCookie(this.cookieName, { path: this.cookiePath })
.header('Content-Type', 'application/json')
.status(HttpStatus.OK)
.send(message);
}
// ...
}
External Mobile friendly API
To make our external OAuth mobile friendly we need to add an internal token endpoint for an internal code exchange.
Exchange Code Generation
First, we need to decide on the format of the code. A good, secure, and URL-friendly random string is a base62-encoded UUID, so that is what we will use. Add a static method to the Oauth2Service
to generate base62 UUIDs:
// ...
import { v4 } from 'uuid';
// ...
@Injectable()
export class Oauth2Service {
private static readonly BASE62 =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private static readonly BIG62 = BigInt(Oauth2Service.BASE62.length);
// ...
private static generateCode(): string {
let num = BigInt('0x' + v4().replace(/-/g, ''));
let code = '';
while (num > 0) {
const remainder = Number(num % Oauth2Service.BIG62);
code = Oauth2Service.BASE62[remainder] + code;
num = num / Oauth2Service.BIG62;
}
return code.padStart(22, '0');
}
// ...
}
NOTE: this is a standard base 62 encoding that you can find by searching stackoverflow.
New Callback Logic
Since the callback now needs to work for both mobile and web, we need to redirect with Accept
status code to the device.
Start by renaming the login
method to callback
on the Oauth2Service
and create a callback-result.interface.ts
file:
export interface ICallbackResult {
readonly code: string;
readonly accessToken: string;
readonly expiresIn: number;
}
Next, update the callback
method to cache the code and return both the code and accessToken:
// ...
@Injectable()
export class Oauth2Service {
// ...
private static getOAuthCodeKey(code: string): string {
return `oauth_code:${code}`;
}
// ...
public async callback(
provider: OAuthProvidersEnum,
email: string,
name: string,
): Promise<ICallbackResult> {
// Get or create a user for the given provider
const user = await this.usersService.findOrCreate(provider, email, name);
// Generate and cache the code with the user email
const code = Oauth2Service.generateCode();
await this.commonService.throwInternalError(
this.cacheManager.set(
Oauth2Service.getOAuthCodeKey(code),
user.email,
this.jwtService.accessTime * 1000,
),
);
// Generate an access token to authenticate on the token endpoint
const accessToken = await this.jwtService.generateToken(
user,
TokenTypeEnum.ACCESS,
);
return {
code,
accessToken,
expiresIn: this.jwtService.accessTime,
};
}
// ...
}
Lastly, update the Oauth2Controller
to redirect with the correct URL Search params. Rename the loginAndRedirect
method to callbackAndRedirect
, and add the new business logic:
// ...
@ApiTags('Oauth2')
@Controller('api/auth/ext')
@UseGuards(FastifyThrottlerGuard)
export class Oauth2Controller {
// ...
private async callbackAndRedirect(
res: FastifyReply,
provider: OAuthProvidersEnum,
email: string,
name: string,
): Promise<FastifyReply> {
// Get the code, access token and expiration
const { code, accessToken, expiresIn } = await this.oauth2Service.callback(
provider,
email,
name,
);
// Build the query params
const urlSearchParams = new URLSearchParams({
code,
accessToken,
tokenType: 'Bearer',
expiresIn: expiresIn.toString(),
});
// Redirect to the front-end with the encoded query params
return res
.status(HttpStatus.FOUND) // Note that now we use `FOUND` instead of `Redirect` as the user still needs to do an extra request
.redirect(`${this.url}/auth/callback?${urlSearchParams.toString()}`);
}
}
Token Endpoint
At long last, to be able to get a refreshToken
the we need to build a POST /api/auth/ext/token
endpoint.
Start by creating a token
method on the Oauth2Service
:
// ...
@Injectable()
export class Oauth2Service {
// ...
public async token(code: string, userId: number): Promise<IAuthResult> {
// Check if the code is cached
const codeKey = Oauth2Service.getOAuthCodeKey(code);
const email = await this.commonService.throwInternalError(
this.cacheManager.get<string>(codeKey),
);
// Return 401 UNAUTHORIZED if code is invalid or expired
if (!email) {
throw new UnauthorizedException();
}
// Delete the code so it can't be used again
await this.commonService.throwInternalError(this.cacheManager.del(codeKey));
// Find the user by the cached email
const user = await this.usersService.findOneByEmail(email);
// Check if the authenticated user is the owner of the code
if (user.id !== userId) {
throw new UnauthorizedException();
}
// Return new access and refresh token
const [accessToken, refreshToken] =
await this.jwtService.generateAuthTokens(user);
return {
user,
accessToken,
refreshToken,
expiresIn: this.jwtService.accessTime,
};
}
// ...
}
Afterward, create a dto for this endpint token.dto.ts
:
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsUrl, Length } from 'class-validator';
export abstract class TokenDto {
@ApiProperty({
description: 'The Code to exchange for a token',
example: '5WA0R4DVyWThKFnc73z7nT',
minLength: 1,
maxLength: 22,
type: String,
})
@IsString()
@Length(1, 22)
public code: string;
@ApiProperty({
description: 'Redirect URI that was used to get the token',
example: 'https://example.com/auth/callback',
type: String,
})
@IsString()
@IsUrl()
public redirectUri: string;
}
Finally, create the new endpoint on the Oauth2Controller
:
// ...
import { TokenDto } from './dtos/token.dto';
// ...
@ApiTags('Oauth2')
@Controller('api/auth/ext')
@UseGuards(FastifyThrottlerGuard)
export class Oauth2Controller {
// ...
@Post('token')
@ApiResponse({
description: "Returns the user's OAuth 2 response",
status: HttpStatus.OK,
})
@ApiUnauthorizedResponse({
description: 'Code or redirectUri is invalid',
})
public async token(
@CurrentUser() userId: number,
@Body() tokenDto: TokenDto,
@Res() res: FastifyReply,
): Promise<void> {
// Check if the URI is correct
if (tokenDto.redirectUri !== this.url + '/auth/callback') {
throw new UnauthorizedException();
}
// Login the user and return the auth response
const result = await this.oauth2Service.token(tokenDto.code, userId);
return res
.cookie(this.cookieName, result.refreshToken, {
secure: !this.testing,
httpOnly: true,
signed: true,
path: this.cookiePath,
expires: new Date(Date.now() + this.refreshTime * 1000),
})
.header('Content-Type', 'application/json')
.status(HttpStatus.OK)
.send(AuthResponseMapper.map(result));
}
// ...
}
NOTE: if you really want to be strict you could also enforce the content-type
to be application/x-www-form-urlencoded
. Personally, I think allowing json as well makes the API friendlier.
Conclusion
This is the true end of my OAuth 2.0 series with NestJS, with this series you have learnt how to create a production grade OAuth 2.0 microservice using NodeJS that supports both web and mobile apps.
The full source code can be found on this repo.
About the Author
Hey there! I am Afonso Barracha, your go-to econometrician who found his way into the world of back-end development with a soft spot for GraphQL. If you enjoyed reading this article, why not show some love by buying me a coffee?
Lately, I have been diving deep into more advanced subjects. As a result, I have switched from sharing my thoughts every week to posting once or twice a month. This way, I can make sure to bring you the highest quality content possible.
Do not miss out on any of my latest articles – follow me here on dev and LinkedIn to stay updated. I would be thrilled to welcome you to our ever-growing community! See you around!
Top comments (1)
Just noticed that I confused the accepted 202 status code, with Found 302 status code, just fixed it now