passport-cas-ts
Version:
CAS strategy for passport.js authentication
236 lines (210 loc) • 7.92 kB
text/typescript
/**
* index.ts
* =========
*
* In this file is defined a `passport` strategy for a CAS
* ({@link https://en.wikipedia.org/wiki/Central_Authentication_Service|Central Authentication Service}).
*
* This strategy is meant to be used with `passport` to authenticate users using an external CAS.
*
*/
import axios from "axios";
import xml2js from "xml2js";
import express from "express";
import passport, { StrategyFailure } from "passport";
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Successful CAS server response
export interface CasAuthenticationSuccess<Attributes> {
user: string; // username
// user metadata
attributes: Attributes;
}
// Unsuccessful CAS server response
interface CasAuthenticationFailure {
$?: {
code?: any; // error code
};
}
// User-provided hook on the authentication process
export type VerifyCallback<Attributes> = (
profile: CasAuthenticationSuccess<Attributes>,
done: VerifiedCallback,
) => void;
// Callback to be called by the user hook defined above
// Calling such a function with a truthy value for `err` make the authentication fail.
// Otherwise, if `err` is falsy, the authentication succeeds iff profile is truthy.
export type VerifiedCallback = (
err: any,
profile: object | null,
info?: object,
challenge?: StrategyFailure | string | number,
) => void;
// CAS server response after a validation request
interface CasServiceResponse<Attributes> {
authenticationsuccess?: CasAuthenticationSuccess<Attributes>;
authenticationfailure?: CasAuthenticationFailure;
}
export interface CasOptions {
base: string; // base URL of the CAS server
loginRoute: string; // login route of the CAS server
validateRoute: string; // validate route of the CAS server
logoutRoute: string; // logout route of the CAS server
serverUrl: string; // public URL of the server using CAS authentication
}
type ErrorLoggingFunction = (msg: string, err: any) => void;
type LoggingFunction = (msg: string) => void;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// CAS server validation response parsing options
const XML_PARSE_OPTIONS = {
trim: true,
normalize: true,
explicitArray: false,
tagNameProcessors: [
xml2js.processors.normalize,
xml2js.processors.stripPrefix,
],
};
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
export class CasStrategy<Attributes> extends passport.Strategy {
public readonly name: string = "cas"; // used by passport to identify the strategy
private readonly verify: VerifyCallback<Attributes>; // user-provided hook to ultimately verify the authentication
private readonly config: CasOptions;
private readonly errorLogger: ErrorLoggingFunction | undefined;
private readonly debugLogger: LoggingFunction | undefined;
constructor(
verify: VerifyCallback<Attributes>,
options: CasOptions,
errorLogger?: ErrorLoggingFunction,
debugLogger?: LoggingFunction,
) {
super();
this.verify = verify;
this.config = { ...options };
this.errorLogger = errorLogger;
this.debugLogger = debugLogger;
}
private logError: ErrorLoggingFunction = (msg: string, err: any) => {
if (this.errorLogger) {
this.errorLogger(msg, err);
}
};
private logDebug: LoggingFunction = (msg: string) => {
if (this.debugLogger) {
this.debugLogger(msg);
}
};
// See https://apereo.github.io/cas/6.6.x/images/cas_flow_diagram.png for details about the CAS authentication
// workflow
public async authenticate(req: express.Request) {
// handle logout request
if (req.query.RelayState) {
this.logDebug("RelayState present, logging out");
return this.logout(req);
}
const service = this.getReqService(req);
this.logDebug(`Extracted service: ${service}`);
const ticket = req.query.ticket;
// If no ticket, then it is the first call. We redirect to login page, and it will call this code again with a
// ticket
if (!ticket) {
this.logDebug("No ticket provided, starting login process");
const redirectURL = new URL(this.config.loginRoute, this.config.base);
redirectURL.searchParams.set("service", service);
return this.redirect(redirectURL.toString());
}
// If there is a ticket, then we check it validity
this.logDebug(`Got ticket ${ticket}, starting validation process`);
const target = new URL(this.config.validateRoute, this.config.base);
target.searchParams.set("ticket", ticket as string);
target.searchParams.set("service", service);
const res = await axios.get<string>(target.toString());
if (res.status == 200) {
// Check ticket validity when data has arrived
this.logDebug(`Got info: ${res.data}`);
this.validate(res.data);
} else {
// If the CAS server cannot be reached for some reason, make authentication fail
const err = res.statusText;
this.logError("Failed to validate ticket", err);
return this.fail(new Error(err));
}
}
// Logout handler in case the query had a `RelayState` parameter
private logout(req: express.Request) {
// logout locally
req.logout((err: any) => {
if (err) {
this.logError("Failed to log request out", err);
}
});
// request logout on the CAS server
const redirectURL = new URL(this.config.logoutRoute, this.config.base);
redirectURL.searchParams.set("_eventId", "next");
redirectURL.searchParams.set("RelayState", req.query.RelayState as string);
return this.redirect(redirectURL.toString());
}
// Extract the service of the request by removing the ticket parameter
private getReqService(req: express.Request) {
const url = new URL(req.originalUrl, this.config.serverUrl);
url.searchParams.delete("ticket");
return url.toString();
}
// Read the validation response to know whether the ticket is valid or not
private validate(body: string) {
const done: VerifiedCallback = this.onceVerified.bind(this);
xml2js.parseString(
body,
XML_PARSE_OPTIONS,
(
err: Error | null,
result: { serviceresponse?: CasServiceResponse<Attributes> },
): void => {
if (err || !result) {
// XML parsing failed
return done(new Error("Bad response from server"), null);
}
if (result.serviceresponse?.authenticationfailure) {
return done(
new Error(
"Authentication failed " +
result.serviceresponse.authenticationfailure.$?.code,
),
null,
);
}
const success = result.serviceresponse?.authenticationsuccess;
if (success) {
this.logDebug(
"CAS validate: ticket ok, calling user verify function",
);
this.logDebug("Got profile: " + JSON.stringify(success));
// call user-provided hook before validating
return this.verify(success, done);
} else {
return done(new Error("Authentication failed"), null);
}
},
);
}
// Finish the authentication process (called by the user-provided verify function)
private onceVerified: VerifiedCallback = function (
this: CasStrategy<Attributes>,
err,
profile,
info,
challenge,
) {
if (err) {
this.logError("CAS verified callback error", err);
return this.fail(challenge);
}
if (!profile) {
this.logDebug("CAS verified callback error: invalid profile");
this.logDebug(JSON.stringify(profile));
return this.fail(challenge);
}
this.logDebug("CAS verified callback success");
this.logDebug(JSON.stringify(profile));
this.success(profile, info);
};
}