@jmarca/cas_validate

Interact with a CAS server to validate client interaction

Usage no npm install needed!

<script type="module">
  import jmarcaCasValidate from 'https://cdn.skypack.dev/@jmarca/cas_validate';
</script>

README

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 sleeps 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 like cas.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 the check_... 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 like cas.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 like cas.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.