Skip to content

Commit

Permalink
Merge pull request #9 from acasella/acasella/implement_auth_code
Browse files Browse the repository at this point in the history
Implement authorization_code grant and customize responses
  • Loading branch information
poveden authored Mar 18, 2019
2 parents 0308035 + ffefc5b commit 0abed38
Show file tree
Hide file tree
Showing 9 changed files with 339 additions and 23 deletions.
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,39 @@ request.get(
);
```

It also provides a convenient way, through event emitters, to programatically customize:

- The JWT access token
- The token endpoint response body and status
- The userinfo endpoint response body and status

This is particularly useful when expecting the oidc service to behave in a specific way on one single test.

```js
//Force the oidc service to provide an invalid_grant response on next call to the token endpoint
service.once('beforeResponse', (tokenEndpointResponse) => {
tokenEndpointResponse.body = {
error: 'invalid_grant'
};
tokenEndpointResponse.statusCode = 400;
});

//Force the oidc service to provide an error on next call to userinfo endpoint
service.once('beforeUserinfo', (userInfoResponse) => {
userInfoResponse.body = {
error: 'invalid_token',
error_message: 'token is expired',
};
userInfoResponse.statusCode = 401;
});

//Modify the expiration time on next token produced
service.issuer.once('beforeSigning', (token) => {
const timestamp = Math.floor(Date.now() / 1000);
token.payload.exp = timestamp + 400;
});
```

## Supported endpoints

### GET `/.well-known/openid-configuration`
Expand All @@ -92,8 +125,19 @@ Returns the JSON Web Key Set (JWKS) of all the keys configured in the server.
Issues access tokens. Currently, this endpoint is limited to:

- No authentication
- Client Credentials grants
- Resource Owner Password Credentials grants
- Client Credentials grant
- Resource Owner Password Credentials grant
- Authorization code grant
- Refresh token grant

### GET /authorize

It simulates the user authentication. It will automatically redirect to the callback endpoint sent as parameter.
It currently supports only 'code' response_type.

### GET /userinfo

It provides extra userinfo claims.

## Command-Line Interface

Expand Down
13 changes: 10 additions & 3 deletions lib/oauth2-issuer.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,20 @@
'use strict';

const jwt = require('jsonwebtoken');
const { EventEmitter } = require('events');
const JWKStore = require('./jwk-store');

const keys = Symbol('keys');

/**
* Represents an OAuth 2 issuer.
*/
class OAuth2Issuer {
class OAuth2Issuer extends EventEmitter {
/**
* Creates a new instance of HttpServer.
*/
constructor() {
super();
/**
* Sets or returns the issuer URL.
* @type {String}
Expand Down Expand Up @@ -88,12 +90,17 @@ class OAuth2Issuer {
scopesOrTransform(header, payload);
}

const token = {
header,
payload,
};
this.emit('beforeSigning', token);
const options = {
algorithm: ((arguments.length === 0 || signed) ? getKeyAlg(key) : 'none'),
header,
header: token.header,
};

return jwt.sign(payload, getSecret(key), options);
return jwt.sign(token.payload, getSecret(key), options);
}
}

Expand Down
14 changes: 12 additions & 2 deletions lib/oauth2-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const OAuth2Issuer = require('./oauth2-issuer');
const OAuth2Service = require('./oauth2-service');

const issuer = Symbol('issuer');
const service = Symbol('service');

/**
* Represents an OAuth2 HTTP server.
Expand All @@ -37,11 +38,12 @@ class OAuth2Server extends HttpServer {
*/
constructor() {
const iss = new OAuth2Issuer();
const service = new OAuth2Service(iss);
const serv = new OAuth2Service(iss);

super(service.requestHandler);
super(serv.requestHandler);

this[issuer] = iss;
this[service] = serv;
}

/**
Expand All @@ -52,6 +54,14 @@ class OAuth2Server extends HttpServer {
return this[issuer];
}

/**
* Returns the OAuth2Service instance used by the server.
* @type {OAuth2Service}
*/
get service() {
return this[service];
}

/**
* Returns a value indicating whether or not the server is listening for connections.
* @type {Boolean}
Expand Down
93 changes: 84 additions & 9 deletions lib/oauth2-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@

const express = require('express');
const bodyParser = require('body-parser');
const basicAuth = require('basic-auth');
const { EventEmitter } = require('events');
const uuidv4 = require('uuid').v4;

const OPENID_CONFIGURATION_PATH = '/.well-known/openid-configuration';
const TOKEN_ENDPOINT_PATH = '/token';
const JWKS_URI_PATH = '/jwks';
const AUTHORIZE_PATH = '/authorize';
const USERINFO_PATH = '/userinfo';

const issuer = Symbol('issuer');
const requestHandler = Symbol('requestHandler');
Expand All @@ -34,17 +39,20 @@ const buildRequestHandler = Symbol('buildRequestHandler');
const openidConfigurationHandler = Symbol('openidConfigurationHandler');
const jwksHandler = Symbol('jwksHandler');
const tokenHandler = Symbol('tokenHandler');
const authorizeHandler = Symbol('authorizeHandler');
const userInfoHandler = Symbol('userInfoHandler');

/**
* Provides a request handler for an OAuth 2 server.
*/
class OAuth2Service {
class OAuth2Service extends EventEmitter {
/**
* Creates a new instance of OAuth2Server.
* @param {OAuth2Issuer} oauth2Issuer The OAuth2Issuer instance
* that will be offered through the service.
*/
constructor(oauth2Issuer) {
super();
this[issuer] = oauth2Issuer;

this[requestHandler] = this[buildRequestHandler]();
Expand Down Expand Up @@ -87,6 +95,8 @@ class OAuth2Service {
app.post(TOKEN_ENDPOINT_PATH,
bodyParser.urlencoded({ extended: false }),
this[tokenHandler].bind(this));
app.get(AUTHORIZE_PATH, OAuth2Service[authorizeHandler]);
app.get(USERINFO_PATH, this[userInfoHandler].bind(this));

return app;
}
Expand All @@ -95,10 +105,15 @@ class OAuth2Service {
const openidConfig = {
issuer: this.issuer.url,
token_endpoint: `${this.issuer.url}${TOKEN_ENDPOINT_PATH}`,
authorization_endpoint: `${this.issuer.url}${AUTHORIZE_PATH}`,
userinfo_endpoint: `${this.issuer.url}${USERINFO_PATH}`,
token_endpoint_auth_methods_supported: ['none'],
jwks_uri: `${this.issuer.url}${JWKS_URI_PATH}`,
response_types_supported: ['code'],
grant_types_supported: ['client_credentials', 'password'],
grant_types_supported: ['client_credentials', 'authorization_code', 'password'],
token_endpoint_auth_signing_alg_values_supported: ['RS256'],
response_modes_supported: ['query'],
id_token_signing_alg_values_supported: ['RS256'],
};

return res.json(openidConfig);
Expand All @@ -117,17 +132,38 @@ class OAuth2Service {
});

let xfn;
let { scope } = req.body;

switch (req.body.grant_type) {
case 'client_credentials':
xfn = req.body.scope;
xfn = scope;
break;
case 'password':
xfn = (header, payload) => {
Object.assign(payload, {
sub: req.body.username,
amr: ['pwd'],
scope: req.body.scope,
scope,
});
};
break;
case 'authorization_code':
scope = 'dummy';
xfn = (header, payload) => {
Object.assign(payload, {
sub: 'johndoe',
amr: ['pwd'],
scope,
});
};
break;
case 'refresh_token':
scope = 'dummy';
xfn = (header, payload) => {
Object.assign(payload, {
sub: 'johndoe',
amr: ['pwd'],
scope,
});
};
break;
Expand All @@ -138,16 +174,55 @@ class OAuth2Service {
}

const token = this.buildToken(true, xfn, tokenTtl);

const resp = {
const body = {
access_token: token,
token_type: 'Bearer',
expires_in: tokenTtl,
scope: req.body.scope,
scope,
};
if (req.body.grant_type !== 'client_credentials') {
const credentials = basicAuth(req);
const clientId = credentials ? credentials.name : null;
body.id_token = this.buildToken(true, (header, payload) => {
Object.assign(payload, {
sub: 'johndoe',
aud: clientId,
});
}, tokenTtl);
body.refresh_token = uuidv4();
}
const tokenEndpointResponse = {
body,
statusCode: 200,
};
this.emit('beforeResponse', tokenEndpointResponse);

return res.json(resp);
return res.status(tokenEndpointResponse.statusCode).json(tokenEndpointResponse.body);
}

static [authorizeHandler](req, res) {
const { scope, state } = req.query;
const responseType = req.query.response_type;
const redirectUri = req.query.redirect_uri;
const code = uuidv4();

let targetRedirection = `${redirectUri}?code=${encodeURIComponent(code)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}`;
if (responseType !== 'code') {
targetRedirection = `${redirectUri}?error=unsupported_response_type&error_description=The+authorization+server+does+not+support+obtaining+an+access+token+using+this+response_type.&state=${encodeURIComponent(state)}`;
}

res.redirect(targetRedirection);
}
}

[userInfoHandler](req, res) {
const userInfoResponse = {
body: {
sub: 'johndoe',
},
statusCode: 200,
};
this.emit('beforeUserinfo', userInfoResponse);
res.status(userInfoResponse.statusCode).json(userInfoResponse.body);
}
}
module.exports = OAuth2Service;
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@
"test": "npm run jest"
},
"dependencies": {
"basic-auth": "^2.0.1",
"body-parser": "^1.18.3",
"express": "^4.16.4",
"jsonwebtoken": "^8.4.0",
"node-jose": "^1.1.0"
"node-jose": "^1.1.0",
"uuid": "^3.3.2"
},
"devDependencies": {
"eslint": "^5.11.0",
Expand Down
15 changes: 15 additions & 0 deletions test/oauth2-issuer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ describe('OAuth 2 issuer', () => {
expect(decoded.header.x5t).toEqual('a-new-value');
expect(decoded.payload.sub).toEqual('the-subject');
});

it('should be able to modify the header and the payload through a beforeSigning event', () => {
issuer.once('beforeSigning', (token) => {
/* eslint-disable no-param-reassign */
token.header.x5t = 'a-new-value';
token.payload.sub = 'the-subject';
/* eslint-enable no-param-reassign */
});

const token = issuer.buildToken(true, 'test-rsa-key');
const decoded = jwt.decode(token, { complete: true });

expect(decoded.header.x5t).toEqual('a-new-value');
expect(decoded.payload.sub).toEqual('the-subject');
});
});

function getSecret(key) {
Expand Down
6 changes: 6 additions & 0 deletions test/oauth2-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,10 @@ describe('OAuth 2 Server', () => {

await expect(server.stop());
});

it('should expose the oauth2 service', () => {
const server = new OAuth2Server();

expect(server.service).toBeDefined();
});
});
Loading

0 comments on commit 0abed38

Please sign in to comment.