@dapperduckling/keycloak-connector-server
TypeScript icon, indicating that this package has built-in type declarations

2.5.5 • Public • Published

keycloak-connector-server

Description

Keycloak Connector Server is an opinionated utility library built to ease integration of keycloak into existing nodejs Express or Fastify servers following the FAPI Security Profile 1.0 (baseline & advanced).

Simple Keycloak connector for Node.js projects using Fastify or Express

Configuring and securing Keycloak

Configure Realm Sessions/Tokens

  1. Realm Settings -> Sessions
    • SSO Session Idle: 4 hours (recommended)
      • obtaining new access tokens with a refresh token resets this clock
    • SSO Session Max: 1 day (recommended)
      • regardless of a user's activity, their sessions will end at the max time
    • The rest can be as desired or blank/zero to inherit
  2. Realm Settings -> Tokens
    • Default signature algo: PS256
    • Revoke refresh token: Enabled
    • Refresh token max reuse: 0
    • Access token lifespan: 15 minutes (recommended)
      • this must be shorter than SSO session idle timeout

Configure Client

  1. Select client -> Advanced -> (change 5 settings)
    • Access token signature algorithm: PS256
    • ID token signature algorithm: PS256
    • User info signed response algorithm: PS256
    • Request object signature algorithm: PS256
    • Authorization response signature algorithm: PS256

It is imperative to enable fapi-1-baseline and fapi-1-advanced client profiles to ensure complete FAPI compliance.

Secure Client: Activate Client Profiles

  1. Select realm -> Configure -> Realm Settings
  2. Client policies tab
  3. Policies tab
  4. Create client policy -> Save
  5. Add Condition
    • Condition Type: client-access-type
    • Client Access Type: confidential
  6. Add Client Profile
    • fapi-1-baseline
    • fapi-1-advanced

Once complete, navigate to any client's settings page and hit save. Fix any save errors that are a result of the new policy.

Final step: Disable mTLS via OAuth 2.0 Mutual TLS Certificate Bound Access Tokens Enabled option on the Advanced tab.

Getting started with Fastify

Required packages

Recommended Keycloak settings

Disable or remove default scopes

  • Manage -> Client Scopes -> (any scope)
  • Either
    • A) Change assigned type to "Optional"
    • B) Remove from access token
      • Click scope name -> Mappers -> Select Mapper
      • Disable add to ID token (if able)
      • Disable add to access token

Install (for Fastify)

npm i keycloak-connector-server @fastify/static

Setup the server

import Fastify from 'fastify';
import {keycloakConnectorFastify} from 'keycloak-connector';

// Configure fastify
const fastify = Fastify({
    pluginTimeout: 120000, // Recommended to allow for up to two minutes 
});

// Initialize the connector
fastify.register(keycloakConnectorFastify, {
    authServerOrigin: 'http://localhost:8080',
    realm: 'the-sky',
    clientId: 'tactical-airlift',
    pinoLogger: fastify.log
});

// Start the server
try {
    await fastify.listen({port: 3000, host: '127.0.0.1'});
} catch (err) {
    fastify.log.error(err);
    process.exit(1);
}

By default, once the keycloakConnectorFastify plugin is registered, all unauthenticated requests are blocked.

Add routes

import {RoleLocations} from "keycloak-connector-server";

// Public route
fastify.get('/', {config: {public: true}}, async (request, reply) => {
    return 'I am publicly accessible, no login needed.';
});

// Default non-public route
fastify.get('/not-public', async (request, reply) => {
    return 'I am not public, but I do not require any particular roles to access.';
});

// Shorthand route configuration
fastify.get('/cool-person', {config: {roles: ['COOL_PERSON', 'NICE_PERSON']}}, async (request, reply) => {
    return 'I am not public and I require either the `cool_person` or `nice_person` role granted under this keycloak client to access.';
});

// Extended route configuration
fastify.get('/cool-person', {config: {roles: {[RoleLocations.REALM_ACCESS]: ['realm_lead']}}}, async (request, reply) => {
    return 'I am not public and I require the `realm_lead` role granted under the current keycloak realm to access.';
});

Getting started with Express

Required packages

Setup the server

import express from 'express';
import {keycloakConnectorExpress} from "keycloak-connector-server";
import cookieParser from "cookie-parser";
import logger from "pino-http"; // Optional, see below

// Grab express app
const app = express();

// Register the cookie parser
app.use(cookieParser());

// Initialize the keycloak connector
const lock = await keycloakConnectorExpress(app, {
    serverOrigin: 'http://localhost:3005',
    authServerUrl: 'http://localhost:8080/',
    realm: 'local-dev',
    refreshConfigMins: -1, // Disable for dev testing
    pinoLogger: logger().logger, // Optional, but without pinologger, log messages are supressed (ie. error, warn, etc...)
});

// Start server
const port = 5000;
app.listen(port, () => {
    console.log(`I'm alive on ${port}`);
});

Add routes

import {RoleLocations} from "keycloak-connector-server";

// Public route (default)
app.get('/', (req, res) => {
    // Send the response
    res.send('hey!');
});

// Non-public route
app.get('/not-public', lock(), (req, res) => {
    // Send the response
    res.send('hey, but hidden behind login!');
});

// Shorthand route configuration
app.get('/wow', lock(['cool_person', 'nice_person']), (req, res) => {
    // Send the response
    res.send('hey, but you have to have either the `cool_person` or `nice_person` roles');
});

// Extended route configuration
app.get('/wow', lock({roles: {[RoleLocations.REALM_ACCESS]: ['realm_lead']}}), (req, res) => {
    // Send the response
    res.send('hey, but you have to have the `realm_lead` role for this realm');
});

Restricting an entire router

Note
Due to how Express handles middleware, a lock does not work in parallel or override a previous lock. Instead, they each stack on each other, making a route more restrictive.

const router = express.Router();

// Lock all routes in this router behind a login page
// (must place before declaring any other routes for it to be effective)
router.use(lock());

// Public route  ***will not work since entire router is locked!
router.get('/', lock(false), (req, res) => {
    // Send the response
    res.send('hey!');
});

// Non-public route (default)
router.get('/not-public', (req, res) => {
    // Send the response
    res.send('hey, but hidden behind login!');
});

app.use(router);

Specifying Role Requirements

RoleRules (simple)

When passed a simple array, keycloak-connector-server will interpret this as a list of roles required for the current client (overridable by configuring defaultResourceAccessKey). Roles assumed to be logically OR'd unless wrapped inside an inner array where those roles are logically AND'd.

// A user must either have the `nice_person` role OR have both the `mean_person` AND `has_counselor` roles
const requiredRoles = ['nice_person', ['mean_person', 'has_counselor']];

/** Typescript Example */
import {RoleRules} from "keycloak-connector-server";
enum Roles {
    nice_person = "nice_person",
    mean_person = "mean_person",
    has_counselor = "has_counselor"
}
const requiredRolesTs: RoleRules<Roles> = [Roles.nice_person, [Roles.mean_person, Roles.has_counselor]];

ClientRole

Used when requiring roles from a client other than the current (or as configured withdefaultResourceAccessKey) client. Each client is logically AND'd together.

// A user must have either `eat_toast` OR `eat_bread` for `other_client` AND ALSO have the `make_bread` role for `random_client`
const requiredRoles = {
    other_client: ['eat_toast', 'eat_bread'],
    random_client: ['make_bread'],
}

/** Typescript Example */
import {ClientRole} from "keycloak-connector-server";
type CombinedRoles = OtherClientRoles | RandomClientRoles;
enum OtherClientRoles {
    eat_toast = "eat_toast",
    eat_bread = "eat_bread",
}
enum RandomClientRoles {
    make_bread = "make_bread"
}
enum Clients {
    other_client = "other_client",
    random_client = "random_client"
}
const requiredRolesTs: ClientRole<Clients, CombinedRoles> = {
    [Clients.other_client]: [OtherClientRoles.eat_toast, OtherClientRoles.eat_bread],
    [Clients.random_client]: [RandomClientRoles.make_bread],
}

RoleLocation

Used when requiring roles from the realm. Can be used in combination with requiring client roles.

// A user requires ALL of the following:
//  - The `buy_house` realm role
//  - Either the `eat_toast` or `eat_bread` role for `other_client`
//  - The `make_bread` role for `random_client`
const requiredRoles = {
    REALM_ACCESS: ['buy_house'],
    RESOURCE_ACCESS: {
        other_client: ['eat_toast', 'eat_bread'],
        random_client: ['make_bread'],
    }
}

/** Typescript Example */
import {RoleLocation} from "keycloak-connector-server";
type CombinedRoles = RealmRoles | OtherClientRoles | RandomClientRoles;
enum OtherClientRoles {
    eat_toast = "eat_toast",
    eat_bread = "eat_bread",
}
enum RandomClientRoles {
    make_bread = "make_bread"
}
enum Clients {
    other_client = "other_client",
    random_client = "random_client"
}
enum RealmRoles {
    buy_house = "buy_house"
}
const requiredRolesTs: RoleLocation<CombinedRoles, Clients> = {
    [RoleLocations.REALM_ACCESS]: [RealmRoles.buy_house],
    [RoleLocations.RESOURCE_ACCESS]: {
        [Clients.other_client]: [OtherClientRoles.eat_toast, OtherClientRoles.eat_bread],
        [Clients.random_client]: [RandomClientRoles.make_bread],
    }
}

Array of CombinedRoleRules

Used for situations where multiple complex rules must be OR'd together.

// A user must meet ANY ONE of the following requirements:
//  - Have either `eat_toast` OR `eat_bread` for `other_client` AND ALSO have the `make_bread` role for `random_client`
//  - Have the `pizza_person` role for the current client
const requiredRoles = [
    {
        other_client: ['eat_toast', 'eat_bread'],
        random_client: ['make_bread'],
    },
    ['pizza_person'],
];

// Typescript example
import {CombinedRoles} from "keycloak-connector-server";
enum Clients {
    other_client = "other_client",
    random_client = "random_client"
}
type CombinedRoles = OtherClientRoles | RandomClientRoles | CurrentClientRoles;
enum OtherClientRoles {
    eat_toast = "eat_toast",
    eat_bread = "eat_bread",
}
enum RandomClientRoles {
    make_bread = "make_bread"
}
enum CurrentClientRoles {
    pizza_person = "pizza_person"
}
const requiredRolesTs: RequiredRoles<CombinedRoles, Clients> = [
    {
        [Clients.other_client]: [OtherClientRoles.eat_toast, OtherClientRoles.eat_bread],
        [Clients.random_client]: [RandomClientRoles.make_bread],
    },
    [CurrentClientRoles.pizza_person],
];

Advanced Configuration

KeycloakConnector

export interface KeycloakConnectorConfiguration {
    /** The RP server origin */
    serverOrigin: string;

    /** The OP server url */
    authServerUrl: string;

    /** The OP realm to use */
    realm: string;

    /** The RP client data */
    oidcClientMetadata: ClientMetadata;

    /** TLDR; KC versions < 18 have the /auth _prefix in the url */
    keycloakVersionBelow18?: boolean;

    /** How often should we ping the OP for an updated oidc configuration */
    refreshConfigMins?: number;

    /** Pino logger reference */
    pinoLogger?: Logger;

    /** Custom oidc discovery url */
    oidcDiscoveryUrlOverride?: string;

    /** Determines where the client will store a user's oauth token information */
    stateType?: StateOptions

    /**
     *  How long until the initial login sequence cookie expires. Shorter times may impact users who may take a while
     *  to finish logging in.
     */
    authCookieTimeout: number;

    /** Overrides the default routes created to handle keycloak interactions */
    routePaths?: CustomRouteUrl;

    /** Overrides the default configuration for all routes */
    globalRouteConfig?: KeycloakRouteConfig;

    /**
     * When a role rule doesn't specify a specific client, the default is to use the current `client_id` when
     * searching through the `resource_access` key of the JWT for required roles. Overridable here.
     */
    defaultResourceAccessKey?: string;

    /** When true, a case-sensitive search is used to match requirements to user's roles */
    caseSensitiveRoleCheck?: boolean;

    /** Optional claims required when verifying user-provided JWTs */
    jwtClaims?: {
        /** Require the user-provided JWT to be intended for a particular audience */
        audience?: string;

        /** Ensures the party to which the JWT was issued matches provided value. By default, azp must match the current `client_id` */
        azp?: string | AzpOptions;
    }

    /** Allows you to specify a built-in or pass a custom key provider */
    keyProvider?: ClassConstructor<AbstractKeyProvider>;
}

Config Types

export enum StateOptions {
    STATELESS = 0,
    MIXED = 1,
    STATEFUL = 2,
}

export type CustomRouteUrl = {
    _prefix?: string;
    loginPage?: string;
    loginPost?: string;
    loginListener?: string;
    logoutPage?: string;
    logoutPost?: string;
    callback?: string;
    logoutCallback?: string;
    publicKeys?: string;
    adminUrl?: string;
    backChannelLogout?: string;
    userStatus?: string;
    publicDir?: string;
}

export type KeycloakRouteConfig = {
    public: true,
    autoRedirect?: boolean,
} | {
    public?: false,
    roles: RequiredRoles,
    autoRedirect?: boolean,
}

export enum AzpOptions {
    MUST_MATCH_CLIENT_ID = 0,
    MATCH_CLIENT_ID_IF_PRESENT = 1,
    IGNORE = 2,
}
Default unauthenticated requests handling
  • GET - 307 redirect to initiate login request
  • [all other METHODs] - 401 unauthorized

Environmental variables

NODE_KEYCLOAK_CONNECTOR_LOGIN_COOKIE_TIMEOUT Defaults to 30 minutes

Security Considerations

  • State-less
    • Refresh token may be susceptible to DOS attack where a large payload is passed from the end-user to the client and the client passes to the OP during an automatic access token refresh

Package Sidebar

Install

npm i @dapperduckling/keycloak-connector-server

Weekly Downloads

180

Version

2.5.5

License

AGPL-3.0-only

Unpacked Size

732 kB

Total Files

234

Last publish

Collaborators

  • duckboy81