diff --git a/Procfile b/Procfile new file mode 100644 index 00000000..bf9e09b8 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: node example/index.js diff --git a/README.md b/README.md index 138b8efe..e751eee0 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,63 @@ -# oidc-client +# openid-client [![build][travis-image]][travis-url] [![codecov][codecov-image]][codecov-url] [![npm][npm-image]][npm-url] [![licence][licence-image]][licence-url] -oidc-client is a server side [OpenID][openid-connect] Relying Party (RP, Client) implementation for +openid-client is a server side [OpenID][openid-connect] Relying Party (RP, Client) implementation for Node.js +## Example +Head over to the example folder to see the library in use. This example is deployed and configured +to use an example OpenID Connect Provider [here][heroku-example]. The provider is using +[oidc-provider][oidc-provider] library. + ## Get started On the off-chance you want to manage multiple clients for multiple issuers you need to first get -a Provider. +an Issuer instance. -### via Discovery +### via Discovery (recommended) ```js -const Provider = require('oidc-client').Provider; -Provider.discover('https://accounts.google.com') // => Promise - .then(function (googleProvider) { - console.log('Discovered issuer %s', googleProvider.issuer); +const Issuer = require('openid-client').Issuer; +Issuer.discover('https://accounts.google.com') // => Promise + .then(function (googleIssuer) { + console.log('Discovered issuer %s', googleIssuer); }); ``` ### manually ```js -const Provider = require('oidc-client').Provider; -const googleProvider = new Provider({ +const Issuer = require('openid-client').Issuer; +const googleIssuer = new Issuer({ issuer: 'https://accounts.google.com', authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', token_endpoint: 'https://www.googleapis.com/oauth2/v4/token', userinfo_endpoint: 'https://www.googleapis.com/oauth2/v3/userinfo', jwks_uri: 'https://www.googleapis.com/oauth2/v3/certs', -}); // => Provider -console.log('Set up issuer %s', googleProvider.issuer); +}); // => Issuer +console.log('Set up issuer %s', googleIssuer); ``` -Now you can create your Client. +**Now you can create your Client.** -### manually +### manually (recommended) You should provide the following metadata; `client_id, client_secret`. You can also provide `id_token_signed_response_alg` (defaults to `RS256`) and `token_endpoint_auth_method` (defaults to `client_secret_basic`); ```js -const client = new googleProvider.Client({ +const client = new googleIssuer.Client({ client_id: 'zELcpfANLqY7Oqas', client_secret: 'TQV5U29k1gHibH5bx1layBo0OSAvAbRT3UYW3EWrSYBB5swxjVfWUa1BS8lqzxG/0v9wruMcrGadany3' -}) // => Client +}); // => Client ``` -### via Dynamic Registration -Should your provider support Dynamic Registration and/or provided you with a registration client uri -and registration access token you can also have the Client discovered. +### via registration client uri +Should your oidc provider have provided you with a registration client uri and registration access +token you can also have the Client discovered. ```js -new googleProvider.Client.fromUri(registration_client_uri, registration_access_token) // => Promise +new googleIssuer.Client.fromUri(registration_client_uri, registration_access_token) // => Promise .then(function (client) { - console.log('Discovered client %s', client.client_id); - }) + console.log('Discovered client %s', client); + }); ``` ## Usage @@ -81,6 +86,45 @@ client.refresh(refreshToken) // => Promise }); ``` +### Revoke a token +```js +client.revoke(token) // => Promise + .then(function () { + console.log('revoked token %j', token); + }); +``` + +### Introspect a token +```js +client.introspect(token) // => Promise + .then(function (details) { + console.log('token details %j', details); + }); +``` + +### Fetching userinfo +```js +client.userinfo(accessToken) // => Promise + .then(function (userinfo) { + console.log('userinfo %j', userinfo); + }); +``` + +via POST +```js +client.userinfo(accessToken, { verb: 'post' }); // => Promise +``` + +auth via query +```js +client.userinfo(accessToken, { via: 'query' }); // => Promise +``` + +auth via body +```js +client.userinfo(accessToken, { verb: 'post', via: 'body' }); // => Promise +``` + ### Custom token endpoint grants Use when the token endpoint also supports client_credentials or password grants; @@ -96,12 +140,22 @@ client.grant({ }); // => Promise ``` -[travis-image]: https://img.shields.io/travis/panva/node-oidc-client/master.svg?style=flat-square&maxAge=7200 -[travis-url]: https://travis-ci.org/panva/node-oidc-client -[codecov-image]: https://img.shields.io/codecov/c/github/panva/node-oidc-client/master.svg?style=flat-square&maxAge=7200 -[codecov-url]: https://codecov.io/gh/panva/node-oidc-client -[npm-image]: https://img.shields.io/npm/v/oidc-client.svg?style=flat-square&maxAge=7200 -[npm-url]: https://www.npmjs.com/package/oidc-client -[licence-image]: https://img.shields.io/github/license/panva/node-oidc-client.svg?style=flat-square&maxAge=7200 +### Registering new client (via Dynamic Registration) +```js +issuer.Client.register(metadata, [keystore]) // => Promise + .then(function (client) { + console.log('Registered client %s, %j', client, client.metadata); + }); +``` + +[travis-image]: https://img.shields.io/travis/panva/node-openid-client/master.svg?style=flat-square&maxAge=7200 +[travis-url]: https://travis-ci.org/panva/node-openid-client +[codecov-image]: https://img.shields.io/codecov/c/github/panva/node-openid-client/master.svg?style=flat-square&maxAge=7200 +[codecov-url]: https://codecov.io/gh/panva/node-openid-client +[npm-image]: https://img.shields.io/npm/v/openid-client.svg?style=flat-square&maxAge=7200 +[npm-url]: https://www.npmjs.com/package/openid-client +[licence-image]: https://img.shields.io/github/license/panva/node-openid-client.svg?style=flat-square&maxAge=7200 [licence-url]: LICENSE.md [openid-connect]: http://openid.net/connect/ +[heroku-example]: https://tranquil-reef-95185.herokuapp.com/client +[oidc-provider]: https://github.com/panva/node-oidc-provider diff --git a/example/app.js b/example/app.js new file mode 100644 index 00000000..e172de68 --- /dev/null +++ b/example/app.js @@ -0,0 +1,208 @@ +/* eslint-disable import/no-extraneous-dependencies, func-names */ +'use strict'; + +const _ = require('lodash'); +const decode = require('base64url').decode; +const koa = require('koa'); +const crypto = require('crypto'); +const url = require('url'); +const uuid = require('node-uuid').v4; +const jose = require('node-jose'); +const path = require('path'); +const Router = require('koa-router'); +const session = require('koa-session'); +const render = require('koa-ejs'); + +module.exports = issuer => { + const app = koa(); + + if (process.env.HEROKU) { + app.proxy = true; + + app.use(function * (next) { + if (this.secure) { + yield next; + } else { + this.redirect(this.href.replace(/^http:\/\//i, 'https://')); + } + }); + } + + app.keys = ['some secret hurr']; + app.use(session(app)); + + const CLIENTS = new Map(); + const TOKENS = new Map(); + + render(app, { + cache: false, + layout: '_layout', + root: path.join(__dirname, 'views'), + }); + + app.use(function * (next) { + this.session.id = this.session.id || uuid(); + yield next; + }); + + app.use(function * (next) { + try { + yield next; + } catch (error) { + yield this.render('error', { error, session: this.session }); + } + }); + + app.use(function * (next) { + if (!CLIENTS.has(this.session.id)) { + const keystore = jose.JWK.createKeyStore(); + yield keystore.generate.apply(keystore, + _.sample([['RSA', 2048], ['EC', _.sample(['P-256', 'P-384', 'P-521'])]])); + + const client = yield issuer.Client.register({ + grant_types: ['authorization_code', 'refresh_token'], + post_logout_redirect_uris: [url.resolve(this.href, '/')], + redirect_uris: [url.resolve(this.href, 'cb')], + response_types: ['code'], + // token_endpoint_auth_method: 'client_secret_jwt', + token_endpoint_auth_method: 'private_key_jwt', + // id_token_signed_response_alg: 'HS256', + // token_endpoint_auth_signing_alg: 'HS256', + // }); + }, keystore); + CLIENTS.set(this.session.id, client); + } + yield next; + }); + + const router = new Router(); + + router.get('/', function * () { + yield this.render('index', { session: this.session }); + }); + + router.get('/issuer', function * () { + yield this.render('issuer', { + issuer, + keystore: (yield issuer.keystore()), + session: this.session, + }); + }); + + router.get('/client', function * () { + yield this.render('client', { client: CLIENTS.get(this.session.id), session: this.session }); + }); + + router.get('/logout', function * () { + const id = this.session.id; + this.session = null; + + if (!TOKENS.has(id)) { + return this.redirect('/'); + } + + const tokens = TOKENS.get(id); + + yield CLIENTS.get(id).revoke(tokens.access_token); + + return this.redirect(url.format(Object.assign(url.parse(issuer.end_session_endpoint), { + search: null, + query: { + id_token_hint: tokens.id_token, + post_logout_redirect_uri: url.resolve(this.href, '/'), + }, + }))); + }); + + router.get('/login', function * () { + this.session.state = crypto.randomBytes(16).toString('hex'); + this.session.nonce = crypto.randomBytes(16).toString('hex'); + const authz = CLIENTS.get(this.session.id).authorizationUrl({ + claims: { + id_token: { email_verified: null }, + userinfo: { sub: null, email: null }, + }, + redirect_uri: url.resolve(this.href, 'cb'), + scope: 'openid', + // prompt: 'consent', + state: this.session.state, + nonce: this.session.nonce, + }); + + this.redirect(authz); + }); + + router.get('/refresh', function * () { + if (!TOKENS.has(this.session.id)) { + this.session = null; + return this.redirect('/'); + } + + const tokens = TOKENS.get(this.session.id); + const client = CLIENTS.get(this.session.id); + + TOKENS.set( + this.session.id, + yield client.refresh(tokens) + ); + + return this.redirect('/user'); + }); + + router.get('/cb', function * () { + const state = this.session.state; + delete this.session.state; + const nonce = this.session.nonce; + delete this.session.nonce; + + TOKENS.set( + this.session.id, + yield CLIENTS.get(this.session.id) + .authorizationCallback(url.resolve(this.href, 'cb'), this.query, { nonce, state })); + + this.session.loggedIn = true; + + this.redirect('/user'); + }); + + router.get('/user', function * () { + if (!TOKENS.has(this.session.id)) { + this.session = null; + return this.redirect('/'); + } + const tokens = TOKENS.get(this.session.id); + const client = CLIENTS.get(this.session.id); + + const context = { + tokens, + userinfo: (yield client.userinfo(tokens).catch(() => {})), + id_token: tokens.id_token ? _.map(tokens.id_token.split('.'), part => { + try { + return JSON.parse(decode(part)); + } catch (err) { + return part; + } + }) : undefined, + session: this.session, + introspections: {}, + }; + + const introspections = _.map(tokens, (value, key) => { + if (key.endsWith('token') && key !== 'id_token') { + return client.introspect(value).then((response) => { + context.introspections[key] = response; + }); + } + return undefined; + }); + + yield Promise.all(introspections); + + return yield this.render('user', context); + }); + + app.use(router.routes()); + app.use(router.allowedMethods()); + + return app; +}; diff --git a/example/index.js b/example/index.js new file mode 100644 index 00000000..1ca932ae --- /dev/null +++ b/example/index.js @@ -0,0 +1,12 @@ +'use strict'; + +const Issuer = require('..').Issuer; +const ISSUER = process.env.ISSUER || 'https://guarded-cliffs-8635.herokuapp.com'; +const port = process.env.PORT || 3001; + +const appFactory = require('./app'); + +Issuer.discover(ISSUER).then(issuer => { + const app = appFactory(issuer); + app.listen(port); +}).catch(() => process.exit(1)); diff --git a/example/views/_layout.html b/example/views/_layout.html new file mode 100644 index 00000000..185adbca --- /dev/null +++ b/example/views/_layout.html @@ -0,0 +1,41 @@ + + + + + + + Client Example + + + + + + + +
+ <%- body %> +
+ + diff --git a/example/views/client.html b/example/views/client.html new file mode 100644 index 00000000..7e9be9a8 --- /dev/null +++ b/example/views/client.html @@ -0,0 +1,2 @@ +

client - <%= client.client_id %>

+
<%= JSON.stringify(client.metadata, null, 4) %>
diff --git a/example/views/error.html b/example/views/error.html new file mode 100644 index 00000000..62114df4 --- /dev/null +++ b/example/views/error.html @@ -0,0 +1,5 @@ +

error encountered

+<% if (error.error) { %> +
<%= JSON.stringify(error) %>
+<% } %> +
<%= error.stack %>
diff --git a/example/views/index.html b/example/views/index.html new file mode 100644 index 00000000..e69de29b diff --git a/example/views/issuer.html b/example/views/issuer.html new file mode 100644 index 00000000..374c2ac6 --- /dev/null +++ b/example/views/issuer.html @@ -0,0 +1,4 @@ +

issuer - <%= issuer.issuer %>

+
<%= JSON.stringify(issuer.metadata, null, 4) %>
+

issuer jwks

+
<%= JSON.stringify(keystore.all(), null, 4) %>
diff --git a/example/views/user.html b/example/views/user.html new file mode 100644 index 00000000..4b759c63 --- /dev/null +++ b/example/views/user.html @@ -0,0 +1,20 @@ +

tokens - <%= tokens.expired() ? 'expired' : `fresh (expires in ${tokens.expires_in} seconds)` %>

+<% if (tokens.refresh_token) { %> +Refresh +<% } %> +
<%= JSON.stringify(tokens, null, 4) %>
+ +<% if (id_token) { %> +

id_token validated and decoded

+
<%= JSON.stringify(id_token, null, 4) %>
+<% } %> + +<% if (userinfo) { %> +

userinfo response

+
<%= JSON.stringify(userinfo, null, 4) %>
+<% } %> + +<% if (introspections) { %> +

introspection responses

+
<%= JSON.stringify(introspections, null, 4) %>
+<% } %> diff --git a/lib/base_client.js b/lib/base_client.js index 76cad768..6086e7dd 100644 --- a/lib/base_client.js +++ b/lib/base_client.js @@ -3,66 +3,88 @@ const util = require('util'); const assert = require('assert'); const jose = require('node-jose'); +const uuid = require('node-uuid').v4; const base64url = require('base64url'); const url = require('url'); -const { merge, defaults, pick, forEach } = require('lodash'); +const _ = require('lodash'); const TokenSet = require('./token_set'); const tokenHash = require('./token_hash'); +const isStandardError = require('./is_standard_error'); +const OpenIdConnectError = require('./open_id_connect_error'); -const { - USER_AGENT, - CLIENT_METADATA, - CLIENT_DEFAULTS, -} = require('./consts'); - -const debug = require('debug')('oidc:client'); +const CALLBACK_PROPERTIES = require('./consts').CALLBACK_PROPERTIES; +const CLIENT_METADATA = require('./consts').CLIENT_METADATA; +const CLIENT_DEFAULTS = require('./consts').CLIENT_DEFAULTS; const got = require('got'); const map = new WeakMap(); +function bearer(token) { + return `Bearer ${token}`; +} + function instance(ctx) { if (!map.has(ctx)) map.set(ctx, {}); return map.get(ctx); } class BaseClient { - constructor(metadata) { - forEach(defaults(pick(metadata, CLIENT_METADATA), CLIENT_DEFAULTS), (value, key) => { + constructor(metadata, keystore) { + _.forEach(_.defaults(_.pick(metadata, CLIENT_METADATA), CLIENT_DEFAULTS), (value, key) => { instance(this)[key] = value; }); + + if (keystore !== undefined) { + assert.ok(jose.JWK.isKeyStore(keystore), 'keystore must be an instance of jose.JWK.KeyStore'); + instance(this).keystore = keystore; + } + + if (this.token_endpoint_auth_method.endsWith('_jwt')) { + assert.ok(this.issuer.token_endpoint_auth_signing_alg_values_supported, + 'token_endpoint_auth_signing_alg_values_supported must be provided on the issuer'); + } } authorizationUrl(params) { - const query = defaults(params, { + assert.ok(typeof params === 'object', 'you must provide an object'); + + const query = _.defaults(params, { client_id: this.client_id, scope: 'openid', response_type: 'code', }); if (typeof query.claims === 'object') { - query.claims = JSON.stringify(query.claims, ['id_token', 'userinfo']); + query.claims = JSON.stringify(query.claims); } - return url.format(defaults({ + return url.format(_.defaults({ search: null, query, - }, url.parse(this.provider.authorization_endpoint))); + }, url.parse(this.issuer.authorization_endpoint))); } - authorizationCallback(redirectUri, params) { + authorizationCallback(redirectUri, parameters, checks) { + const params = _.pick(parameters, CALLBACK_PROPERTIES); + const toCheck = checks || {}; + if (params.error) { - return Promise.reject(pick(params, 'error', 'error_description', 'state')); + return Promise.reject(new OpenIdConnectError(params)); + } + + if (toCheck.state !== parameters.state) { + return Promise.reject(new Error('state mismatch')); } return this.grant({ grant_type: 'authorization_code', code: params.code, redirect_uri: redirectUri, - }).then(tokenset => this.validateIdToken(tokenset)); + }).then(tokenset => this.validateIdToken(tokenset, toCheck.nonce)); } - validateIdToken(token) { + validateIdToken(token, nonce) { let idToken = token; if (idToken instanceof TokenSet) { @@ -89,18 +111,22 @@ class BaseClient { assert.equal(this.id_token_signed_response_alg, headerObject.alg, 'unexpected algorithm used'); ['iss', 'sub', 'aud', 'exp', 'iat'].forEach(verifyPresence); - assert.equal(this.provider.issuer, payloadObject.iss, 'unexpected iss value'); + assert.equal(this.issuer.issuer, payloadObject.iss, 'unexpected iss value'); - assert(typeof payloadObject.iat === 'number', 'iat is not a number'); - assert(payloadObject.iat <= now, 'id_token issued in the future'); + assert.ok(typeof payloadObject.iat === 'number', 'iat is not a number'); + assert.ok(payloadObject.iat <= now, 'id_token issued in the future'); if (payloadObject.nbf !== undefined) { - assert(typeof payloadObject.nbf === 'number', 'nbf is not a number'); - assert(payloadObject.nbf <= now, 'id_token not active yet'); + assert.ok(typeof payloadObject.nbf === 'number', 'nbf is not a number'); + assert.ok(payloadObject.nbf <= now, 'id_token not active yet'); } - assert(typeof payloadObject.exp === 'number', 'exp is not a number'); - assert(now < payloadObject.exp, 'id_token expired'); + if (payloadObject.nonce || nonce !== undefined) { + assert.equal(payloadObject.nonce, nonce, 'nonce mismatch'); + } + + assert.ok(typeof payloadObject.exp === 'number', 'exp is not a number'); + assert.ok(now < payloadObject.exp, 'id_token expired'); if (payloadObject.azp !== undefined) { assert.equal(this.client_id, payloadObject.azp, 'azp must be the client_id'); @@ -112,7 +138,7 @@ class BaseClient { throw new Error('missing required JWT property azp'); } - assert(payloadObject.aud.indexOf(this.client_id) !== -1, 'aud is missing the client_id'); + assert.ok(payloadObject.aud.indexOf(this.client_id) !== -1, 'aud is missing the client_id'); if (payloadObject.at_hash && token.access_token) { assert.equal(payloadObject.at_hash, tokenHash(token.access_token, headerObject.alg), @@ -124,17 +150,11 @@ class BaseClient { 'c_hash mismatch'); } - return this.provider.key(headerObject) + return (headerObject.alg.startsWith('HS') ? this.joseSecret() : this.issuer.key(headerObject)) .then(key => jose.JWS.createVerify(key).verify(idToken)) .then(() => token); } - // implicitCallback(params, verify) { - // if (params.error) { - // return Promise.reject(pick(params, 'error', 'error_description', 'state')); - // } - // } - refresh(refreshToken) { let token = refreshToken; @@ -151,25 +171,127 @@ class BaseClient { }).then(tokenset => this.validateIdToken(tokenset)); } - grant(body) { - const auth = this.grantAuth(); - debug('client %s %s grant request started', this.client_id, body.grant_type); - - return got.post(this.provider.token_endpoint, merge({ - body, - retries: 0, - followRedirect: false, - headers: { - 'User-Agent': USER_AGENT, - }, - }, auth)).then(response => new TokenSet(JSON.parse(response.body)), err => { - debug('client %s grant request failed (%s > %s)', this.client_id, err.name, err.message); + userinfo(accessToken, options) { + let token = accessToken; + const opts = _.merge({ + verb: 'get', + via: 'header', + }, options); + + if (token instanceof TokenSet) { + if (!token.access_token) { + return Promise.reject(new Error('access_token not present in TokenSet')); + } + token = token.access_token; + } + + const verb = String(opts.verb).toLowerCase(); + let httpOptions; + + switch (opts.via) { + case 'query': + assert.equal(verb, 'get', 'providers should only parse query strings for GET requests'); + httpOptions = { query: { access_token: token } }; + break; + case 'body': + assert.equal(verb, 'post', 'can only send body on POST'); + httpOptions = { body: { access_token: token } }; + break; + default: + httpOptions = { headers: { Authorization: bearer(token) } }; + } + + return got[verb](this.issuer.userinfo_endpoint, this.issuer.httpOptions( + httpOptions + )).then(response => JSON.parse(response.body), err => { + if (isStandardError(err)) throw new OpenIdConnectError(err.response.body); throw err; }); } + joseSecret() { + if (instance(this).jose_secret) { + return Promise.resolve(instance(this).jose_secret); + } + + return jose.JWK.asKey({ + k: base64url(new Buffer(this.client_secret)), + kty: 'oct', + }).then(key => { + instance(this).jose_secret = key; + return key; + }); + } + + grant(body) { + return this.authenticatedPost(this.issuer.token_endpoint, { body }, + response => new TokenSet(JSON.parse(response.body))); + } + + revoke(token) { + assert.ok(this.issuer.revocation_endpoint || this.issuer.token_revocation_endpoint, + 'issuer must be configured with revocation endpoint'); + const endpoint = this.issuer.revocation_endpoint || this.issuer.token_revocation_endpoint; + return this.authenticatedPost(endpoint, { body: { token } }, + response => JSON.parse(response.body)); + } + + introspect(token) { + assert.ok(this.issuer.introspection_endpoint || this.issuer.token_introspection_endpoint, + 'issuer must be configured with introspection endpoint'); + const endpoint = this.issuer.introspection_endpoint || this.issuer.token_introspection_endpoint; + return this.authenticatedPost(endpoint, { body: { token } }, + response => JSON.parse(response.body)); + } + + authenticatedPost(endpoint, httpOptions, success) { + return Promise.resolve(this.grantAuth()) + .then(auth => got.post(endpoint, this.issuer.httpOptions(_.merge(httpOptions, auth))) + .then(success, err => { + if (isStandardError(err)) throw new OpenIdConnectError(err.response.body); + throw err; + })); + } + + createSign() { + let alg = this.token_endpoint_auth_signing_alg; + switch (this.token_endpoint_auth_method) { + case 'client_secret_jwt': + return this.joseSecret().then(key => { + if (!alg) { + alg = _.find(this.issuer.token_endpoint_auth_signing_alg_values_supported, + (signAlg) => key.algorithms('sign').indexOf(signAlg) !== -1); + } + + return jose.JWS.createSign({ + fields: { alg, typ: 'JWT' }, + format: 'compact', + }, { key, reference: false }); + }); + case 'private_key_jwt': { + if (!alg) { + const algz = _.uniq(_.flatten(_.map(this.keystore.all(), key => key.algorithms('sign')))); + alg = _.find(this.issuer.token_endpoint_auth_signing_alg_values_supported, + (signAlg) => algz.indexOf(signAlg) !== -1); + } + + const key = this.keystore.get({ alg }); + assert.ok(key, 'no valid key found'); + + return Promise.resolve(jose.JWS.createSign({ + fields: { alg, typ: 'JWT' }, + format: 'compact', + }, { key, reference: true })); + } + default: + throw new Error('createSign only works for _jwt token auth methods'); + } + } + grantAuth() { switch (this.token_endpoint_auth_method) { + case 'none' : + throw new Error('client not supposed to access token endpoint'); case 'client_secret_post': return { body: { @@ -177,13 +299,26 @@ class BaseClient { client_secret: this.client_secret, }, }; + case 'private_key_jwt' : + case 'client_secret_jwt' : { + const now = Math.floor(Date.now() / 1000); + return this.createSign().then(sign => sign.update(JSON.stringify({ + iat: now, + exp: now + 60, + jti: uuid(), + iss: this.client_id, + sub: this.client_id, + aud: this.issuer.token_endpoint, + })).final().then(client_assertion => { // eslint-disable-line camelcase, arrow-body-style + return { body: { + client_assertion, + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + } }; + })); + } default: { const value = new Buffer(`${this.client_id}:${this.client_secret}`).toString('base64'); - return { - headers: { - Authorization: `Basic ${value}`, - }, - }; + return { headers: { Authorization: `Basic ${value}` } }; } } } @@ -192,18 +327,40 @@ class BaseClient { return util.format('Client <%s>', this.client_id); } + static register(body, keystore) { + assert.ok(this.issuer.registration_endpoint, 'issuer does not support dynamic registration'); + + if (keystore !== undefined && !(body.jwks || body.jwks_uri)) { + assert.ok(jose.JWK.isKeyStore(keystore), 'keystore must be an instance of jose.JWK.KeyStore'); + assert.ok(keystore.all().every(key => { + try { key.toPEM(true); } catch (err) { return false; } + return key.kty === 'RSA' || key.kty === 'EC'; + }), 'keystore must only contain private EC or RSA keys'); + body.jwks = keystore.toJSON(); + } + + return got.post(this.issuer.registration_endpoint, this.issuer.httpOptions({ + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + })).then(response => new this(JSON.parse(response.body), keystore), err => { + if (isStandardError(err)) throw new OpenIdConnectError(err.response.body); + throw err; + }); + } + + get keystore() { + return instance(this).keystore; + } + + get metadata() { + return _.omitBy(_.pick(this, CLIENT_METADATA), _.isUndefined); + } + static fromUri(uri, token) { - debug('fetching client from %s', uri); - - return got.get(uri, { - retries: 0, - followRedirect: false, - headers: { - Authorization: `Bearer ${token}`, - 'User-Agent': USER_AGENT, - }, - }).then(response => new this(JSON.parse(response.body)), err => { - debug('%s request failed (%s > %s)', uri, err.name, err.message); + return got.get(uri, this.issuer.httpOptions({ + headers: { Authorization: bearer(token) }, + })).then(response => new this(JSON.parse(response.body)), err => { + if (isStandardError(err)) throw new OpenIdConnectError(err.response.body); throw err; }); } diff --git a/lib/consts.js b/lib/consts.js index 5a80c248..5ab8cd8e 100644 --- a/lib/consts.js +++ b/lib/consts.js @@ -4,19 +4,26 @@ const USER_AGENT = `${pkg.name}/${pkg.version} (${pkg.homepage})`; const WELL_KNOWN = '/.well-known/openid-configuration'; -const PROVIDER_METADATA = [ +const ISSUER_METADATA = [ 'acr_values_supported', 'authorization_endpoint', + 'check_session_iframe', 'claims_parameter_supported', 'claims_supported', + 'end_session_endpoint', 'grant_types_supported', + 'id_token_encryption_alg_values_supported', + 'id_token_encryption_enc_values_supported', 'id_token_signing_alg_values_supported', 'issuer', 'jwks_uri', 'registration_endpoint', + 'request_object_encryption_alg_values_supported', + 'request_object_encryption_enc_values_supported', 'request_object_signing_alg_values_supported', 'request_parameter_supported', 'request_uri_parameter_supported', + 'require_request_uri_registration', 'response_modes_supported', 'response_types_supported', 'scopes_supported', @@ -25,17 +32,13 @@ const PROVIDER_METADATA = [ 'token_endpoint_auth_methods_supported', 'token_endpoint_auth_signing_alg_values_supported', 'token_introspection_endpoint', + 'introspection_endpoint', 'token_revocation_endpoint', - 'userinfo_endpoint', - 'userinfo_signing_alg_values_supported', - 'id_token_encryption_alg_values_supported', - 'id_token_encryption_enc_values_supported', + 'revocation_endpoint', 'userinfo_encryption_alg_values_supported', 'userinfo_encryption_enc_values_supported', - 'request_object_encryption_alg_values_supported', - 'request_object_encryption_enc_values_supported', - 'check_session_iframe', - 'end_session_endpoint', + 'userinfo_endpoint', + 'userinfo_signing_alg_values_supported', ]; const CLIENT_METADATA = [ @@ -60,6 +63,7 @@ const CLIENT_METADATA = [ 'post_logout_redirect_uris', 'redirect_uris', 'registration_access_token', + 'registration_client_uri', 'request_object_encryption_alg', 'request_object_encryption_enc', 'request_object_signing_alg', @@ -76,27 +80,39 @@ const CLIENT_METADATA = [ 'userinfo_signed_response_alg', ]; -const PROVIDER_DEFAULTS = { - response_modes_supported: ['query', 'fragment'], - grant_types_supported: ['authorization_code', 'implicit'], - token_endpoint_auth_methods_supported: ['client_secret_basic'], +const ISSUER_DEFAULTS = { claims_parameter_supported: false, + grant_types_supported: ['authorization_code', 'implicit'], request_parameter_supported: false, request_uri_parameter_supported: true, require_request_uri_registration: false, + response_modes_supported: ['query', 'fragment'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], }; const CLIENT_DEFAULTS = { - response_types: ['code'], - grant_types: ['authorization_code'], application_type: ['web'], + grant_types: ['authorization_code'], id_token_signed_response_alg: 'RS256', + response_types: ['code'], token_endpoint_auth_method: 'client_secret_basic', }; -module.exports.WELL_KNOWN = WELL_KNOWN; -module.exports.PROVIDER_DEFAULTS = PROVIDER_DEFAULTS; -module.exports.PROVIDER_METADATA = PROVIDER_METADATA; +const CALLBACK_PROPERTIES = [ + 'access_token', + 'code', + 'error', + 'error_description', + 'expires_in', + 'id_token', + 'state', + 'token_type', +]; + +module.exports.CALLBACK_PROPERTIES = CALLBACK_PROPERTIES; module.exports.CLIENT_DEFAULTS = CLIENT_DEFAULTS; module.exports.CLIENT_METADATA = CLIENT_METADATA; +module.exports.ISSUER_DEFAULTS = ISSUER_DEFAULTS; +module.exports.ISSUER_METADATA = ISSUER_METADATA; module.exports.USER_AGENT = USER_AGENT; +module.exports.WELL_KNOWN = WELL_KNOWN; diff --git a/lib/index.js b/lib/index.js index c1d932ba..f7efad36 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,7 +1,7 @@ 'use strict'; -const Provider = require('./provider'); +const Issuer = require('./issuer'); module.exports = { - Provider, + Issuer, }; diff --git a/lib/is_standard_error.js b/lib/is_standard_error.js new file mode 100644 index 00000000..99030f67 --- /dev/null +++ b/lib/is_standard_error.js @@ -0,0 +1,14 @@ +'use strict'; + +const got = require('got'); + +module.exports = function isStandardError(error) { + if (error instanceof got.HTTPError) { + try { + error.response.body = JSON.parse(error.response.body); + return !!error.response.body.error; + } catch (err) {} + } + + return false; +}; diff --git a/lib/issuer.js b/lib/issuer.js new file mode 100644 index 00000000..cc843bce --- /dev/null +++ b/lib/issuer.js @@ -0,0 +1,115 @@ +'use strict'; + +const url = require('url'); +const jose = require('node-jose'); +const util = require('util'); +const _ = require('lodash'); + +const isStandardError = require('./is_standard_error'); +const OpenIdConnectError = require('./open_id_connect_error'); + +const USER_AGENT = require('./consts').USER_AGENT; +const WELL_KNOWN = require('./consts').WELL_KNOWN; +const ISSUER_METADATA = require('./consts').ISSUER_METADATA; +const ISSUER_DEFAULTS = require('./consts').ISSUER_DEFAULTS; + +const BaseClient = require('./base_client'); + +const got = require('got'); +const map = new WeakMap(); + +const DEFAULT_HTTP_OPTIONS = { + followRedirect: false, + headers: { 'User-Agent': USER_AGENT }, + retries: 0, + timeout: 1500, +}; +Object.freeze(DEFAULT_HTTP_OPTIONS); + +let defaultHttpOptions = _.clone(DEFAULT_HTTP_OPTIONS); + +function instance(ctx) { + if (!map.has(ctx)) map.set(ctx, {}); + return map.get(ctx); +} + +class Issuer { + constructor(metadata) { + _.forEach(_.defaults(_.pick(metadata, ISSUER_METADATA), ISSUER_DEFAULTS), (value, key) => { + instance(this)[key] = value; + }); + + const self = this; + + Object.defineProperty(this, 'Client', { + value: class Client extends BaseClient { + static get issuer() { + return self; + } + + get issuer() { + return this.constructor.issuer; + } + }, + }); + } + + inspect() { + return util.format('Issuer <%s>', this.issuer); + } + + keystore() { + return got.get(this.jwks_uri, this.httpOptions()) + .then(response => JSON.parse(response.body), err => { + if (isStandardError(err)) throw new OpenIdConnectError(err.response.body); + throw err; + }) + .then(jwks => jose.JWK.asKeyStore(jwks)); + } + + key(def) { + return this.keystore().then(store => store.get(def)); + } + + get metadata() { + return _.omitBy(_.pick(this, ISSUER_METADATA), _.isUndefined); + } + + static discover(uri) { + const isWellKnown = uri.endsWith(WELL_KNOWN); + const wellKnownUri = isWellKnown ? uri : url.resolve(uri, WELL_KNOWN); + + return got.get(wellKnownUri, this.httpOptions()) + .then(response => new this(JSON.parse(response.body)), err => { + if (isStandardError(err)) throw new OpenIdConnectError(err.response.body); + throw err; + }); + } + + httpOptions() { + return this.constructor.httpOptions.apply(this.constructor, arguments); // eslint-disable-line prefer-rest-params, max-len + } + + static httpOptions(values) { + return _.merge({}, this.defaultHttpOptions, values); + } + + static get defaultHttpOptions() { + return defaultHttpOptions; + } + + static set defaultHttpOptions(value) { + defaultHttpOptions = _.merge({}, DEFAULT_HTTP_OPTIONS, value); + } + +} + +ISSUER_METADATA.forEach(prop => { + Object.defineProperty(Issuer.prototype, prop, { + get() { + return instance(this)[prop]; + }, + }); +}); + +module.exports = Issuer; diff --git a/lib/open_id_connect_error.js b/lib/open_id_connect_error.js new file mode 100644 index 00000000..cf2506c4 --- /dev/null +++ b/lib/open_id_connect_error.js @@ -0,0 +1,13 @@ +'use strict'; + +const createErrorClass = require('create-error-class'); + +module.exports = createErrorClass('OpenIdConnectError', function stdError(response) { + Object.assign(this, { + message: response.error, + error: response.error, + error_description: response.error_description, + state: response.state, + scope: response.scope, + }); +}); diff --git a/lib/provider.js b/lib/provider.js deleted file mode 100644 index e4bebb6d..00000000 --- a/lib/provider.js +++ /dev/null @@ -1,94 +0,0 @@ -'use strict'; - -const url = require('url'); -const jose = require('node-jose'); -const util = require('util'); -const { defaults, pick, forEach } = require('lodash'); - -const { - USER_AGENT, - WELL_KNOWN, - PROVIDER_METADATA, - PROVIDER_DEFAULTS, -} = require('./consts'); - -const BaseClient = require('./base_client'); - -const debug = require('debug')('oidc:provider'); - -const got = require('got'); -const map = new WeakMap(); - -function instance(ctx) { - if (!map.has(ctx)) map.set(ctx, {}); - return map.get(ctx); -} - -class Provider { - constructor(metadata) { - forEach(defaults(pick(metadata, PROVIDER_METADATA), PROVIDER_DEFAULTS), (value, key) => { - instance(this)[key] = value; - }); - - const self = this; - - Object.defineProperty(this, 'Client', { - value: class Client extends BaseClient { - static get provider() { - return self; - } - - get provider() { - return this.constructor.provider; - } - }, - }); - } - - inspect() { - return util.format('Provider <%s>', this.issuer); - } - - keyStore() { - debug('%s request started', this.jwks_uri); - - return got.get(this.jwks_uri) - .then(response => JSON.parse(response.body), err => { - debug('%s request failed (%s > %s)', this.provider.jwks_uri, err.name, err.message); - throw err; - }) - .then(jwks => jose.JWK.asKeyStore(jwks)); - } - - key(def) { - return this.keyStore().then(store => store.get(def)); - } - - static discover(uri) { - const isWellKnown = uri.endsWith(WELL_KNOWN); - const wellKnownUri = isWellKnown ? uri : url.resolve(uri, WELL_KNOWN); - - debug('discovering configuration from %s', wellKnownUri); - - return got.get(wellKnownUri, { - retries: 0, - followRedirect: false, - headers: { - 'User-Agent': USER_AGENT, - }, - }).then(response => new this(JSON.parse(response.body)), err => { - debug('%s discovery failed (%s > %s)', wellKnownUri, err.name, err.message); - throw err; - }); - } -} - -PROVIDER_METADATA.forEach(prop => { - Object.defineProperty(Provider.prototype, prop, { - get() { - return instance(this)[prop]; - }, - }); -}); - -module.exports = Provider; diff --git a/package.json b/package.json index 86157b3e..5afac026 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,18 @@ { - "name": "oidc-client", + "name": "openid-client", "version": "0.0.1", - "description": "OpenID Relying Party (RP, Client) implementation for Node.js", + "description": "OpenID Connect Relying Party (RP, Client) implementation for Node.js", "main": "lib/index.js", "scripts": { "coverage": "make coverage", "test": "make test", "lint": "eslint lib example test --fix" }, - "repository": "panva/node-oidc-client", + "repository": "panva/node-openid-client", "engines": { - "node": ">=6" + "node": ">=4" }, - "homepage": "https://github.com/panva/node-oidc-client", + "homepage": "https://github.com/panva/node-openid-client", "keywords": [ "openid", "connect", @@ -34,16 +34,19 @@ "eslint-config-airbnb-base": "^4.0.0", "eslint-plugin-import": "^1.10.2", "istanbul": "^0.4.4", + "koa": "^1.2.0", + "koa-ejs": "^3.0.0", + "koa-router": "^5.4.0", + "koa-session": "^3.3.1", "mocha": "^2.5.3", "nock": "^8.0.0", "sinon": "^1.17.4" }, "dependencies": { "base64url": "^1.0.6", - "debug": "^2.2.0", + "create-error-class": "^3.0.2", "got": "^6.3.0", "lodash": "^4.13.1", - "lru-cache": "^4.0.1", "node-jose": "^0.8.0", "node-uuid": "^1.4.7" } diff --git a/test/.eslintrc b/test/.eslintrc index 8fa7b291..6b4993e2 100644 --- a/test/.eslintrc +++ b/test/.eslintrc @@ -3,10 +3,8 @@ "mocha": true }, "rules": { - "max-len": "off", "func-names": "off", "prefer-arrow-callback": "off", - "comma-dangle": "off", "no-unused-expressions": "off", "import/no-extraneous-dependencies": "off" } diff --git a/test/client/client_instance.test.js b/test/client/client_instance.test.js new file mode 100644 index 00000000..09a2409c --- /dev/null +++ b/test/client/client_instance.test.js @@ -0,0 +1,346 @@ +'use strict'; + +const Issuer = require('../../lib').Issuer; +const expect = require('chai').expect; +const BaseClient = require('../../lib/base_client'); +const url = require('url'); +const querystring = require('querystring'); +const nock = require('nock'); +const OpenIdConnectError = require('../../lib/open_id_connect_error'); +const sinon = require('sinon'); +const TokenSet = require('../../lib/token_set'); +const got = require('got'); +const fail = () => { + throw new Error('expected promise to be rejected'); +}; + +describe('Client', function () { + describe('#authorizationUrl', function () { + before(function () { + const issuer = new Issuer({ + authorization_endpoint: 'https://op.example.com/auth', + }); + this.client = new issuer.Client({ + client_id: 'identifier', + }); + }); + + it('returns a string with the url with some basic defaults', function () { + expect(url.parse(this.client.authorizationUrl({ + redirect_uri: 'https://rp.example.com/cb', + }), true).query).to.eql({ + client_id: 'identifier', + redirect_uri: 'https://rp.example.com/cb', + response_type: 'code', + scope: 'openid', + }); + }); + + it('allows to overwrite the defaults', function () { + expect(url.parse(this.client.authorizationUrl({ + scope: 'openid offline_access', + redirect_uri: 'https://rp.example.com/cb', + response_type: 'id_token', + }), true).query).to.eql({ + client_id: 'identifier', + scope: 'openid offline_access', + redirect_uri: 'https://rp.example.com/cb', + response_type: 'id_token', + }); + }); + + it('allows any other params to be provide too', function () { + expect(url.parse(this.client.authorizationUrl({ + state: 'state', + custom: 'property', + }), true).query).to.contain({ + state: 'state', + custom: 'property', + }); + }); + + it('auto-stringifies claims parameter', function () { + expect(url.parse(this.client.authorizationUrl({ + claims: { id_token: { email: null } }, + }), true).query).to.contain({ + claims: '{"id_token":{"email":null}}', + }); + }); + }); + + describe('#authorizationCallback', function () { + before(function () { + const issuer = new Issuer({ + token_endpoint: 'https://op.example.com/auth', + }); + this.client = new issuer.Client({ + client_id: 'identifier', + client_secret: 'secure', + }); + + sinon.stub(this.client, 'validateIdToken', function (value) { + return value; + }); + }); + + afterEach(function () { + nock.cleanAll(); + }); + + it('does an authorization_code grant with code and redirect_uri', function () { + nock('https://op.example.com') + .filteringRequestBody(function (body) { + expect(querystring.parse(body)).to.eql({ + code: 'codeValue', + redirect_uri: 'https://rp.example.com/cb', + grant_type: 'authorization_code', + }); + }) + .post('/auth') + .reply(200, {}); + + return this.client.authorizationCallback('https://rp.example.com/cb', { + code: 'codeValue', + }); + }); + + it('returns a TokenSet', function () { + nock('https://op.example.com') + .post('/auth') + .reply(200, { + access_token: 'tokenValue', + }); + + return this.client.authorizationCallback('https://rp.example.com/cb', {}) + .then(set => { + expect(set).to.be.instanceof(TokenSet); + expect(set).to.have.property('access_token', 'tokenValue'); + }); + }); + + it('rejects with OpenIdConnectError when part of the response', function () { + return this.client.authorizationCallback('https://rp.example.com/cb', { + error: 'invalid_request', + }).then(fail, error => { + expect(error).to.be.instanceof(OpenIdConnectError); + expect(error).to.have.property('message', 'invalid_request'); + }); + }); + + it('rejects with an Error when states mismatch (returned)', function () { + return this.client.authorizationCallback('https://rp.example.com/cb', { + state: 'should be checked for this', + }).then(fail, error => { + expect(error).to.be.instanceof(Error); + expect(error).to.have.property('message', 'state mismatch'); + }); + }); + + it('rejects with an Error when states mismatch (not returned)', function () { + return this.client.authorizationCallback('https://rp.example.com/cb', {}, { + state: 'should be this', + }) + .then(fail, error => { + expect(error).to.be.instanceof(Error); + expect(error).to.have.property('message', 'state mismatch'); + }); + }); + + it('rejects with an Error when states mismatch (general mismatch)', function () { + return this.client.authorizationCallback('https://rp.example.com/cb', { + state: 'is this', + }, { + state: 'should be this', + }) + .then(fail, error => { + expect(error).to.be.instanceof(Error); + expect(error).to.have.property('message', 'state mismatch'); + }); + }); + }); + + it('#joseSecret', function () { + const client = new BaseClient({ client_secret: 'rj_JR' }); + + return client.joseSecret() + .then(key => { + // TODO: check the "k" value + expect(key).to.have.property('kty', 'oct'); + return client.joseSecret().then(cached => { + expect(key).to.equal(cached); + }); + }); + }); + + it('#inspect', function () { + const issuer = new Issuer({ issuer: 'https://op.example.com' }); + const client = new issuer.Client({ client_id: 'identifier' }); + expect(client.inspect()).to.equal('Client '); + }); + + it('#metadata returns a copy of the clients metadata', function () { + const issuer = new Issuer({ issuer: 'https://op.example.com' }); + const client = new issuer.Client({ client_id: 'identifier' }); + const expected = { + application_type: ['web'], + client_id: 'identifier', + grant_types: ['authorization_code'], + id_token_signed_response_alg: 'RS256', + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_basic', + }; + expect(client.metadata).not.to.equal(expected); + expect(client.metadata).to.eql(expected); + }); + + describe('#userinfo', function () { + afterEach(function () { + nock.cleanAll(); + }); + + it('takes a string token', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + nock('https://op.example.com') + .matchHeader('authorization', 'Bearer tokenValue') + .get('/me').reply(200, {}); + + return client.userinfo('tokenValue').then(() => { + expect(nock.isDone()).to.be.true; + }); + }); + + it('takes a tokenset', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + nock('https://op.example.com') + .matchHeader('authorization', 'Bearer tokenValue') + .get('/me').reply(200, {}); + + return client.userinfo(new TokenSet({ + id_token: 'foo', + refresh_token: 'bar', + access_token: 'tokenValue', + })).then(() => { + expect(nock.isDone()).to.be.true; + }); + }); + + it('validates an access token is present in the tokenset', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + return client.userinfo(new TokenSet({ + id_token: 'foo', + refresh_token: 'bar', + })).then(fail, (err) => { + expect(err.message).to.equal('access_token not present in TokenSet'); + }); + }); + + it('can do a post call', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + nock('https://op.example.com') + .post('/me').reply(200, {}); + + return client.userinfo('tokenValue', { verb: 'POST' }).then(() => { + expect(nock.isDone()).to.be.true; + }); + }); + + it('can submit access token in a body when post', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + nock('https://op.example.com') + .filteringRequestBody(/^access_token=tokenValue$/) + .post('/me').reply(200, {}); + + return client.userinfo('tokenValue', { verb: 'POST', via: 'body' }).then(() => { + expect(nock.isDone()).to.be.true; + }); + }); + + it('can only submit access token in a body when post', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + expect(function () { + client.userinfo('tokenValue', { via: 'body', verb: 'get' }); + }).to.throw('can only send body on POST'); + }); + + it('can submit access token in a query when get', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + nock('https://op.example.com') + .get('/me?access_token=tokenValue') + .reply(200, {}); + + return client.userinfo('tokenValue', { via: 'query' }).then(() => { + expect(nock.isDone()).to.be.true; + }); + }); + + it('can only submit access token in a query when get', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + expect(function () { + client.userinfo('tokenValue', { via: 'query', verb: 'post' }); + }).to.throw('providers should only parse query strings for GET requests'); + }); + + it('is rejected with OpenIdConnectError upon oidc error', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + nock('https://op.example.com') + .get('/me') + .reply(401, { + error: 'invalid_token', + error_description: 'bad things are happening', + }); + + return client.userinfo() + .then(fail, function (error) { + expect(error.name).to.equal('OpenIdConnectError'); + expect(error).to.have.property('message', 'invalid_token'); + }); + }); + + it('is rejected with when non 200 is returned', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + nock('https://op.example.com') + .get('/me') + .reply(500, 'Internal Server Error'); + + return client.userinfo() + .then(fail, function (error) { + expect(error).to.be.an.instanceof(got.HTTPError); + }); + }); + + it('is rejected with JSON.parse error upon invalid response', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client(); + + nock('https://op.example.com') + .get('/me') + .reply(200, '{"notavalid"}'); + + return client.userinfo() + .then(fail, function (error) { + expect(error).to.be.an.instanceof(SyntaxError); + expect(error).to.have.property('message').matches(/Unexpected token/); + }); + }); + }); +}); diff --git a/test/client/new_client.test.js b/test/client/new_client.test.js new file mode 100644 index 00000000..f6ccb817 --- /dev/null +++ b/test/client/new_client.test.js @@ -0,0 +1,64 @@ +'use strict'; + +const Issuer = require('../../lib/issuer'); +const Client = require('../../lib/base_client'); +const expect = require('chai').expect; + +describe('new Client()', function () { + it('accepts the recognized metadata', function () { + let client; + + expect(function () { + client = new Client({ + client_id: 'identifier', + client_secret: 'secure', + }); + }).not.to.throw(); + + expect(client).to.have.property('client_id', 'identifier'); + expect(client).to.have.property('client_secret', 'secure'); + }); + + it('ignores unrecognized metadata', function () { + const client = new Client({ + client_id: 'identifier', + client_secret: 'secure', + unrecognized: 'http://', + }); + + expect(client).not.to.have.property('unrecognized'); + }); + + it('assigns defaults to some properties', function () { + const client = new Client({ client_id: 'identifier' }); + + expect(client).to.have.property('application_type').eql(['web']); + expect(client).to.have.property('client_id', 'identifier'); + expect(client).to.have.property('grant_types').eql(['authorization_code']); + expect(client).to.have.property('id_token_signed_response_alg', 'RS256'); + expect(client).to.have.property('response_types').eql(['code']); + expect(client).to.have.property('token_endpoint_auth_method', 'client_secret_basic'); + }); + + context('with keystore', function () { + it('validates it is a keystore', function () { + [{}, [], 'not a keystore', 2, true, false].forEach(function () { + expect(function () { + new Client({}, 'not a keystore'); // eslint-disable-line no-new + }).to.throw('keystore must be an instance of jose.JWK.KeyStore'); + }); + }); + }); + + context('with token_endpoint_auth_method =~ _jwt', function () { + it('validates the issuer has supported algs announced', function () { + expect(function () { + const issuer = new Issuer(); + new issuer.Client({ // eslint-disable-line no-new + token_endpoint_auth_method: '_jwt', + }); + }).to.throw( + 'token_endpoint_auth_signing_alg_values_supported must be provided on the issuer'); + }); + }); +}); diff --git a/test/issuer/default_http_options.test.js b/test/issuer/default_http_options.test.js new file mode 100644 index 00000000..33c62d5b --- /dev/null +++ b/test/issuer/default_http_options.test.js @@ -0,0 +1,50 @@ +'use strict'; + +const Issuer = require('../../lib').Issuer; +const expect = require('chai').expect; + +describe('Issuer#defaultHttpOptions', function () { + it('does not follow redirects', function () { + expect(Issuer.defaultHttpOptions).to.have.property('followRedirect', false); + }); + + it('includes a user-agent by default', function () { + expect(Issuer.defaultHttpOptions).to.have.deep.property('headers.User-Agent') + .to.match(/^openid-client/); + }); + + it('does not retry', function () { + expect(Issuer.defaultHttpOptions).to.have.property('retries', 0); + }); + + it('has a rather graceous timeout', function () { + expect(Issuer.defaultHttpOptions).to.have.property('timeout', 1500); + }); +}); + +describe('Issuer#defaultHttpOptions=', function () { + before(function () { + this.defaultHttpOptions = Issuer.defaultHttpOptions; + }); + + afterEach(function () { + Issuer.defaultHttpOptions = this.defaultHttpOptions; + }); + + it('can be set to follow redirects', function () { + Issuer.defaultHttpOptions = { followRedirect: true }; + expect(Issuer.defaultHttpOptions).to.have.property('followRedirect', true); + }); + + it('can be set to send more headers by default', function () { + Issuer.defaultHttpOptions = { headers: { 'X-Meta-Id': 'meta meta' } }; + expect(Issuer.defaultHttpOptions).to.have.deep.property('headers.User-Agent') + .to.match(/^openid-client/); + expect(Issuer.defaultHttpOptions).to.have.deep.property('headers.X-Meta-Id', 'meta meta'); + }); + + it('can overwrite the timeout', function () { + Issuer.defaultHttpOptions = { timeout: 2500 }; + expect(Issuer.defaultHttpOptions).to.have.property('timeout', 2500); + }); +}); diff --git a/test/issuer/discover_issuer.test.js b/test/issuer/discover_issuer.test.js new file mode 100644 index 00000000..55cea2dd --- /dev/null +++ b/test/issuer/discover_issuer.test.js @@ -0,0 +1,80 @@ +'use strict'; + +const Issuer = require('../../lib').Issuer; +const expect = require('chai').expect; +const nock = require('nock'); +const got = require('got'); +const fail = () => { + throw new Error('expected promise to be rejected'); +}; + +describe('Issuer#discover()', function () { + it('accepts and assigns the disovered metadata', function () { + nock('https://op.example.com') + .get('/.well-known/openid-configuration') + .reply(200, { + authorization_endpoint: 'https://op.example.com/o/oauth2/v2/auth', + issuer: 'https://op.example.com', + jwks_uri: 'https://op.example.com/oauth2/v3/certs', + token_endpoint: 'https://op.example.com/oauth2/v4/token', + userinfo_endpoint: 'https://op.example.com/oauth2/v3/userinfo', + }); + + return Issuer.discover('https://op.example.com/.well-known/openid-configuration').then(function (issuer) { + expect(issuer).to.have.property('authorization_endpoint', 'https://op.example.com/o/oauth2/v2/auth'); + expect(issuer).to.have.property('issuer', 'https://op.example.com'); + expect(issuer).to.have.property('jwks_uri', 'https://op.example.com/oauth2/v3/certs'); + expect(issuer).to.have.property('token_endpoint', 'https://op.example.com/oauth2/v4/token'); + expect(issuer).to.have.property('userinfo_endpoint', 'https://op.example.com/oauth2/v3/userinfo'); + }); + }); + + it('can be discovered by ommiting the well-known part', function () { + nock('https://op.example.com') + .get('/.well-known/openid-configuration') + .reply(200, { + issuer: 'https://op.example.com', + }); + + return Issuer.discover('https://op.example.com').then(function (issuer) { + expect(issuer).to.have.property('issuer', 'https://op.example.com'); + }); + }); + + it('is rejected with OpenIdConnectError upon oidc error', function () { + nock('https://op.example.com') + .get('/.well-known/openid-configuration') + .reply(500, { + error: 'server_error', + error_description: 'bad things are happening', + }); + + return Issuer.discover('https://op.example.com') + .then(fail, function (error) { + expect(error).to.have.property('message', 'server_error'); + }); + }); + + it('is rejected with when non 200 is returned', function () { + nock('https://op.example.com') + .get('/.well-known/openid-configuration') + .reply(500, 'Internal Server Error'); + + return Issuer.discover('https://op.example.com') + .then(fail, function (error) { + expect(error).to.be.an.instanceof(got.HTTPError); + }); + }); + + it('is rejected with JSON.parse error upon invalid response', function () { + nock('https://op.example.com') + .get('/.well-known/openid-configuration') + .reply(200, '{"notavalid"}'); + + return Issuer.discover('https://op.example.com') + .then(fail, function (error) { + expect(error).to.be.an.instanceof(SyntaxError); + expect(error).to.have.property('message').matches(/Unexpected token/); + }); + }); +}); diff --git a/test/issuer/issuer_instance.test.js b/test/issuer/issuer_instance.test.js new file mode 100644 index 00000000..8db44e3d --- /dev/null +++ b/test/issuer/issuer_instance.test.js @@ -0,0 +1,27 @@ +'use strict'; + +const Issuer = require('../../lib').Issuer; +const expect = require('chai').expect; + +describe('Issuer', function () { + it('#inspect', function () { + const issuer = new Issuer({ issuer: 'https://op.example.com' }); + expect(issuer.inspect()).to.equal('Issuer '); + }); + + it('#metadata returns a copy of the issuers metadata', function () { + const issuer = new Issuer({ issuer: 'https://op.example.com' }); + const expected = { + claims_parameter_supported: false, + grant_types_supported: ['authorization_code', 'implicit'], + issuer: 'https://op.example.com', + request_parameter_supported: false, + request_uri_parameter_supported: true, + require_request_uri_registration: false, + response_modes_supported: ['query', 'fragment'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + }; + expect(issuer.metadata).not.to.equal(expected); + expect(issuer.metadata).to.eql(expected); + }); +}); diff --git a/test/issuer/new_issuer.test.js b/test/issuer/new_issuer.test.js new file mode 100644 index 00000000..9639be5a --- /dev/null +++ b/test/issuer/new_issuer.test.js @@ -0,0 +1,48 @@ +'use strict'; + +const Issuer = require('../../lib').Issuer; +const expect = require('chai').expect; + +describe('new Issuer()', function () { + it('accepts the recognized metadata', function () { + let issuer; + expect(function () { + issuer = new Issuer({ + issuer: 'https://accounts.google.com', + authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + token_endpoint: 'https://www.googleapis.com/oauth2/v4/token', + userinfo_endpoint: 'https://www.googleapis.com/oauth2/v3/userinfo', + jwks_uri: 'https://www.googleapis.com/oauth2/v3/certs', + }); + }).not.to.throw(); + + expect(issuer).to.have.property('authorization_endpoint', 'https://accounts.google.com/o/oauth2/v2/auth'); + expect(issuer).to.have.property('issuer', 'https://accounts.google.com'); + expect(issuer).to.have.property('jwks_uri', 'https://www.googleapis.com/oauth2/v3/certs'); + expect(issuer).to.have.property('token_endpoint', 'https://www.googleapis.com/oauth2/v4/token'); + expect(issuer).to.have.property('userinfo_endpoint', 'https://www.googleapis.com/oauth2/v3/userinfo'); + }); + + it('ignores unrecognized metadata', function () { + const issuer = new Issuer({ + issuer: 'https://accounts.google.com', + unrecognized: 'http://', + }); + + expect(issuer).not.to.have.property('unrecognized'); + }); + + it('assigns defaults to some properties', function () { + const issuer = new Issuer(); + + expect(issuer).to.have.property('claims_parameter_supported', false); + expect(issuer).to.have.property('grant_types_supported') + .to.eql(['authorization_code', 'implicit']); + expect(issuer).to.have.property('request_parameter_supported', false); + expect(issuer).to.have.property('request_uri_parameter_supported', true); + expect(issuer).to.have.property('require_request_uri_registration', false); + expect(issuer).to.have.property('response_modes_supported').to.eql(['query', 'fragment']); + expect(issuer).to.have.property('token_endpoint_auth_methods_supported') + .to.eql(['client_secret_basic']); + }); +}); diff --git a/test/tokenset/tokenset.test.js b/test/tokenset/tokenset.test.js new file mode 100644 index 00000000..535884c9 --- /dev/null +++ b/test/tokenset/tokenset.test.js @@ -0,0 +1,26 @@ +'use strict'; + +const expect = require('chai').expect; +const TokenSet = require('../../lib/token_set'); + +describe('TokenSet', function () { + it('sets the expire_at automatically from expires_in', function () { + const ts = new TokenSet({ + expires_in: 300, + }); + + expect(ts).to.have.property('expires_at', (Date.now() / 1000 | 0) + 300); + expect(ts).to.have.property('expires_in', 300); + expect(ts.expired()).to.be.false; + }); + + it('expired token sets', function () { + const ts = new TokenSet({ + expires_in: -30, + }); + + expect(ts).to.have.property('expires_at', (Date.now() / 1000 | 0) - 30); + expect(ts).to.have.property('expires_in', 0); + expect(ts.expired()).to.be.true; + }); +});