Search code examples
jwtnestjskeycloak

Validate keycloak token


In a keycloak context I am using Insomnia to get a token and send a post request to my nestjs app. I use this function to validate the token


  async validateToken(token: string): Promise<boolean> {
    try {
      // Make a request to the Keycloak token endpoint to validate the token
      // Decode the token to extract its claims
      const decodedToken: CustomJwtPayload = jwtDecode(token);
      console.log(Date());
      console.log(decodedToken);

      // Validate the token signature (verify it with Keycloak public key)
      const publicKeyResponse = await axios.get<PublicKeyResponse>(
        `${process.env.KEYCLOAK_BASE_URL}/protocol/openid-connect/certs`,
      );

      const publicKey = publicKeyResponse.data.keys[0].x5c[0];
      // Verify the token signature with the public key
      const verifiedToken = jwt.verify(token, publicKey, {
        algorithms: ['RS256'],
      });

      console.log(verifiedToken);

      if (!verifiedToken) {
        return false;
      }

      // Check token expiry
      const currentTime = Math.floor(Date.now() / 1000);
      if (decodedToken.exp && decodedToken.exp < currentTime) {
        return false; // Token expired
      }
      // Verify token issuer
      if (decodedToken.notes.iss !== `${process.env.KEYCLOAK_BASE_URL}`) {
        return false; // Invalid issuer
      }

      // Check token audience
      if (decodedToken.aud !== process.env.KEYCLOAK_CLIENT_ID) {
        return false; // Invalid audience
      }

      // Token is valid
      return true;
    } catch (error) {
      console.error('Error validating token:', error);
      return false;
    }
  }
}

Yet I am having this error Error validating token: JsonWebTokenError: invalid token it's not verbose enough and I can not get my mind around it.

Does anyone have any idea how to get through ?

EDIT: now that both my db and keycloak are running on the same Docker, here is my app structure

my-nestjs-project/
│
├── src/
│   ├── auth/
│   ├── catApi/
│   │   ├── catApi.controller.spec.ts  
│   │   ├── catApi.controller.ts
│   │   ├── catApi.module.ts
│   │   ├── catApi.service.spec.ts
│   │   └── catApi.service.ts
│   ├── job/
│   │   ├── dto
│   │   ├── job.controller.ts
│   │   ├── job.module.ts
│   │   └── job.service.ts
│   ├── keycloak/
│   │   ├── keycloak.guard.spec.ts # ??? nothing is happening here
│   │   ├── keycloak.guard.ts      # guarding routes in the backend
│   │   ├── keycloak.module.ts
│   │   └── keycloak.service.ts    # Keycloak logic to discover keycloak issuer conf, validatetoken
│   ├── prisma
│   ├── webapp
│   │   ├── dto
│   │   ├── job.controller.ts 
│   │   ├── job.module.ts
│   │   └── job.service.ts
│   │
│   └── ...                  # Other pages (e.g., app.module.ts, main.ts, etc.)
│
├── node_modules/            # Node modules (not manually edited)
│
├── .env.local               # Environment variables (e.g., Keycloak URL, client ID)
│
├── styles/                  # Global styles, CSS modules, etc.
│
├── docker-compose.yml       # Keycloak Docker Compose File
│
├── package.json             # Project metadata and dependencies
│
└── ...       

Job and Webapp are both business logic and have db tables, they will be the protected routes when I succeed. I am using catApi to hit catfacts and get some fun and quirky content with Insomnia, this route is currently protected

@Get('facts/cats')
  @UseGuards(KeycloakAuthGuard)
  getCatFacts() {
    this.apiService.getCatFactsWithAxiosLib();
  }

I get my token via : Post in insomnia at http://localhost:8080/realms/my-nestjs-app/protocol/openid-connect/auth

and ask for cat fact like this get request ininsomnia

and that's when it runs through my function above and send me back the error


Solution

  • Explanation of the token validation logic

    enter image description here

    Decode the Token: The JWT token is decoded to extract its payload, which includes information like the issuer, audience, and expiry time.

    Verify Issuer and Audience: The code checks if the token's issuer (iss) and authorized party (azp) match the expected values, ensuring it's from a trusted source and intended for the correct audience.

    Check Expiry: The token's expiry time (exp) is compared against the current time to ensure it hasn't expired.

    Fetch Public Key: The code retrieves the public key from the Keycloak server, which is necessary for the next step.

    Signature Verification: Using the obtained public key, the code verifies the token's signature to ensure it's valid and hasn't been tampered with.

    This code will work

    Token generator by Keycloak

    Install Docker Desktop

    Install Docker Desktop in Windows

    Or In Mac

    Launching Docker Desktop

    enter image description here

    Save as docker-compose.yml More detail information in here

    version: '3.7'
    
    services:
      postgres:
        image: postgres
        volumes:
          - postgres_data:/var/lib/postgresql/data
        environment:
          POSTGRES_DB: keycloak
          POSTGRES_USER: keycloak
          POSTGRES_PASSWORD: password
    
      keycloak:
        image: quay.io/keycloak/keycloak:latest  # Update to the latest Keycloak image
        command: start-dev
        environment:
          KC_DB: postgres
          KC_DB_URL: jdbc:postgresql://postgres/keycloak
          KC_DB_USERNAME: keycloak
          KC_DB_PASSWORD: password
          KC_HTTP_ENABLED: true  # Enable HTTP if you're not using HTTPS
          KC_HEALTH_ENABLED: true
          KEYCLOAK_ADMIN: admin
          KEYCLOAK_ADMIN_PASSWORD: admin
        ports:
          - 8080:8080
        restart: always
        depends_on:
          - postgres
    
    volumes:
      postgres_data:
        driver: local
    

    Launching Keycloak

    In root directory

    docker compose up
    

    enter image description here

    Open Keycloak

    http://localhost:8080/
    

    enter image description here enter image description here

    Check User Token

    Token Endpoint

    POST http://localhost:8080/realms/my-nestjs-app/protocol/openid-connect/token
    

    At x-www-form-urlencoded tab

    client_id : admin-cli
    username  : admin
    password  : 1234
    grant_type : password
    

    enter image description here

    file hierarchy structure for your Next.js project

    enter image description here

    Each file code

    auth.controller.ts

    import {Body, Controller, Post} from '@nestjs/common';
    import {KeycloakService} from '../keycloak/keycloak.service';
    
    @Controller('auth')
    export class AuthController {
      constructor(private keycloakService: KeycloakService) {}
    
      @Post('validateToken')
      async validateToken(@Body() body) {
        const {token} = body;
        return await this.keycloakService.validateToken(token);
      }
    }
    

    auth.module.ts

    import {Module} from '@nestjs/common';
    
    import {KeycloakModule} from '../keycloak/keycloak.module';
    
    import {AuthController} from './auth.controller';
    import {AuthService} from './auth.service';
    
    @Module({
      imports: [KeycloakModule],
      providers: [AuthService],
      controllers: [AuthController]
    })
    export class AuthModule {
    }
    

    cat-api.module

    import {HttpModule} from '@nestjs/axios';
    import {Module} from '@nestjs/common';
    
    import {KeycloakModule} from '../keycloak/keycloak.module';
    
    import {CatApiController} from './catApi.controller';
    import {CatApiService} from './catApi.service';
    
    @Module({
      imports: [
        KeycloakModule,
        HttpModule,
      ],
      controllers: [CatApiController],
      providers: [CatApiService],
    })
    export class CatApiModule {
    }
    

    catApi.controller.ts

    import { Controller, Get, UseGuards } from '@nestjs/common';
    import { CatApiService } from './catApi.service';
    import { KeycloakAuthGuard } from '../keycloak/keycloak.guard';
    
    @Controller('facts')
    export class CatApiController {
      constructor(private catApiService: CatApiService) {}
    
      @Get('cats')
      @UseGuards(KeycloakAuthGuard)
      async getCatFacts() {
        return this.catApiService.getCatFactsWithAxiosLib();
      }
    }
    

    catApi.service.ts

    import {HttpService} from '@nestjs/axios';
    import {Injectable} from '@nestjs/common';
    import {AxiosResponse} from 'axios';
    import {Observable} from 'rxjs';
    import {map} from 'rxjs/operators';
    
    @Injectable()
    export class CatApiService {
      constructor(private httpService: HttpService) {}
    
      async getCatFactsWithAxiosLib(): Promise<any> {
        try {
          // Replace 'external-api-url' with the actual API URL
          const response: Observable<AxiosResponse<any>> =
              this.httpService.get('https://catfact.ninja/breeds?limit=1');
          return response
              .pipe(map((axiosResponse) => {
                return {
                  data: axiosResponse.data,
                  status: axiosResponse.status,
                };
              }))
              .toPromise();
        } catch (error) {
          // Handle error (e.g., log it, format it, etc.)
          console.error('Error fetching cat facts:', error);
          throw new Error('Failed to fetch cat facts');
        }
      }
    }
    

    keycloak.guard.ts

    import {CanActivate, ExecutionContext, Injectable} from '@nestjs/common';
    import {Observable} from 'rxjs';
    
    import {KeycloakService} from './keycloak.service';
    
    @Injectable()
    export class KeycloakAuthGuard implements CanActivate {
      constructor(private keycloakService: KeycloakService) {}
    
      canActivate(
          context: ExecutionContext,
          ): boolean|Promise<boolean>|Observable<boolean> {
        const request = context.switchToHttp().getRequest();
        const token = this.keycloakService.extractToken(request);
    
        if (!token) {
          // Handle the case where the token is not provided
          return false;
        }
    
        return this.keycloakService.validateToken(token);
      }
    }
    

    keycloak.module.ts

    import { HttpModule } from '@nestjs/axios';
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    
    import { KeycloakService } from './keycloak.service';
    
    @Module({
      imports: [HttpModule, ConfigModule],
      providers: [KeycloakService],
      exports: [KeycloakService],
    })
    export class KeycloakModule {
    }
    

    keycloak.service.ts

    import {HttpService} from '@nestjs/axios';
    import {Injectable} from '@nestjs/common';
    import axios from 'axios';
    import * as jwt from 'jsonwebtoken';
    
    @Injectable()
    export class KeycloakService {
      constructor(private httpService: HttpService) {}
    
      extractToken(request: any): string|null {
        const authHeader = request.headers.authorization;
        if (authHeader) {
          return authHeader.split(' ')[1];  // Assumes Bearer token format
        }
        return null;
      }
    
      async validateToken(token: string): Promise<boolean> {
        const keycloakUrl = 'http://localhost:8080';
        const realm = 'my-nestjs-app';
        const clientId = 'admin-cli';
    
        try {
          if (!token) {
            console.error('Token not provided');
            return false;
          }
          const decodedToken = jwt.decode(token, {complete: true});
    
          const publicKeyResponse = await axios.get(
              `${keycloakUrl}/realms/${realm}/protocol/openid-connect/certs`);
          const signingKey = publicKeyResponse.data.keys.find(
              key => key.use === 'sig' && key.alg === 'RS256');
    
          if (!signingKey) {
            return false;  // Signing key not found
          }
    
          const pemStart = '-----BEGIN CERTIFICATE-----\n';
          const pemEnd = '\n-----END CERTIFICATE-----';
          const pem = pemStart + signingKey.x5c[0] + pemEnd;
    
          if (decodedToken.payload.iss !== `${keycloakUrl}/realms/${realm}`) {
            return false;  // Invalid issuer
          }
    
          if (decodedToken.payload.azp !== clientId) {
            return false;  // Invalid audience
          }
    
          const currentTime = Math.floor(Date.now() / 1000);
          if (decodedToken.payload.exp < currentTime) {
            return false;  // Token expired
          }
    
          jwt.verify(token, pem, {algorithms: ['RS256']});
          return true;
        } catch (error) {
          console.error('Error validating token:', error);
          return false;  // Error in validating token
        }
      }
    }
    

    app.module.ts

    import { Module } from '@nestjs/common';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { AuthModule } from './auth/auth.module';
    import { CatApiModule } from './cat-api/cat-api.module';
    import { JobModule } from './job/job.module';
    import { WebappModule } from './webapp/webapp.module';
    import { KeycloakModule } from './keycloak/keycloak.module';
    
    @Module({
      imports: [AuthModule, CatApiModule, JobModule, WebappModule, KeycloakModule],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}
    

    main.ts

    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      app.enableCors(); // Enables CORS for all routes
      await app.listen(3000);
    }
    bootstrap();
    

    Install Dependencies

    npm install axios jsonwebtoken reflect-metadata rxjs
    

    Run it

    npm run start
    
    

    enter image description here

    Result

    Get token first by Postman enter image description here

    enter image description here

    Get Call with token.

    GET http://localhost:3000/facts/cats
    

    The response body data get from this URL'data

    'https://catfact.ninja/breeds?limit=1'
    

    getCatFactsWithAxiosLib() at catApi.service.ts file Forwarding by this code.

    @Controller('facts')
    export class CatApiController {
      constructor(private catApiService: CatApiService) {}
    
      @Get('cats')
      @UseGuards(KeycloakAuthGuard)
      async getCatFacts() {
        return this.catApiService.getCatFactsWithAxiosLib();
      }
    }
    

    enter image description here


    JWKS (JSON Web Key Set)

    Retrieve JWKS URI: The code accesses the JWKS (JSON Web Key Set) URI, a standard URL provided by the authentication server (like Keycloak), which contains the public keys.

    Fetch Public Keys: It then makes an HTTP request to this URI to fetch the set of public keys, one of which will be used to verify the JWT token's signature.

    http://localhost:8080/realms/master/protocol/openid-connect/certs
    
    {
      "issuer": "http://localhost:8080/realms/my-nestjs-app",
      "authorization_endpoint": "http://localhost:8080/realms/my-nestjs-app/protocol/openid-connect/auth",
      "token_endpoint": "http://localhost:8080/realms/my-nestjs-app/protocol/openid-connect/token",
      "introspection_endpoint": "http://localhost:8080/realms/my-nestjs-app/protocol/openid-connect/token/introspect",
      "userinfo_endpoint": "http://localhost:8080/realms/my-nestjs-app/protocol/openid-connect/userinfo",
      "end_session_endpoint": "http://localhost:8080/realms/my-nestjs-app/protocol/openid-connect/logout",
      "frontchannel_logout_session_supported": true,
      "frontchannel_logout_supported": true,
      "jwks_uri": "http://localhost:8080/realms/my-nestjs-app/protocol/openid-connect/certs",
      "check_session_iframe": "http://localhost:8080/realms/my-nestjs-app/protocol/openid-connect/login-status-iframe.html",
      "grant_types_supported": [
        "authorization_code",
        "implicit",
        "refresh_token",
        "password",
        "client_credentials",
        "urn:openid:params:grant-type:ciba",
        "urn:ietf:params:oauth:grant-type:device_code"
      ],
    // cut-off
    

    Define Certificate Boundaries: The code defines the starting (pemStart) and ending (pemEnd) boundaries of a PEM formatted certificate.

    Extract Key: It extracts the first certificate (x5c[0]) from the signingKey object, which is part of the JSON Web Key Set (JWKS) retrieved from the authentication server.

    Create PEM Format: These components are concatenated to create a full PEM-formatted public key (pem), which is used for verifying JWT signatures.

    const pemStart = '-----BEGIN CERTIFICATE-----\n';
    const pemEnd = '\n-----END CERTIFICATE-----';
    const pem = pemStart + signingKey.x5c[0] + pemEnd;