Skip to content

Commit

Permalink
feat: attach id_token to user profile
Browse files Browse the repository at this point in the history
  • Loading branch information
brakmic committed Jan 23, 2025
1 parent c67084f commit 772c811
Showing 1 changed file with 102 additions and 76 deletions.
178 changes: 102 additions & 76 deletions src/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ import OAuth2Strategy = require('passport-oauth2');
import { InternalOAuthError, StrategyOptionsWithRequest } from 'passport-oauth2';
import { Request } from 'express';

/**
* Simplified Profile interface for user information.
*/
interface Profile {
provider: string;
id: string;
Expand All @@ -13,11 +10,9 @@ interface Profile {
emails?: { value: string }[];
_raw?: string;
_json?: any;
_id_token?: string | null; // captured id_token
}

/**
* Callback function to verify the user when passReqToCallback is true.
*/
type VerifyCallbackWithRequest = (
req: Request,
accessToken: string,
Expand All @@ -26,12 +21,9 @@ type VerifyCallbackWithRequest = (
done: (err: any, user?: any) => void
) => void;

/**
* Base options for KeycloakStrategy.
*/
interface BaseKeycloakStrategyOptions {
authorizationURL?: string; // Optional
tokenURL?: string; // Optional
authorizationURL?: string;
tokenURL?: string;
realm: string;
authServerURL: string;
clientID: string;
Expand All @@ -44,152 +36,187 @@ interface BaseKeycloakStrategyOptions {
store?: OAuth2Strategy.StateStore;
state?: any;
skipUserProfile?: any;
pkce?: boolean; // Enable PKCE
proxy?: any; // Proxy settings
pkce?: boolean;
proxy?: any;
}

/**
* Combined KeycloakStrategyOptions type.
* - `publicClient` is optional and determines if the client is public or confidential.
* - `clientSecret` is optional but required for confidential clients.
*/
interface KeycloakStrategyOptions extends BaseKeycloakStrategyOptions {
publicClient?: boolean;
clientSecret?: string;
}

/**
* `KeycloakStrategy` class extending `OAuth2Strategy`.
*/
class KeycloakStrategy extends OAuth2Strategy {
protected options: KeycloakStrategyOptions;
private _userProfileURL: string;

/**
* Constructs a new `KeycloakStrategy`.
*
* @param options - Configuration options for the strategy.
* @param verify - Callback function to verify the user.
*/
constructor(options: KeycloakStrategyOptions, verify: VerifyCallbackWithRequest) {
const realm = encodeURIComponent(options.realm || 'master');
const publicClient = options.publicClient === true; // Explicitly true or false
const _sslRequired = options.sslRequired || 'external';
const publicClient = options.publicClient === true;

// Validate required options
if (!options.authServerURL) {
throw new Error('Keycloak authServerURL is required.');
}
if (!options.callbackURL) {
throw new Error('Keycloak callbackURL is required.');
}

// Enforce clientSecret requirements based on publicClient flag
if (!publicClient && !options.clientSecret) {
throw new Error('Keycloak clientSecret is required for confidential clients.');
}

// Construct authorization and token URLs
const authorizationURL = `${options.authServerURL}/realms/${realm}/protocol/openid-connect/auth`;
const tokenURL = `${options.authServerURL}/realms/${realm}/protocol/openid-connect/token`;

// Define scopes
// Merge required + user-provided scopes
const requiredScopes = ['openid', 'profile', 'email'];
const existingScopes = options.scope
? Array.isArray(options.scope)
? options.scope
: options.scope.split(' ')
: [];
const scope = Array.from(new Set([...requiredScopes, ...existingScopes])).join(' ');
const mergedScopes = Array.from(new Set([...requiredScopes, ...existingScopes])).join(' ');

// Conditionally set clientSecret:
// - For public clients, default to an empty string.
// - For confidential clients, use the provided clientSecret.
const clientSecret = publicClient ? '' : options.clientSecret!;
// For a public client => no secret
const clientSecret = publicClient ? '' : (options.clientSecret || '');

// Assign constructed URLs and scope back to options for testing purposes
options.authorizationURL = authorizationURL;
options.tokenURL = tokenURL;
options.scope = scope;
options.scope = mergedScopes;

// Build the full options for OAuth2Strategy
const strategyOptions: StrategyOptionsWithRequest = {
clientID: options.clientID,
clientSecret: clientSecret,
clientSecret,
callbackURL: options.callbackURL,
authorizationURL: authorizationURL,
tokenURL: tokenURL,
scope: scope,
passReqToCallback: true, // Must be true to access the request in verify callback
// Include other optional properties if present
authorizationURL,
tokenURL,
scope: mergedScopes,
passReqToCallback: true,
skipUserProfile: false, // we want userProfile for e.g. displayName, emails
pkce: options.pkce,
customHeaders: options.customHeaders,
scopeSeparator: options.scopeSeparator,
sessionKey: options.sessionKey,
store: options.store,
state: options.state,
skipUserProfile: options.skipUserProfile,
pkce: options.pkce,
proxy: options.proxy,
store: options.store,
proxy: options.proxy
};

// Initialize the parent OAuth2Strategy with the constructed options
super(strategyOptions, verify);
// Final verify callback
super(strategyOptions, (req: Request, accessToken: string, refreshToken: string, profile: Profile, done: (err: any, user?: any) => void) => {
// After the token request, we store the results on (this._oauth2 as any)._keycloakResults
// If there's an id_token, attach it
const tokenData = (this._oauth2 as any)._keycloakResults;
if (tokenData && tokenData.id_token) {
profile._id_token = tokenData.id_token;
}
return verify(req, accessToken, refreshToken, profile, done);
});

this.options = options;
this._userProfileURL = `${options.authServerURL}/realms/${realm}/protocol/openid-connect/userinfo`;
this.name = 'keycloak';

// Patch the protected _request(...) method
this._patchRequestForIdToken();
}

/**
* Override the authorizationParams method to include PKCE parameters.
*
* @param options - Additional options for authorization.
* @returns Authorization parameters including PKCE if enabled.
* Patches this._oauth2._request so we can parse the token response if it's the token endpoint.
* We cast this._oauth2 to "any" to bypass TS's "protected property" restriction.
*/
private _patchRequestForIdToken(): void {
const tokenEndpoint = this.options.tokenURL || '';
const oauth2Any = this._oauth2 as any;

// The original protected method
const originalRequest = oauth2Any._request;

// We replace it
oauth2Any._request = (
method: string,
url: string,
headers: Record<string, string>,
post_body: any,
access_token: string | null,
callback: (err: any, body?: any, res?: any) => void
) => {
// If the request is going to the token endpoint, parse the response
if (url === tokenEndpoint && method.toUpperCase() === 'POST') {
const newCallback = (err: any, body?: string, res?: any) => {
if (err) {
return callback(err, body, res);
}
if (!body) {
return callback(null, body, res);
}
try {
const parsed = JSON.parse(body);

// Store the entire object so the final verify can see id_token
oauth2Any._keycloakResults = parsed;
} catch (_parseErr) {
// If not JSON, ignore
}
callback(null, body, res);
};

return originalRequest.call(
this._oauth2,
method,
url,
headers,
post_body,
access_token,
newCallback
);
} else {
// Otherwise, normal request
return originalRequest.call(
this._oauth2,
method,
url,
headers,
post_body,
access_token,
callback
);
}
};
}

/**
* Overridden to handle PKCE code_challenge
*/
authorizationParams(options: any): any {
const params: any = super.authorizationParams(options);

if (options.code_challenge) {
params.code_challenge = options.code_challenge;
params.code_challenge_method = 'S256';
}

return params;
}

/**
* Override the tokenParams method to include code_verifier from session.
*
* @param options - Additional options for token exchange.
* @returns Token parameters including code_verifier if present.
* Overridden to pass code_verifier from session if present.
*/
tokenParams(options: any): any {
const params: any = {};

if (options.req && options.req.session && options.req.session.code_verifier) {
params.code_verifier = options.req.session.code_verifier;
}

return params;
}

/**
* Retrieve user profile from Keycloak.
*
* @param accessToken - The access token.
* @param done - Callback to handle the retrieved profile.
* The normal userProfile method to fetch e.g. displayName, emails from /userinfo
*/
userProfile(accessToken: string, done: Function): void {
this._oauth2.useAuthorizationHeaderforGET(true);
this._oauth2.get(this._userProfileURL, accessToken, (err: any, body?: string | Buffer) => {
if (err) {
return done(new InternalOAuthError('Failed to fetch user profile', err));
}

if (!body) {
return done(new Error('Empty profile response'));
}

try {
const bodyString = typeof body === 'string' ? body : body.toString();
const json = JSON.parse(bodyString);
Expand All @@ -200,11 +227,10 @@ class KeycloakStrategy extends OAuth2Strategy {
displayName: json.name || '',
username: json.preferred_username || '',
emails: json.email ? [{ value: json.email }] : [],
_raw: bodyString,
_json: json
};

profile._raw = bodyString;
profile._json = json;

done(null, profile);
} catch (_e) {
done(new Error('Failed to parse user profile'));
Expand Down

0 comments on commit 772c811

Please sign in to comment.