CAS Validate
This is a utility to facilitate validating a web service based on Connect or Express (and perhaps other frameworks or nothing at all) with a CAS server(https://www.apereo.org/cas. It allows single sign on and single sign out. In other words, if a client has previously logged in to the CAS server, this library will allow your service to transparently detect that fact, and if the user subsequently logs off from the CAS server, this library can handle the subsequent POST message from the CAS server and log the user out of your service as well.
The need to support single sign out was the original reason I wrote this library. Since then I modularized it so that I could apply different strategies to different services in my Connect and Express applications. The original development was conducted when Connect still had routing capabilities, but all but one feature still works with the latest Connect, and all features work with Express.
Request has been deprecated…
As of February 2020, Request has been deprecated. As cas_validate has been stuck for years in a state of un-testedness, and as I'm finally putting in some work trying to remedy that situation, I figure now is as good a time as any to move on from Request.
And with this push, that work is done. Tests pass too.
Testing things and notes on latest updates
I don't use this code in practice anymore, but it has been bothering
me for years that I didn't have a good testing setup. This past
summer (August 2019) I was able to finally get a CAS server up and
running under Docker. My repo for that is
here. I forked the
original cas_overlay_template
repo and tweaked the gradle compiling
settings in order to enable LDAP authentication.
The testing setup is encapsulated in my run_docker.sh
file. This
isn't really a sh
file, but rather more like a bashrc
file, in
that it just creates a bunch of functions that run docker commands and
such.
In order to test my code, the idea is that you have to fire up an LDAP
container, a CAS container, and a third node.js container to run this
code. To make that easy, the run_docker.sh
command sets up
dependencies between the different containers and container networks.
Steps to run tests
(Note, I run linux. I've no idea how to do any of this on Windows, but a Mac might work out of the box if you have the docker tool chain installed.)
Step 0: source the aliases
The zeroth test is to open up a terminal shell window (my daughters
call it "the black box") and source the run_docker.sh
file, as
follows:
. run_docker.sh
This will load up a bunch of aliases in the current command line environment.
Step 1: build the node docker image
The first step is to build the cas_node_tests
image that can run
this code. In the same shell in which you source the run_docker.sh
file, execute the following command:
make_cas_node_tests_docker
This will build a new docker image using the Dockerfile found in this repository. Alternately, you can instead run
docker build -t jmarca/cas_node_tests .
If all goes well, you should see some complaints about deprecated node.js libraries, and then the build should finish with something like:
...
Removing intermediate container 86883a14e082
---> 987bf3c2c600
Step 10/10 : CMD [ "npm", "start" ]
---> Running in 6ca824e68aa2
Removing intermediate container 6ca824e68aa2
---> 012985a8c476
Successfully built 012985a8c476
Successfully tagged jmarca/cas_node_tests:latest
Step 2: fire up the test environment
Once the needed container is built, the idea is to launch a shell
inside of that container that lets you run the tests. To do that,
simply execute the function cas_node_test
:
cas_node_test
This should start a few needed networks, start some containers, and
then load the cas_node_tests
container that was built in the
previous step. The output should look like this:
james at farfalla in ~/repos/jem/cas_validate on master [!?]
$ cas_node_test
Error response from daemon: No such container: cas_node_tests
cas_nw is not up, starting it for you.
a69d66d35df366171531e383192184ee2d3223b84072a8000e639d14098d8a88
redis_nw is not up, starting it for you.
ceadfcf1f57c5de6829e02abcc0063cee9a97001162313762f093665ce1f4da4
cas is not running, starting it for you.
openldap is not running, starting it for you.
openldap_nw is not up, starting it for you.
0b0fcde7c60a29067ea2b632e3833fc7fe6826d668c1fc7f7e09bbc0c0cde2a8
f22f322d21d6538b5dfbe26ef7fbb877ca272828312f77db28341e5893fa298d
1555ed92e3bff10b4930876e7b2c43f79192a12dd0ccabcf948f042a5a2c25d2
cas
redis is not running, starting it for you.
1ce014d22a29f9f9dcd451f371dd17bc1d6787fd69bd75c744ed266f5d23dd18
934ef10dc7053ff31be53360e1cc5100236014b76d5485cb289b044a274627d2
cas_node_tests
bash-5.0$
The final prompt means you're in bash, ready to run the tests. The build command should have installed all the node libraries, but just in case, install them, then run the tests:
bash-5.0$ npm install
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.1.2 (node_modules/fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.1.2: wanted {"os":"darwin","arch":"any"} (current: {"os":"linux","arch":"x64"})
audited 1719 packages in 2.341s
19 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
bash-5.0$ npm test
> cas_validate@0.1.9 test /usr/src/dev
> NODE_ENV='test' tap test/**/*-test*.js --cov
test/01-test-loadup.js 1> in ticket store, redishost is redis
test/01-test-loadup.js 1> {"message":"setting login service endpoint on CAS server to /cas/login","level":"info"}
...
test/06-test-xml-parser.js 1> server up: cas_node_tests 3002
PASS test/06-test-xml-parser.js 24 OK 500.848ms
🌈 SUMMARY RESULTS 🌈
Suites: 6 passed, 6 of 6 completed
Asserts: 100 passed, of 100
Time: 31s
----------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------------------|----------|----------|----------|----------|-------------------|
All files | 70.32 | 51.63 | 61.4 | 71.18 | |
cas_validate.js | 100 | 100 | 100 | 100 | |
check_or_redirect.js | 57.69 | 25 | 50 | 60 |... 35,36,38,42,46 |
force_protocol.js | 95 | 92.31 | 100 | 100 | 45,62 |
invalidate.js | 26.67 | 0 | 0 | 26.67 |... 48,49,50,52,61 |
logger.js | 65 | 58.33 | 100 | 65 |... 20,27,29,30,36 |
logout.js | 75.76 | 41.67 | 80 | 78.13 |... 40,41,42,44,45 |
redirect.js | 94.12 | 70 | 100 | 96.97 | 63 |
sax_parser.js | 23.73 | 23.33 | 25 | 23.73 |... ,97,99,101,163 |
session_or_abort.js | 25 | 0 | 0 | 25 | 14,15,16,18,20,21 |
ssoff.js | 90.91 | 50 | 100 | 90.91 | 9 |
ticket.js | 81.48 | 65 | 60 | 83.02 |... 99,100,115,116 |
ticket_store.js | 78 | 45.83 | 80 | 78 |... 0,91,94,96,133 |
xml_parser.js | 87.5 | 56.67 | 72.73 | 87.5 |... 86,187,188,190 |
----------------------|----------|----------|----------|----------|-------------------|
bash-5.0$
(The tests take a while (31 seconds in the above run), because there
are a few sleep
s in there to allow tickets to time out.)
As you can see, test coverage is not great. I'm working on that.
Redis version 2.6
This library now requires redis version 2.6.x. I recently added time-to-live capabilities when storing the session ticket data (using the redis setex command as suggested by @chrisbarran). The test for this functionality (test/ttl_test.js) fails when running Redis 2.4, but passes when running Redis 2.6.
Version 0.1.9
A minor update to parse attributes in a second way. According to user @cricri's pull request, another way that is common to send user attributes to the CAS client is to simply list them. That is, the way I am parsing by default is
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>h_mueller</cas:user>
<cas:attributes>
<cas:mail>a.b@c.de</cas:mail>
<cas:__AUTHUSERCONTEXT__>cont</cas:__AUTHUSERCONTEXT__>
<cas:cn>commonname</cas:cn>
<cas:__AUTHTYPE__>TUID</cas:__AUTHTYPE__>
<cas:surname>Müller</cas:surname>
<cas:tudUserUniqueID>1234567</cas:tudUserUniqueID>
<cas:givenName>Hans</cas:givenName>
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>
But there is an alternate way that simply lists the attributes as so:
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>bob</cas:user>
<cas:attribute name="uid" value="b01234" />
<cas:attribute name="mail" value="bob@mail.com" />
<cas:attribute name="cn" value="smith" />
<cas:attribute name="givenname" value="bob" />
<cas:attribute name="service" value="other" />
<cas:attribute name="permission" value="p1" />
<cas:attribute name="permission" value="p2" />
<cas:attribute name="permission" value="p3" />
<cas:attribute name="uidnumber" value="123456789" />
</cas:authenticationSuccess>
</cas:serviceResponse>
Version 0.1.9 should now properly parse the second way as well, whereas before it would simply choke and die.
Check out the test in test/xml_parser_test.js, look for the test with "cas_auth_2.xml" (about line 122 is where that test begins).
This email thread is one that I found when trying to dig up the "standard" way to send user attributes: http://jasig.275507.n4.nabble.com/CAS-attributes-and-how-they-appear-in-the-CAS-response-td264272.html
Version 0.1.8
Biggest change here is a switch from SAX parser to XML doc parser for parsing the response from the CAS server ticket validation. It was reported that the parser was choking on attributes with umlauts and other common utf-8 characters. Apparently, either the SAX parser approach in libxmljs or the way I was using it was to blame, but the upshot was that non-ascii characters were getting mangled.
To fix this, I switched to using the whole-doc parsing approach. This will use slightly more memory, etc, as the whole DOM tree for the response has to be loaded into memory, but the docs are small so that is probably fine.
The test that checks this is in test/xml_parser_test.js
It does not
use a live CAS server, so you can run it on your own and load it up
with your own CAS response docs to see whether everything is getting
parsed okay. Just replace the file in test/files/cas_auth.xml
with
your own difficult-to-parse case, and then submit a bug report is
stuff is breaking.
Version 0.1.0
This new version brings with it some small API changes for the few people who might be using this. The major difference is that it is no longer optional to pass the service location. That is, the routines do not try to guess what the service might be from the request header. This is because at the Open Apereo 2013 conference, it was pointed out during a security audit that doing so is a possible security flaw.
So invoke the various functions as so:
app.use('/valid'
,cas_validate.ticket({'cas_host':my_cas_host,
'service':'http://'+testhost +':'+testport+'/valid'}))
The other major change in functionality is that the ticket response from the CAS server is now parsed for attributes. Unfortunately, this currently requires an XML response from the CAS server. I will implement the JSON response handler soon, but in the interim you might want to check out the Sheffield University fork
Roadmap
I am in the midst of refactoring and modularizing this library. The most pressing need is to parse a JSON response from the CAS server. Next comes centralizing the initialization of this library. For the moment, the best approach is to create an object in your calling code that holds the common CAS initialization attributes.
Version 0.2.0 will be hit when JSON responses are possible, and version 0.3.0 will be hit when all of the various routines are modularized (so you don't have to delete the SSOFF code, for example, you just don't have to use it)
Example use for a Connect-based server:
Using the library is pretty easy. Just add the necessary require
statement, and then slot in the desired CAS behavior. For example to
prevent all access to your application, you would do the following:
var cas_validate = require('cas_validate');
...
var app = connect()
.use(connect.cookieParser('barley wheat napoleon'))
.use(connect.session({ store: new RedisStore }))
.use(cas_validate.redirect({'cas_host':'my.cas.host.net'})
.use(function(req, res, next){
res.end('hello world')
});
var server = app.listen(3000,function(e){
if(e) throw new Error(e)
console.log('app started on port 3000')
});
);
A few things to note. First I am using the connect-redis plugin to manage sessions from CAS. I haven't tested whether other session management plugins will work, but as long as they allow simple operations such as
req.session.st = ticket
they should work fine.
Second, the cas_host
option currently just wants the host. I prepend
https://
to this host. If you aren't using https for your CAS
server, then you're out of luck for using this library.
Installation
Via npm
$ cd myapplication
$ npm install cas_validate
Or you can add it to your package.json dependencies.
Manual install
$ cd ~/my/github/repos
$ git clone git://github.com/jmarca/cas_validate.git
$ cd myapplication
$ npm install ~/my/github/repos/cas_validate
Exported Functions
ticket
The ticket
function is crucial to handling CAS sessions. It will
consume the service ticket from the CAS server, verify that it is
valid, establish a valid session on your service for the client, and
will store the CAS credentials in a redis database to allow for single
sign out POST messages. If there is no service ticket in the request,
or if the service ticket is not valid, this function will simply pass
control along to the next connect middleware in the web stack.
Options
-
cas_host
: the CAS hostname, without the 'https://' part and without the '/cas/login' part. Something likecas.example.net
. The default is to read the CAS_HOST environment variable. This option, if set, will override the default. -
service
: the service for which the service ticket was issued. If used in the same route as thecheck_...
part of the function, then this parameter can be left to its default, and the correct value will be deduced from the request parameters. In some cases it might be necessary to specify this value.
check_or_redirect
The check_or_redirect
function is probably the most useful one.
Used in conjunction with the ticket
function, it will enable
CAS-based authentication.
Options
-
cas_host
: the CAS hostname, without the 'https://' part and without the '/cas/login' part. Something likecas.example.net
. The default is to read the CAS_HOST environment variable. This option, if set, will override the default. -
service
: the service for which the service ticket will be issued, and to which the CAS server will redirect the request after the user has logged in. The default is to figure out the service from the incoming request, but one may want to redirect the incoming request somewhere else.
Example
An example of redirecting the request to another destination is shown below, modified from the test suite.
app = connect()
app.use(cas_validate.ssoff())
app.use(cas_validate.ticket({'cas_host':chost}))
app.use(function(req, res, next){
if(req.session.st){
return res.end('hello '+req.session.name)
}else{
return res.end('hello world (not logged in)')
}
}
)
var login = connect()
login.use(connect.cookieParser('six foot barley at Waterloo'))
login.use(connect.session({ store: new RedisStore }))
login.use('/login',cas_validate.check_or_redirect({'cas_host':chost
,'service':'http://'+testhost+':'+testport+'/'}))
login.use('/',app)
server = login.listen(testport,done)
In the above example, the /login
route will send the user to the CAS
server to login, and then return them to the /
destination. The
default behavior would be to return them to the /login
path that
they came from.
Also note that since we don't expect the CAS server to send its ticket
to the /login
path, the ticket
service is not attached to that
route. It is attached to the /
route, and will consume the ticket
there.
Also also, when the CAS session expires and the CAS server sends a
post request informing your server of this fact, it will send it to
the path listed in the service
parameter. So if you only want to
allow POST requests to a certain address, that is another reason to
specify the service parameter.
check_and_return
The check_and_return
function is somewhat useful. The idea is to
exploit the feature in the CAS server that listens for a
'gateway=true' parameter in the URL. This will return a service
ticket if the client has a valid CAS session, and will return nothing
if not.
Options
The same options as check_or_redirect
, above
Example
The previous example has been modified below to use check_and_return instead of check_or_redirect
app = connect()
app.use(cas_validate.ssoff())
app.use(cas_validate.ticket({'cas_host':chost}))
app.use(cas_validate.check_and_return({'cas_host':chost
,'service':'http://'+testhost+':'+testport+'/'}))
app.use(function(req, res, next){
if(req.session.st){
return res.end('hello '+req.session.name)
}else{
return res.end('hello world (not logged in)')
}
}
)
var login = connect()
login.use(connect.cookieParser('six foot barley at Waterloo'))
login.use(connect.session({ store: new RedisStore }))
login.use('/login',cas_validate.check_or_redirect({'cas_host':chost
,'service':'http://'+testhost+':'+testport+'/'}))
login.use('/',app)
server = login.listen(testport,done)
In the previous server, the system would not know whether or not a
user was logged in until the user went to the /login
route and
triggered the check_or_redirect
function. Here, instead, the /
route has the check_and_return
function set. What happens is that
the first time the user goes to the /
location, the CAS system is
checked to see if the user is logged in already. Internally this sets
a flag in the session, so as to prevent an infinite loop. If the user
is logged in already, then the CAS system will respond with a valid
service ticket that the ticket
service will consume. If the client
has not established a CAS login, then there is no ticket sent from
CAS, and the user is not logged in.
The problem with this approach is that it does not detect if the user goes to your web application, then logs in to another CAS service. Once the gateway service is checked, it is not checked again.
If you wish to check the CAS service once with every request, then
simply delete the session property req.session.gateway
. However, be
aware that until the user logs in properly, resetting
req.session.gateway
will cause a redirect through the CAS server
with every request, which will greatly slow down the performance of
your system.
redirect
redirect
is a somewhat lame filter, but it can be useful. All it
does is redirect incoming queries to the CAS login page. Even if the
session has been established, it will always ignore that fact and
bounce the request.
ssoff
The ssoff
service will listen for incoming POST messages from the
CAS server and will delete sessions as appropriate.
Do not put this service after the check_or_redirect
service, or the
CAS server POSTs will get redirected to the CAS server to log in!
Options
No options
Example
app.use(cas_validate.ssoff())
app.use(cas_validate.ticket())
app.use(cas_validate.check_or_redirect())
app.use('/',function(req, res, next){
res.end('hello only to the authenticated world')
});
logout
The logout
service is similar to single sign off, but does the job
of invalidating the current session first, before triggering the CAS
server's logout function.
You can use this with the ssoff
service to enable logging out from
your application directly, or indirectly from some other CAS enabled
app.
Options
-
cas_host
: the CAS hostname, without the 'https://' part and without the '/cas/logout' part. Something likecas.example.net
. The default is to read the CAS_HOST environment variable. This option, if set, will override the default. -
service
: the service for which the service ticket will be issued, and to which the CAS server will redirect the request after the user has logged in. The default is to figure out the service from the incoming request, but one may want to redirect the incoming request somewhere else. -
logout_service
: the default CAS logout service is/cas/logout
. If your CAS setup uses a different endpoint, then specify that here.
Example
As usual, check out the test for a complete example. Cut and paste below:
app = connect()
.use(connect.bodyParser())
.use(connect.cookieParser('barley Waterloo Napoleon loser'))
.use(connect.session({ store: new RedisStore }))
app.use('/username',cas_validate.username)
app.use('/quit',cas_validate.logout({'cas_host':'my.cas.host'
,'service':'http://myhost.com'}))
app.use(cas_validate.ssoff())
app.use(cas_validate.ticket({'cas_host':'my.cas.host'
,'service':'http://myhost.com'}))
app.use(cas_validate.check_and_return({'cas_host':'my.cas.host'
,'service':'http://myhost.com'}))
app.use(function(req, res, next){
if(req.session.st){
return res.end('hello '+req.session.name)
}else{
return res.end('hello world (not logged in)')
}
}
)
var login = connect()
.use(connect.cookieParser('six foot barley at Waterloo'))
.use(connect.session({ store: new RedisStore }))
login.use('/login',cas_validate.check_or_redirect({'cas_host':'my.cas.host'
,'service':'http://myhost.com'}))
login.use('/',app)
server=login.listen(testport
,done)
username
A simple service to spit back the current logged in user's username as a JSON object, or null.
Either:
return res.end(JSON.stringify({'user':req.session.name}));
or
return res.end(JSON.stringify({'user':null}));
Options
No options
session_or_abort
The session_or_abort
service no longer works with Connect, as
routing has been removed. This is the only feature that requires
Express.
The idea is to abort the current route if a session has not been
established. This is done by calling next('route')
within the code
if the CAS session check fails.
The intended use case is to assign certain stacks of routes to logged
in users, and others to those who are not logged in, without having to
resort to multiple paths or lots of if
statements in your server
code.
Options
No options
Example
app = express()
.use(connect.cookieParser('barley Waterloo Napoleon Mareschal Foch bravest'))
.use(connect.session({ store: new RedisStore }))
app.get('/secrets'
,cas_validate.session_or_abort()
,function(req,res,next){
res.end('super secret secrets')
})
app.get('/secrets'
,function(req,res,next){
res.end('public secrets')
})
Tests
The tests provide working examples of how to use the library.
To run the tests, you need to have a working CAS server, and you need to set lots of environment variables.
Environment variables
-
CAS_HOST: no default. The CAS host (bare host name or number; not https, not /cas/login)
-
CAS_USER: no default. Your CAS username you want to use for the tests.
-
CAS_PASS: no default. The password to go along with the CAS username
-
CAS_VALIDATE_TEST_URL: Default is '127.0.0.1'. If you want to test single sign out (the
ssoff
service), then you'll need to run your test server on a public machine, with a URL that the CAS server can send a POST to. SSOFF tests will be skipped if CAS_VALIDATE_TEST_URL is 127.0.0.1 and the hostname part of CAS_HOST is not 127.0.0.1. -
CAS_VALIDATE_TEST_PORT: Default is 3000. If you are already using port 3000 for something else, change this. Also, make sure that this port is not blocked in your firewall if you want to test single sign off...otherwise you won't see the POSTs from the CAS server to your test application.
To run the tests, make sure to first install all of the dependencies with
npm install
Then run the tests with
npm test
or
make test
(I do this to get the nyan cat reporter)
If you are running on localhost, the last tests related to single sign off will be skipped. The idea is that localhost isn't usually an address that can be hit by another machine, so the test should not be run.
Instead, put the library on a machine with a URL (even a numeric one) that your CAS server can see and send a POST to. This will more accurately model a real production environment.
For example, if you have a server called http://awesome.mycompany.net
you can run the test on port 3000 on this machine by typing
export CAS_VALIDATE_TEST_URL='awesome.mycompany.net'
Then all the tests will run, and they should all pass. Assuming of
course that you have a properly configured CAS server and identified
it as noted above. The only caveat is that waiting for the POST is
slow, and so the test may timeout. If this happens, try running with
a longer timeout period (mocha --timeout 50000 test
)
Testing XML validation functionality
By default now, if your CAS server returns user attributes as XML, then these attributes will be parsed and loaded into the environment.
As noted above, the XML parser was switched away from a SAX-style
parser to a whole document parser, in order to get around a character
encoding bug. This change has an accompanying test in
test/xml_parser_test.js
, and reads in the two files found in
test/files/
, so if you want to test out your own specific case prior
to deploying this library, swap in your own XML files there.
The test (test/parse_casxml_response.js
) is designed explicitly for my case,
where I am passing back ['mail','sn','cn','givenName','groups']
from
ldap via CAS. If your local CAS server is not passing back these
things, then the test will fail for you. To help, I am dumping to the
console the object returned from parsing. If it makes sense to you
given your CAS server and given your test user (CAS_USER environment
variable), then the test is passing. Feel free to fork and create a
more general test if you can think of one.
Logging
This package uses winston. Not
well, but anyway, there it is. Basically, if you want lots of output,
set the NODE_ENV
environment variable to 'development'. If you are
running in production, set NODE_ENV
to 'production'. This also
meshes well with Express usage of the NODE_ENV
variable. Finally,
if something weird is going on in production, you can also set the log
level explicitly, by setting either CAS_VALIDATE_LOG_LEVEL
or
LOGLEVEL
to the usual ['debug','info','warn','error'] (although this
hasn't been tested)
In the code noisy alerts are at the debug level, and then errors are at the error level, but maybe in the future I'll add finer grained message levels.
See Also
The CAS server is documented at http://www.jasig.org/cas.