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
- 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
- SSO Session Idle: 4 hours (recommended)
- 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
- 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.
- Select realm -> Configure -> Realm Settings
- Client policies tab
- Policies tab
- Create client policy -> Save
- Add Condition
- Condition Type: client-access-type
- Client Access Type: confidential
- 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.
- 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
npm i keycloak-connector-server @fastify/static
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.
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.';
});
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}`);
});
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');
});
Note
Due to how Express handles middleware, alock
does not work in parallel or override a previouslock
. 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);
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]];
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],
}
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],
}
}
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],
];
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>;
}
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,
}
- GET - 307 redirect to initiate login request
- [all other METHODs] - 401 unauthorized
NODE_KEYCLOAK_CONNECTOR_LOGIN_COOKIE_TIMEOUT
Defaults to 30 minutes
- 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