Skip to content

Instantly share code, notes, and snippets.

@herveGuigoz
Created May 30, 2023 15:47
Show Gist options
  • Select an option

  • Save herveGuigoz/a0094759bc8f9ade9e7af23e48672ef8 to your computer and use it in GitHub Desktop.

Select an option

Save herveGuigoz/a0094759bc8f9ade9e7af23e48672ef8 to your computer and use it in GitHub Desktop.

Revisions

  1. herveGuigoz created this gist May 30, 2023.
    38 changes: 38 additions & 0 deletions authentication.guard.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,38 @@
    import {
    CanActivate,
    ExecutionContext,
    HttpException,
    HttpStatus,
    Injectable,
    UnauthorizedException,
    } from '@nestjs/common';

    import { AuthenticationService } from './authentication.service';
    import { Request } from 'express';

    @Injectable()
    export class AuthenticationGuard implements CanActivate {
    constructor(private readonly authenticationService: AuthenticationService) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
    throw new UnauthorizedException();
    }

    try {
    // Store the user on the request object if we want to retrieve it from the controllers
    request['user'] = await this.authenticationService.authenticate(token);
    return true;
    } catch (e) {
    throw new HttpException(e.message, HttpStatus.UNAUTHORIZED);
    }
    }

    private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
    }
    }
    19 changes: 19 additions & 0 deletions authentication.module.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,19 @@
    import { Module } from '@nestjs/common';

    import { AuthenticationGuard } from './authentication.guard';
    import { AuthenticationService } from './authentication.service';
    import { AUTHENTICATION_STRATEGY_TOKEN } from './authentication.strategy';
    import { KeycloakAuthenticationStrategy } from './keycloak.strategy';

    @Module({
    providers: [
    AuthenticationGuard,
    AuthenticationService,
    {
    provide: AUTHENTICATION_STRATEGY_TOKEN,
    useClass: KeycloakAuthenticationStrategy,
    },
    ],
    exports: [AuthenticationService],
    })
    export class AuthenticationModule {}
    35 changes: 35 additions & 0 deletions authentication.service.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,35 @@
    import { Inject, Injectable, Logger } from '@nestjs/common';

    import {
    AUTHENTICATION_STRATEGY_TOKEN,
    AuthenticationStrategy,
    } from './authentication.strategy';

    export class AuthenticationError extends Error {}

    @Injectable()
    export class AuthenticationService {
    private logger = new Logger(AuthenticationService.name);

    constructor(
    @Inject(AUTHENTICATION_STRATEGY_TOKEN)
    private readonly strategy: AuthenticationStrategy,
    ) {}

    async authenticate(accessToken: string): Promise<string> {
    try {
    const userInfos = await this.strategy.authenticate(accessToken);

    const user = {
    id: userInfos.sub,
    username: userInfos.preferred_username,
    };

    // TODO: create user if it doesn't exist
    return user.id;
    } catch (e) {
    this.logger.error(e.message, e.stackTrace);
    throw new AuthenticationError(e.message);
    }
    }
    }
    15 changes: 15 additions & 0 deletions authentication.strategy.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,15 @@
    export const AUTHENTICATION_STRATEGY_TOKEN = 'AuthenticationStrategy';

    export interface KeycloakUserInfoResponse {
    sub: string;
    email_verified: boolean;
    name: string;
    preferred_username: string;
    given_name: string;
    family_name: string;
    email: string;
    }

    export interface AuthenticationStrategy {
    authenticate(accessToken: string): Promise<KeycloakUserInfoResponse>;
    }
    71 changes: 71 additions & 0 deletions keycloak.strategy.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,71 @@
    import { Injectable, UnauthorizedException } from '@nestjs/common';
    import * as jwt from 'jsonwebtoken';
    import {
    AuthenticationStrategy,
    KeycloakUserInfoResponse,
    } from './authentication.strategy';

    interface KeycloakCertsResponse {
    keys: KeycloakKey[];
    }

    interface KeycloakKey {
    kid: string;
    x5c: string;
    }

    export class InvalidToken extends Error {
    constructor(keyId: string) {
    super(`Invalid public key ID ${keyId}`);
    }
    }

    @Injectable()
    export class KeycloakAuthenticationStrategy implements AuthenticationStrategy {
    private readonly baseURL: string;
    private readonly realm: string;

    constructor() {
    this.baseURL = process.env.KEYCLOAK_BASE_URL;
    this.realm = process.env.KEYCLOAK_REALM;
    }

    async authenticate(accessToken: string): Promise<KeycloakUserInfoResponse> {
    try {
    const token = jwt.decode(accessToken, { complete: true });

    const keyId = token.header.kid;

    const publicKey = await this.getPublicKey(keyId);

    return jwt.verify(accessToken, publicKey, {
    algorithms: ['RS256'],
    });
    } catch (_) {
    throw new UnauthorizedException();
    }
    }

    /*
    * Fetches the public key from Keycloak to sign the token
    */
    private async getPublicKey(keyId: string): Promise<string> {
    const response = await fetch(
    `${this.baseURL}/realms/${this.realm}/protocol/openid-connect/certs`,
    { method: 'GET' },
    );

    const { keys }: KeycloakCertsResponse = await response.json();

    const key = keys.find((k) => k.kid === keyId);

    if (!key) {
    // Token is probably so old, Keycloak doesn't even advertise the corresponding public key anymore
    throw new InvalidToken(keyId);
    }

    const publicKey = `-----BEGIN CERTIFICATE-----\r\n${key.x5c}\r\n-----END CERTIFICATE-----`;

    return publicKey;
    }
    }