Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@ovotech/laminar-jwt

Package Overview
Dependencies
Maintainers
36
Versions
31
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ovotech/laminar-jwt

A json web token middleware for laminar

  • 0.13.19
  • latest
  • npm
  • Socket score

Version published
Weekly downloads
309
decreased by-26.6%
Maintainers
36
Weekly downloads
 
Created
Source

Laminar JSON Web Tokens

A json web token middleware for laminar

Usage

examples/simple.ts

import { get, post, init, HttpService, jsonOk, router, HttpListener } from '@ovotech/laminar';
import { authMiddleware, createSession } from '@ovotech/laminar-jwt';

const secret = '123';
const auth = authMiddleware({ secret });

// A middleware that would actually restrict access
const loggedIn = auth();
const admin = auth(['admin']);

const listener: HttpListener = router(
  get('/.well-known/health-check', async () => jsonOk({ health: 'ok' })),
  post('/session', async ({ body }) => jsonOk(createSession({ secret }, body))),
  post(
    '/test',
    admin(async ({ authInfo }) => jsonOk({ result: 'ok', user: authInfo })),
  ),
  get(
    '/test',
    loggedIn(async () => jsonOk('index')),
  ),
);

const http = new HttpService({ listener });

init({ initOrder: [http], logger: console });

Usage with oapi

If we had this basic oapi.yaml

examples/oapi.yaml

---
openapi: 3.0.0
info:
  title: Test
  version: 1.0.0
servers:
  - url: http://localhost:3333
paths:
  '/session':
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/User' }
      responses:
        '200':
          description: A session object
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
  '/test':
    post:
      security:
        # Using the JWTSecurity with admin scopes
        - JWTSecurity: ['admin']
      responses:
        '200':
          description: A Test Object
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Test' }
    get:
      security:
        # Using the JWTSecurity with no scopes
        - JWTSecurity: []
      responses:
        '200':
          description: A Test Object
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Test' }

components:
  securitySchemes:
    # Defining the JWTSecurity schema to be used on the routes
    JWTSecurity:
      type: http
      scheme: bearer

  schemas:
    Session:
      additionalProperties: false
      properties:
        jwt:
          type: string
        user:
          $ref: '#/components/schemas/User'
      required:
        - jwt
        - user
    User:
      properties:
        email:
          type: string
        scopes:
          type: array
          items:
            type: string
      required:
        - email

    Test:
      properties:
        text:
          type: string
        user:
          $ref: '#/components/schemas/User'
      required:
        - text

And then implement it using the helper jwtSecurityResolver. That function would return a securityOk object if the jwt was validated, with the contents of the jwt, or a 403 error response.

examples/oapi.ts

import { HttpService, init, jsonOk, openApi } from '@ovotech/laminar';
import { createSession, jwtSecurityResolver } from '@ovotech/laminar-jwt';
import { join } from 'path';

const main = async () => {
  const secret = '123';
  const listener = await openApi({
    api: join(__dirname, 'oapi.yaml'),
    security: { JWTSecurity: jwtSecurityResolver({ secret }) },
    paths: {
      '/session': {
        post: async ({ body }) => jsonOk(createSession({ secret }, body)),
      },
      '/test': {
        get: async ({ authInfo }) => jsonOk({ text: 'ok', user: authInfo }),
        post: async ({ authInfo }) => jsonOk({ text: 'ok', user: authInfo }),
      },
    },
  });
  const http = new HttpService({ listener });
  await init({ initOrder: [http], logger: console });
};

main();

If you need the old school but still awesome cookie security, OpenAPI can handle that too - docs for cookie auth with OpenAPI. You can use the "apiKey" security to define it.

examples/oapi-api-key.yaml

---
openapi: 3.0.0
info:
  title: Test
  version: 1.0.0
servers:
  - url: http://localhost:3333
paths:
  '/session':
    post:
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/User' }
      responses:
        '200':
          description: A session object
          content:
            text/plain:
              schema: { $ref: '#/components/schemas/Text' }
          headers:
            Set-Cookie:
              schema:
                type: string
                example: auth=abcde12345; Path=/; HttpOnly
  '/test':
    post:
      description: Protected by CookieSecurity, no scopes
      security:
        - CookieSecurity: []
      responses:
        '200':
          description: A Test Object
          content:
            text/plain:
              schema: { $ref: '#/components/schemas/Text' }
    get:
      description: Protected by CookieSecurity, no scopes
      security:
        - CookieSecurity: []
      responses:
        '200':
          description: A Test Object
          content:
            text/plain:
              schema: { $ref: '#/components/schemas/Text' }

components:
  securitySchemes:
    CookieSecurity:
      description: Security using the `auth` cookie. To be used in the routes.
      type: apiKey
      in: cookie
      name: auth

  schemas:
    User:
      properties:
        email:
          type: string
      required:
        - email
    Text:
      type: string

Implementing it involves reading the cookie and validating its contents.

examples/oapi-api-key.ts

import { HttpService, init, openApi, textOk, setCookie } from '@ovotech/laminar';
import { createSession, verifyToken } from '@ovotech/laminar-jwt';
import { join } from 'path';

const main = async () => {
  const secret = '123';
  const listener = await openApi({
    api: join(__dirname, 'oapi-api-key.yaml'),
    security: {
      /**
       * Implement cookie security.
       */
      CookieSecurity: ({ cookies, scopes }) => verifyToken({ secret }, cookies?.auth, scopes),
    },
    paths: {
      '/session': {
        post: async ({ body }) => setCookie({ auth: createSession({ secret }, body).jwt }, textOk('Cookie Set')),
      },
      '/test': {
        get: async () => textOk('OK'),
        post: async ({ authInfo }) => textOk(`OK ${authInfo.email}`),
      },
    },
  });
  const http = new HttpService({ listener });
  await init({ initOrder: [http], logger: console });
};

main();

Custom security resolvers

OpenApi supports more security methods, and they can be implemented with a security resolver. Since a security resolver is just a function that gets request properties and returns either securityOk or a Response object, we can do a lot of custom things.

examples/oapi-custom.yaml

---
openapi: 3.0.0
info:
  title: Test
  version: 1.0.0
servers:
  - url: http://localhost:3333
paths:
  '/session':
    post:
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/User' }
      responses:
        '200':
          description: A session object
          content:
            text/plain:
              schema: { $ref: '#/components/schemas/Text' }
          headers:
            Set-Cookie:
              schema:
                type: string
                example: auth=abcde12345; Path=/; HttpOnly
  '/test':
    post:
      description: Either CookieSecurity or CloudSchedulerSecurity should match, no scopes
      security:
        - CookieSecurity: []
        - CloudSchedulerSecurity: []
      responses:
        '200':
          description: A Test Object
          content:
            text/plain:
              schema: { $ref: '#/components/schemas/Text' }
    get:
      description: Only CookieSecurity can be used for this route, no scopes, no scopes
      security:
        - CookieSecurity: []
      responses:
        '200':
          description: A Test Object
          content:
            text/plain:
              schema: { $ref: '#/components/schemas/Text' }
  '/unauthorized':
    get:
      responses:
        '403':
          description: Forbidden
          content:
            text/plain:
              schema: { $ref: '#/components/schemas/Text' }

components:
  securitySchemes:
    CookieSecurity:
      description: Security using the `auth` cookie. To be used in the routes.
      type: apiKey
      in: cookie
      name: auth
    CloudSchedulerSecurity:
      description: Security using the `X-CloudScheduler` header. To be used in the routes.
      type: apiKey
      in: header
      name: 'x-cloudscheduler'

  schemas:
    User:
      properties:
        email:
          type: string
      required:
        - email
    Text:
      type: string

examples/oapi-custom.ts

import {
  HttpService,
  init,
  openApi,
  securityRedirect,
  isSecurityOk,
  securityOk,
  textOk,
  textForbidden,
  setCookie,
  securityError,
} from '@ovotech/laminar';
import { createSession, verifyToken } from '@ovotech/laminar-jwt';
import { join } from 'path';

const main = async () => {
  const secret = '123';
  const listener = await openApi({
    api: join(__dirname, 'oapi-custom.yaml'),
    security: {
      /**
       * Implement additional cookie security.
       * In an event of a failure, we'd want to redirect to an error page, instead of returning a 403 response
       */
      CookieSecurity: async ({ cookies, scopes }) => {
        const result = await verifyToken({ secret }, cookies?.auth, scopes);
        return isSecurityOk(result) ? result : securityRedirect('/unauthorized', { message: 'Redirect', location });
      },
      /**
       * Cloud Scheduler would ensure that this header is never sent outside of the app engine environment,
       * so we're safe just checking for the existance of the header.
       */
      CloudSchedulerSecurity: ({ headers }) =>
        headers['x-cloudscheduler'] ? securityOk({}) : securityError({ message: 'Not Cloud Scheduler Job' }),
    },
    paths: {
      '/session': {
        post: async ({ body }) => setCookie({ auth: createSession({ secret }, body).jwt }, textOk('Cookie Set')),
      },
      '/test': {
        get: async () => textOk('OK'),
        post: async ({ authInfo }) => textOk(`OK ${authInfo.email}`),
      },
      '/unauthorized': { get: async () => textForbidden('Forbidden!') },
    },
  });
  const http = new HttpService({ listener });
  await init({ initOrder: [http], logger: console });
};

main();

Public / Private keys

You can specify public / private key pair (where the private key is used for signing and the public for verifying)

examples/keypair.ts

import { get, post, HttpService, router, init, jsonOk, HttpListener } from '@ovotech/laminar';
import { authMiddleware, createSession } from '@ovotech/laminar-jwt';
import { readFileSync } from 'fs';
import { join } from 'path';

const publicKey = readFileSync(join(__dirname, './public-key.pem'), 'utf8');
const privateKey = readFileSync(join(__dirname, './private-key.pem'), 'utf8');

// This middleware would only add security related functions to the context, without restricting any access
// You can specify public and private keys, as well as verify options
// to be passed down to the underlying jsonwebtoken package
const auth = authMiddleware({ secret: publicKey, options: { clockTolerance: 2 } });

// A middleware that would actually restrict access
const onlyLoggedIn = auth();
const onlyAdmin = auth(['admin']);

const listener: HttpListener = router(
  get('/.well-known/health-check', async () => jsonOk({ health: 'ok' })),
  post('/session', async ({ body }) =>
    jsonOk(createSession({ secret: privateKey, options: { algorithm: 'RS256' } }, body)),
  ),
  post(
    '/test',
    onlyAdmin(async ({ authInfo }) => jsonOk({ result: 'ok', user: authInfo })),
  ),
  get(
    '/test',
    onlyLoggedIn(async () => jsonOk('index')),
  ),
);

const http = new HttpService({ listener });
init({ initOrder: [http], logger: console });

JWK for public keys

JWK are also supported with the jwkPublicKey function. It can also cache the jwk request.

examples/jwk.ts

import { get, post, init, router, HttpService, jsonOk } from '@ovotech/laminar';
import { jwkPublicKey, createSession, authMiddleware } from '@ovotech/laminar-jwt';
import { readFileSync } from 'fs';
import { join } from 'path';
import nock from 'nock';

/**
 * Make sure we have some response from a url
 */
const jwkFile = JSON.parse(readFileSync(join(__dirname, './jwk.json'), 'utf8'));
nock('http://example.com/').get('/jwk.json').reply(200, jwkFile);

/**
 * The public key is now a function that would attempt to retrieve the jwk from a url
 * You can also cache it or specify the max age, which by default is 0 and would never expire.
 */
const publicKey = jwkPublicKey({ uri: 'http://example.com/jwk.json', cache: true });
const privateKey = readFileSync(join(__dirname, './private-key.pem'), 'utf8');

const signOptions = {
  secret: privateKey,
  options: { algorithm: 'RS256' as const, keyid: jwkFile.keys[0].kid },
};
const verifyOptions = { secret: publicKey };

const auth = authMiddleware(verifyOptions);

// A middleware that would actually restrict access
const loggedIn = auth();
const admin = auth(['admin']);

const http = new HttpService({
  listener: router(
    get('/.well-known/health-check', async () => jsonOk({ health: 'ok' })),
    post('/session', async ({ body }) => jsonOk(createSession(signOptions, body))),
    post(
      '/test',
      admin(async ({ authInfo }) => jsonOk({ result: 'ok', user: authInfo })),
    ),
    get(
      '/test',
      loggedIn(async () => jsonOk('index')),
    ),
  ),
});

init({ initOrder: [http], logger: console });

You can test it by running (requires curl and jq):

examples/jwk.sh

JWT=`curl --silent --request POST 'http://localhost:3333/session' --header 'Content-Type: application/json' --data '{"email":"test@example.com","scopes":["admin"]}' | jq '.jwt' -r`
curl --request POST --header "Authorization: Bearer ${JWT}" http://localhost:3333/test

Keycloak and custom scope validators

In order to use keycloak as public / private pair you'll need to provide a custom function that will validate the required scopes against the data comming from the keycloak token.

If we had a keycloak config like this:

examples/keycloak-config.yaml

my-service-name:
  defineRoles:
    - admin

other-client-service:
  serviceAccountRoles:
    - admin

Then we could implement it with this service:

examples/keycloak.ts

import { get, post, HttpService, router, init, jsonOk } from '@ovotech/laminar';
import { jwkPublicKey, createSession, keycloakAuthMiddleware } from '@ovotech/laminar-jwt';
import { readFileSync } from 'fs';
import { join } from 'path';
import nock from 'nock';

/**
 * Make sure we have some response from a url
 */
const jwkFile = readFileSync(join(__dirname, './jwk.json'), 'utf8');
nock('http://example.com/').get('/jwk.json').reply(200, JSON.parse(jwkFile));

/**
 * The public key is now a function that would attempt to retrieve the jwk from a url
 * You can also cache it or specify the max age, which by default is 0 and would never expire.
 */
const publicKey = jwkPublicKey({ uri: 'http://example.com/jwk.json', cache: true });
const privateKey = readFileSync(join(__dirname, './private-key.pem'), 'utf8');

const keyid = JSON.parse(jwkFile).keys[0].kid;
const sessionOptions = { secret: privateKey, options: { algorithm: 'RS256' as const, keyid } };

const auth = keycloakAuthMiddleware({ secret: publicKey, service: 'my-service-name' });

// A middleware that would actually restrict access
const loggedIn = auth();
const admin = auth(['admin']);

const http = new HttpService({
  listener: router(
    get('/.well-known/health-check', async () => jsonOk({ health: 'ok' })),
    post('/session', async ({ body }) => jsonOk(createSession(sessionOptions, body))),
    post(
      '/test',
      admin(async ({ authInfo }) => jsonOk({ result: 'ok', user: authInfo })),
    ),
    get(
      '/test',
      loggedIn(async () => jsonOk('index')),
    ),
  ),
});
init({ initOrder: [http], logger: console });

When this is running, you can test it with calls like this (requires curl and jq):

examples/keycloak.sh

JWT=`curl --silent --request POST 'http://localhost:3333/session' --header 'Content-Type: application/json' --data '{"clientId":"test","resource_access":{"my-service-name":{"roles":["admin"]}}}' | jq '.jwt' -r`
curl --request POST --header "Authorization: Bearer ${JWT}" http://localhost:3333/test

With oapi it is the same concempt - we use the scopes that are defined by the open api standard to check against the values from the keycloack resource access value:

examples/oapi-keycloak.ts

import { init, HttpService, jsonOk, openApi } from '@ovotech/laminar';
import { jwkPublicKey, keycloakJwtSecurityResolver, createSession } from '@ovotech/laminar-jwt';
import { join } from 'path';
import { readFileSync } from 'fs';
import nock from 'nock';

/**
 * Make sure we have some response from a url
 */
const jwkFile = readFileSync(join(__dirname, './jwk.json'), 'utf8');
nock('http://example.com/').get('/jwk.json').reply(200, JSON.parse(jwkFile));

/**
 * The public key is now a function that would attempt to retrieve the jwk from a url
 * You can also cache it or specify the max age, which by default is 0 and would never expire.
 */
const publicKey = jwkPublicKey({ uri: 'http://example.com/jwk.json', cache: true });
const privateKey = readFileSync(join(__dirname, './private-key.pem'), 'utf8');

const keyid = JSON.parse(jwkFile).keys[0].kid;
const jwtSign = { secret: privateKey, options: { algorithm: 'RS256' as const, keyid } };

const main = async () => {
  const jwtSecurity = keycloakJwtSecurityResolver({
    secret: publicKey,
    service: 'my-service-name',
  });

  const listener = await openApi({
    api: join(__dirname, 'oapi.yaml'),
    security: { JWTSecurity: jwtSecurity },
    paths: {
      '/session': {
        post: async ({ body }) => jsonOk(createSession(jwtSign, body)),
      },
      '/test': {
        get: async ({ authInfo }) => jsonOk({ text: 'ok', user: authInfo }),
        post: async ({ authInfo }) => jsonOk({ text: 'ok', user: authInfo }),
      },
    },
  });
  const http = new HttpService({ listener });
  await init({ initOrder: [http], logger: console });
};

main();

When this is running, this can be again test with (requires curl and jq):

examples/oapi-keycloak.sh

JWT=`curl --silent --request POST 'http://localhost:3333/session' --header 'Content-Type: application/json' --data '{"clientId":"test","resource_access":{"my-service-name":{"roles":["admin"]}}}' | jq '.jwt' -r`
curl --request POST --header "Authorization: Bearer ${JWT}" http://localhost:3333/test

Docs

JWTContext provides two functions: createSession(user, options?) and verifyAuthorization(header?: string, scopes?: string[]).

type createSession = (user: TUser, options?: SignOptions) => Session<TUser>;

User has email and scopes(optional) fields but can have other keys as well. options is passed directly to jsonwebtoken package sign function options https://github.com/auth0/node-jsonwebtoken#usage. With it you can for example set expiresIn or notBefore.

type verifyAuthorization = (header?: string, scopes?: string[]) => JWTData;

Verifies if an authorization header is valid. Must be in the form of Bearer {jwt token}. Additionally would check it against scopes if provided.

Running the tests

You can run the tests with:

yarn test

Coding style (linting, etc) tests

Style is maintained with prettier and eslint

yarn lint

Deployment

Deployment is preferment by yarn automatically on merge / push to main, but you'll need to bump the package version numbers yourself. Only updated packages with newer versions will be pushed to the npm registry.

Contributing

Have a bug? File an issue with a simple example that reproduces this so we can take a look & confirm.

Want to make a change? Submit a PR, explain why it's useful, and make sure you've updated the docs (this file) and the tests (see test folder).

License

This project is licensed under Apache 2 - see the LICENSE file for details

FAQs

Package last updated on 08 Nov 2023

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc