Skip to content

Commit

Permalink
Keycloak extract
Browse files Browse the repository at this point in the history
  • Loading branch information
pozylon committed Jan 5, 2025
1 parent 91f0193 commit 47b4ee4
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 143 deletions.
2 changes: 2 additions & 0 deletions examples/keycloak/.env.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ UNCHAINED_COOKIE_INSECURE=
UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET=secret
REDIS_DB=0
MONGOMS_VERSION=8.0.1
UNCHAINED_KEYCLOAK_CLIENT_SECRET=NACOQslmea4kTki7SSKR4HTbAY91eRPe
UNCHAINED_KEYCLOAK_REALM_URL=http://localhost:8080/realms/master
149 changes: 6 additions & 143 deletions examples/keycloak/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,8 @@ import connectBasePluginsToFastify from '@unchainedshop/plugins/presets/base-fas
import { connect } from '@unchainedshop/api/lib/fastify/index.js';
import { createLogger } from '@unchainedshop/logger';
import seed from './seed.js';
import Fastify, { FastifyInstance, FastifyRequest } from 'fastify';
import FastifyOAuth2 from '@fastify/oauth2';
import { Context, UnchainedContextResolver, API_EVENTS } from '@unchainedshop/api';
import fastifyCookie from '@fastify/cookie';
import { emit } from '@unchainedshop/events';
import jwt from 'jsonwebtoken';
import Fastify from 'fastify';
import setupKeycloak from 'keycloak.js';

const logger = createLogger('keycloak');

Expand All @@ -32,23 +28,6 @@ const app = Fastify({
trustProxy: true,
});

// It's very important to await this, else the fastify-session plugin will not work
await app.register(fastifyCookie);

app.register(FastifyOAuth2, {
name: 'keycloak',
credentials: {
client: {
id: 'unchained-local',
secret: 'NACOQslmea4kTki7SSKR4HTbAY91eRPe',
},
},
startRedirectPath: '/login',
scope: ['profile', 'email', 'openid'],
callbackUri: 'http://localhost:4010/login/keycloak/callback',
discovery: { issuer: 'http://localhost:8080/realms/master' },
});

// Workaround: Allow to use sandbox with localhost
app.addHook('preHandler', async function (request) {
request.headers['x-forwarded-proto'] = 'https';
Expand All @@ -60,130 +39,14 @@ app.addHook('onSend', async function (_, reply) {
});
});

// It's very important to await this, else the fastify-session plugin will not work
const context = await setupKeycloak(app);

const engine = await startPlatform({
modules: baseModules,
context: (contextResolver: UnchainedContextResolver) => async (props, req) => {
// eslint-disable-next-line
const keycloakInstance = (app as any).keycloak as FastifyOAuth2.OAuth2Namespace;
const context = await contextResolver(props);
if (context.user || !req.session.keycloak) return context;
try {
const isExpired = new Date(req.session.keycloak.expires_at) < new Date();
if (isExpired) {
req.session.keycloak = (
await keycloakInstance.getNewAccessTokenUsingRefreshToken(req.session.keycloak, {})
).token;
}

const {
sub,
resource_access,
}: {
sub: string;
resource_access: Record<string, { roles: string[] }>;
} = jwt.decode(req.session.keycloak.id_token);

const roles = resource_access?.['unchained-local']?.roles || [];
let user = await context.modules.users.findUserById(`unchained-local:${sub}`);
if (roles.join(':') !== user.roles.join(':')) {
user = await context.modules.users.updateRoles(user._id, roles);
}

return {
...context,
userId: user._id,
user,
logout: async () => {
const tokenObject = {
// eslint-disable-next-line
_id: (req as any).session.sessionId,
userId: user._id,
};
delete req.session.keycloak;
await emit(API_EVENTS.API_LOGOUT, tokenObject);
return true;
},
};
} catch {
delete req.session.keycloak;
}
return {
...context,
};
},
context,
});

app.get(
'/login/keycloak/callback',
async function (
this: FastifyInstance & {
keycloak: FastifyOAuth2.OAuth2Namespace;
},
request: FastifyRequest & {
unchainedContext: Context;
},
reply,
) {
try {
const accessToken = await this.keycloak.getAccessTokenFromAuthorizationCodeFlow(request);
const decoded = jwt.decode(accessToken.token.id_token);
const {
sub,
resource_access,
preferred_username,
name,
given_name,
family_name,
email,
email_verified,
} = decoded as {
sub: string;
resource_access: Record<string, { roles: string[] }>;
preferred_username: string;
name?: string;
given_name?: string;
family_name?: string;
email?: string;
email_verified: boolean;
};

const roles = resource_access?.['unchained-local']?.roles || [];
const username = preferred_username || `unchained-local:${sub}`;
const user = await request.unchainedContext.modules.users.findUserByUsername(username);

if (!user) {
await request.unchainedContext.modules.users.createUser(
{
// eslint-disable-next-line
// @ts-ignore WE KNOW THAT WE CAN SET THAT FIELD
_id: `unchained-local:${sub}`,
username,
password: null,
email: email_verified ? email : null,
profile: {
displayName: name,
address: {
firstName: given_name,
lastName: family_name,
},
},
roles,
},
{ skipMessaging: true, skipPasswordEnrollment: true },
);
}
// eslint-disable-next-line
// @ts-ignore
request.session.keycloak = accessToken.token;
return reply.redirect('http://localhost:3000/');
} catch (e) {
console.error(e);
reply.status(500);
return reply.send();
}
},
);

await seed(engine.unchainedAPI);
await setAccessToken(engine.unchainedAPI, 'admin', 'secret');

Expand Down
161 changes: 161 additions & 0 deletions examples/keycloak/keycloak.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import fastifyCookie from '@fastify/cookie';
import { API_EVENTS, Context, UnchainedContextResolver } from '@unchainedshop/api';
import { emit } from '@unchainedshop/events';
import { FastifyInstance, FastifyRequest } from 'fastify';
import FastifyOAuth2 from '@fastify/oauth2';
import jwt from 'jsonwebtoken';

const {
UNCHAINED_KEYCLOAK_CALLBACK_PATH = '/login/keycloak/callback',
UNCHAINED_KEYCLOAK_CLIENT_ID = 'unchained-local',
UNCHAINED_KEYCLOAK_CLIENT_SECRET,
UNCHAINED_KEYCLOAK_REALM_URL = 'http://localhost:8080/realms/master',
ROOT_URL = 'http://localhost:4010',
} = process.env;

export default async function setupKeycloak(app: FastifyInstance) {
if (!UNCHAINED_KEYCLOAK_CLIENT_SECRET || UNCHAINED_KEYCLOAK_REALM_URL)
throw new Error(
'Environment variables UNCHAINED_KEYCLOAK_CLIENT_SECRET and UNCHAINED_KEYCLOAK_REALm_URL are required',
);

await app.register(fastifyCookie);

app.register(FastifyOAuth2, {
name: 'keycloak',
credentials: {
client: {
id: UNCHAINED_KEYCLOAK_CLIENT_ID,
secret: UNCHAINED_KEYCLOAK_CLIENT_SECRET,
},
},
startRedirectPath: '/login',
scope: ['profile', 'email', 'openid'],
callbackUri: `${ROOT_URL}${UNCHAINED_KEYCLOAK_CALLBACK_PATH}`,
discovery: { issuer: UNCHAINED_KEYCLOAK_REALM_URL },
});

app.get(
UNCHAINED_KEYCLOAK_CALLBACK_PATH,
async function (
this: FastifyInstance & {
keycloak: FastifyOAuth2.OAuth2Namespace;
},
request: FastifyRequest & {
unchainedContext: Context;
},
reply,
) {
try {
const accessToken = await this.keycloak.getAccessTokenFromAuthorizationCodeFlow(request);
const decoded = jwt.decode(accessToken.token.id_token);
const {
sub,
resource_access,
preferred_username,
name,
given_name,
family_name,
email,
email_verified,
} = decoded as {
sub: string;
resource_access: Record<string, { roles: string[] }>;
preferred_username: string;
name?: string;
given_name?: string;
family_name?: string;
email?: string;
email_verified: boolean;
};

const roles = resource_access?.['unchained-local']?.roles || [];
const username = preferred_username || `unchained-local:${sub}`;
const user = await request.unchainedContext.modules.users.findUserByUsername(username);

if (!user) {
await request.unchainedContext.modules.users.createUser(
{
// eslint-disable-next-line
// @ts-ignore WE KNOW THAT WE CAN SET THAT FIELD
_id: `unchained-local:${sub}`,
username,
password: null,
email: email_verified ? email : null,
profile: {
displayName: name,
address: {
firstName: given_name,
lastName: family_name,
},
},
roles,
},
{ skipMessaging: true, skipPasswordEnrollment: true },
);
}
// eslint-disable-next-line
// @ts-ignore
request.session.keycloak = accessToken.token;
return reply.redirect('http://localhost:3000/');
} catch (e) {
console.error(e);
reply.status(500);
return reply.send();
}
},
);

return (contextResolver: UnchainedContextResolver) => async (props, req) => {
// eslint-disable-next-line
const keycloakInstance = (app as any).keycloak as FastifyOAuth2.OAuth2Namespace;
const context = await contextResolver(props);
if (context.user || !req.session.keycloak) return context;

try {
const isExpired = new Date(req.session.keycloak.expires_at) < new Date();
if (isExpired) {
req.session.keycloak = (
await keycloakInstance.getNewAccessTokenUsingRefreshToken(req.session.keycloak, {})
).token;
}

const {
sub,
resource_access,
}: {
sub: string;
resource_access: Record<string, { roles: string[] }>;
} = jwt.decode(req.session.keycloak.id_token);

let user = await context.modules.users.findUserById(`unchained-local:${sub}`);

if (isExpired) {
// only update roles when the token has been refreshed
const roles = resource_access?.['unchained-local']?.roles || [];
if (roles.join(':') !== user.roles.join(':')) {
user = await context.modules.users.updateRoles(user._id, roles);
}
}

return {
...context,
userId: user._id,
user,
logout: async () => {
const tokenObject = {
// eslint-disable-next-line
_id: (req as any).session.sessionId,
userId: user._id,
};
delete req.session.keycloak;
await emit(API_EVENTS.API_LOGOUT, tokenObject);
return true;
},
};
} catch {
delete req.session.keycloak;
}
return context;
};
}

0 comments on commit 47b4ee4

Please sign in to comment.