UNPKG

19 kBJavaScriptView Raw
1/**
2 * Module dependencies
3 */
4
5var qs = require('qs')
6var url = require('url')
7var async = require('async')
8var request = require('request-promise')
9var clientRoles = require('./rest/clientRoles')
10var clients = require('./rest/clients')
11var roles = require('./rest/roles')
12var roleScopes = require('./rest/roleScopes')
13var scopes = require('./rest/scopes')
14var users = require('./rest/users')
15var userRoles = require('./rest/userRoles')
16var IDToken = require('./lib/IDToken')
17var AccessToken = require('./lib/AccessToken')
18var UnauthorizedError = require('./errors/UnauthorizedError')
19var JWT = require('anvil-connect-jwt')
20
21/**
22 * OpenID Connect client (also an Anvil Connect server API client).
23 * @class AnvilConnect
24 * @param [options={}] {Object} Options hashmap object
25 * @param [options.agentOptions={}] {Object} Optional, passed to `request`
26 * library (see npm's `request` or `request-promise` for documentation)
27 * @param [options.issuer] {String} URL of the OIDC Provider. Required for
28 * most operations.
29 * @param [options.scope] {Array|String} Either an array or a space-separated
30 * string list of scopes. Defaults to ['openid', 'profile']
31 * @param [options.client_id] {String} Client ID (obtained after registering
32 * the client with the OP, either via `nvl client:register` cli tool, or
33 * via Dynamic Registration (`client.register()`).
34 * @param [options.client_secret] {String} Client Secret (obtained after
35 * registering the client with the OP, either via `nvl client:register` cli
36 * tool, or via Dynamic Registration (`client.register()`).
37 * @param [options.redirect_uri] {String} Optional client redirect endpoint
38 * @constructor
39 */
40function AnvilConnect (options) {
41 options = options || {}
42
43 // assign required options
44 this.issuer = options.issuer
45 this.client_id = options.client_id
46 this.client_secret = options.client_secret
47 this.redirect_uri = options.redirect_uri
48 this.agentOptions = options.agentOptions
49
50 this.clients = {
51 list: clients.list.bind(this),
52 get: clients.get.bind(this),
53 create: clients.create.bind(this),
54 update: clients.update.bind(this),
55 delete: clients.delete.bind(this),
56 roles: {
57 list: clientRoles.listRoles.bind(this),
58 add: clientRoles.addRole.bind(this),
59 delete: clientRoles.deleteRole.bind(this)
60 }
61 }
62
63 this.roles = {
64 list: roles.list.bind(this),
65 get: roles.get.bind(this),
66 create: roles.create.bind(this),
67 update: roles.update.bind(this),
68 delete: roles.delete.bind(this),
69 scopes: {
70 list: roleScopes.listScopes.bind(this),
71 add: roleScopes.addScope.bind(this),
72 delete: roleScopes.deleteScope.bind(this)
73 }
74 }
75
76 this.scopes = {
77 list: scopes.list.bind(this),
78 get: scopes.get.bind(this),
79 create: scopes.create.bind(this),
80 update: scopes.update.bind(this),
81 delete: scopes.delete.bind(this)
82 }
83
84 this.users = {
85 list: users.list.bind(this),
86 get: users.get.bind(this),
87 create: users.create.bind(this),
88 update: users.update.bind(this),
89 delete: users.delete.bind(this),
90 roles: {
91 list: userRoles.listRoles.bind(this),
92 add: userRoles.addRole.bind(this),
93 delete: userRoles.deleteRole.bind(this)
94 }
95 }
96
97 // add scope to defaults
98 var defaultScope = ['openid', 'profile']
99 if (typeof options.scope === 'string') {
100 this.scope = defaultScope.concat(options.scope.split(' ')).join(' ')
101 } else if (Array.isArray(options.scope)) {
102 this.scope = defaultScope.concat(options.scope).join(' ')
103 } else {
104 this.scope = defaultScope.join(' ')
105 }
106}
107
108/**
109 * Errors
110 */
111AnvilConnect.UnauthorizedError = UnauthorizedError
112
113/**
114 * Requests OIDC configuration from the AnvilConnect instance's provider.
115 * Requires issuer to be set.
116 * @method discover
117 * @return {Promise}
118 */
119function discover () {
120 var self = this
121
122 // construct the uri
123 var uri = url.parse(this.issuer)
124 uri.pathname = '.well-known/openid-configuration'
125 uri = url.format(uri)
126
127 // return a promise
128 return new Promise(function (resolve, reject) {
129 request({
130 url: uri,
131 method: 'GET',
132 json: true,
133 agentOptions: self.agentOptions
134 })
135 .then(function (data) {
136 // data will be an object if the server returned JSON
137 if (typeof data === 'object') {
138 self.configuration = data
139 resolve(data)
140 // If data is not an object, the server is not serving
141 // .well-known/openid-configuration as expected
142 } else {
143 reject(new Error('Unable to retrieve OpenID Connect configuration'))
144 }
145 })
146 .catch(function (err) {
147 reject(err)
148 })
149 })
150}
151AnvilConnect.prototype.discover = discover
152
153/**
154 * Decodes an OIDC issuer (`.iss`) url from an access token and returns it.
155 * @param token {String} JWT Access Token (in encoded string form)
156 * @return {String}
157 */
158function extractIssuer (token) {
159 if (!token) {
160 return
161 }
162 // Decode the JWT. Skip verification (since we need an issuer to verify)
163 var claims = JWT.decode(token, null, { noVerify: true })
164 return claims.payload.iss
165}
166AnvilConnect.prototype.extractIssuer = extractIssuer
167
168/**
169 * Fetches and returns a Client Credentials Grant access token from the OP's
170 * /token endpoint. Convenience method (wrapper for client.token()).
171 * Used as one of the methods to get the token that's required for most
172 * AnvilConnect API client operations (such as creating Users, Clients, etc).
173 * Requires that the client:
174 * - Has been pre-registered with the AnvilConnect server
175 * - Had an 'authority' role assigned to it via `nvl client:assign`
176 * - Has been initialized via client.initProvider()
177 * Usage:
178 *
179 * ```
180 * client.getClientAccessToken()
181 * .then(function (accessToken) {
182 * // you can now use the AnvilConnect API calls, and pass the token
183 * // in the `options` parameter. For example:
184 * var options = { token: accessToken }
185 * return client.users.create(userData, options)
186 * })
187 * ```
188 * @method getClientAccessToken
189 * @return {Promise<Request>}
190 */
191function getClientAccessToken () {
192 return this
193 .token({
194 grant_type: 'client_credentials',
195 scope: 'realm'
196 })
197 .then(function (tokenResponse) {
198 return tokenResponse.access_token
199 })
200}
201AnvilConnect.prototype.getClientAccessToken = getClientAccessToken
202
203/**
204 * Requests JSON Web Key set from configured provider.
205 * Requires provider info to be initialized (like via `discover()`).
206 * @method getJWKs
207 * @return {Promise}
208 */
209function getJWKs () {
210 var self = this
211 var uri = this.configuration.jwks_uri
212
213 return new Promise(function (resolve, reject) {
214 request({
215 url: uri,
216 method: 'GET',
217 json: true,
218 agentOptions: self.agentOptions
219 })
220 .then(function (data) {
221 // make it easier to reference the JWK by use
222 data.keys.forEach(function (jwk) {
223 data[jwk.use] = jwk
224 })
225
226 // make the JWK set available on the client
227 self.jwks = data
228 resolve(data)
229 })
230 .catch(function (err) {
231 reject(err)
232 })
233 })
234}
235AnvilConnect.prototype.getJWKs = getJWKs
236
237/**
238 * Initializes provider-related configurations for this client
239 * (loads OP endpoints via `discover()` and keys via `getJWKs()`).
240 * Requires the issuer to be already set. Usage:
241 *
242 * ```
243 * var client = new AnvilConnect({ issuer: 'https://example.com' })
244 * client.initProvider()
245 * .then(function () {
246 * // now the client is ready to register() or verify()
247 * })
248 * .catch(function (err) {
249 * // handle error
250 * })
251 * ```
252 * @method initProvider
253 * @throws {Error} If `issuer` is not configured
254 * @return {Promise}
255 */
256function initProvider () {
257 if (!this.issuer) {
258 throw new Error('initClient requires an issuer to be configured')
259 }
260 var self = this
261 return self.discover()
262 .then(function () {
263 return self.getJWKs()
264 })
265}
266AnvilConnect.prototype.initProvider = initProvider
267
268/**
269 * Registers the client with an OIDC provider.
270 * Requires that the OIDC provider's registration endpoint is loaded
271 * (say, via `initProvider()`)
272 *
273 * @method register
274 * @param options {Object} Options hashmap of registration params
275 * @param options.redirect_uris {Array<String>} Client callback URLs, for
276 * redirecting users after authentication. REQUIRED.
277 * @param [options.client_name] {String} Name of the client app or service
278 * @param [options.client_uri] {String} Reference app URL (displayed to user)
279 * @param [options.logo_uri] {String} Client logo (displayed to user)
280 * @param [options.response_types] {Array<String>} List of allowed
281 * response types. Allowed values are: either some combination of
282 * `code`, `token` or `id_token`, OR `none` by itself.
283 * Defaults to `['code']`
284 * @param [options.grant_types] {Array<String>} List
285 * of allowed grant types. Defaults to `['authorization_code']`.
286 * @param [options.default_max_age] {Number} Token expiration, in seconds
287 * @param [options.post_logout_redirect_uris] {Array<String>}
288 * @param [options.trusted] {Boolean} Is the client part of your security
289 * realm (is a privileged client), or is it a third party.
290 * @param [options.default_client_scope] {Array<String>} List of client
291 * access token scopes issued by a client_credentials grant.
292 * For example: ['profile', 'realm']
293 * @param [options.token] {String} Access token (for scoped (non-dynamic) client
294 * registartion).
295 * @throws {Error} If `redirect_uris` are missing.
296 * @return {Promise<Object>} Resolves to client configs/metadata
297 * returned from the provider (also sets the relevant client attributes).
298 */
299function register (options) {
300 var self = this
301 var url = this.configuration.registration_endpoint
302 var token = options.token
303 if (!options.redirect_uris) {
304 throw new Error('Missing required redirect_uris parameter for registration')
305 }
306 var requestOptions = {
307 url: url,
308 method: 'POST',
309 json: options,
310 agentOptions: self.agentOptions
311 }
312 if (token) {
313 requestOptions.headers = {
314 'Authorization': 'Bearer ' + token
315 }
316 }
317 return Promise.resolve()
318 .then(function () {
319 return request(requestOptions)
320 })
321 .then(function (data) {
322 self.client_id = data.client_id
323 self.client_secret = data.client_secret
324 self.registration = data
325 return data
326 })
327}
328AnvilConnect.prototype.register = register
329
330/**
331 * Authorization URI
332 */
333function authorizationUri (options) {
334 var u = url.parse(this.configuration.authorization_endpoint)
335
336 // assign endpoint and ensure options
337 var endpoint = 'authorize'
338 if (typeof options === 'string') {
339 endpoint = options
340 options = {}
341 } else if (typeof options === 'object') {
342 endpoint = options.endpoint
343 } else {
344 options = {}
345 }
346
347 // pathname
348 u.pathname = endpoint
349
350 // request params
351 u.query = this.authorizationParams(options)
352
353 return url.format(u)
354}
355AnvilConnect.prototype.authorizationUri = authorizationUri
356
357/**
358 * Authorization Params
359 */
360function authorizationParams (options) {
361 // ensure options is defined
362 options = options || {}
363
364 // essential request params
365 var params = {
366 response_type: options.response_type || 'code',
367 client_id: this.client_id,
368 redirect_uri: options.redirect_uri || this.redirect_uri,
369 scope: options.scope || this.scope
370 }
371
372 // optional request params
373 var optionalParameters = [
374 'email',
375 'password',
376 'provider',
377 'state',
378 'response_mode',
379 'nonce',
380 'display',
381 'prompt',
382 'max_age',
383 'ui_locales',
384 'id_token_hint',
385 'login_hint',
386 'acr_values'
387 ]
388
389 // assign optional request params
390 optionalParameters.forEach(function (param) {
391 if (options[param]) {
392 params[param] = options[param]
393 }
394 })
395
396 return params
397}
398
399AnvilConnect.prototype.authorizationParams = authorizationParams
400
401/**
402 * Refresh
403 */
404function refresh (options) {
405 options = options || {}
406
407 var self = this
408 var refreshToken = options.refresh_token
409 return new Promise(function (resolve, reject) {
410 if (!refreshToken) {
411 return reject(new Error('Missing refresh_token'))
412 }
413 AccessToken.refresh(refreshToken, {
414 issuer: self.issuer,
415 client_id: self.client_id,
416 client_secret: self.client_secret
417 }, function (err, token) {
418 if (err) {
419 return reject(err)
420 }
421 AccessToken.verify(token.access_token, {
422 key: self.jwks.keys[ 0 ],
423 issuer: self.issuer
424 }, function (err) {
425 if (err) {
426 return reject(err)
427 }
428 return resolve(token)
429 })
430 })
431 })
432}
433AnvilConnect.prototype.refresh = refresh
434
435/**
436 * Provides a low-level interface to the server's `/token` REST endpoint.
437 * Using this method requires that the client has been already registered with
438 * the provider, and initialized via `initProvider()`.
439 * Useful for:
440 *
441 * 1. Sending an `authorization_code` grant type request (to exchange
442 * an access code for an ID token in the Auth OIDC flow). Requires either
443 * `options.code` to be set, or `options.responseUri`.
444 * 2. Making a `client_credentials` grant type request (though use
445 * the `client.getClientAccessToken()` convenience method, instead).
446 * Requires that the client was assigned an `authority` role, after
447 * after registration.
448 * 3. Refreshing an expired token (requires `options.refresh_token` to be set)
449 *
450 * @method token
451 * @param [options] {Object} Options hashmap object
452 * @param [options.responseUri] {String} Redirect URL received from a request
453 * (parsed to extract the authorization code)
454 * @param [options.code] {String} Authorization code.
455 * @param [options.grant_type='authorization_code'] {String}
456 * @param [options.redirect_uri] {String} OIDC Redirect URL
457 * (not needed for a grant_type == 'client_credentials')
458 * @param [options.scope] {String} Optional scope
459 * @param [options.refresh_token] {String} Token to be refreshed (used with
460 * grant_type == 'refresh_token').
461 * @return {Promise}
462 */
463function token (options) {
464 options = options || {}
465
466 var self = this
467 var uri = this.configuration.token_endpoint
468 var code = options.code
469 var grantType = options.grant_type || 'authorization_code'
470 var scope = options.scope || self.scope
471 var redirectUri = options.redirect_uri || self.redirect_uri
472 var refreshToken = options.refresh_token
473 var formRequestData
474
475 if (grantType === 'client_credentials') {
476 // 'client_credentials' grants do not need a code or redirect uri
477 // Assumes this client is registered and has had the 'authority' role
478 // added to it previously
479 formRequestData = {
480 grant_type: grantType,
481 scope: scope
482 }
483 } else if (grantType === 'refresh_token') {
484 if (!refreshToken) {
485 return Promise.reject(
486 new Error('Refresh token grant types require refresh_token'))
487 }
488 formRequestData = {
489 grant_type: grantType,
490 refresh_token: refreshToken,
491 scope: scope
492 }
493 } else {
494 // For 'authorization_code' grant type, need to get the code
495 // if code is not passed in explicitly, try extract it from responseUri
496 if (!code && options.responseUri) {
497 var u = url.parse(options.responseUri)
498 code = qs.parse(u.query).code
499 }
500 if (!code) {
501 return Promise.reject(new Error('Missing authorization code'))
502 }
503 formRequestData = {
504 code: code,
505 grant_type: grantType,
506 redirect_uri: redirectUri
507 }
508 }
509
510 return new Promise(function (resolve, reject) {
511 request({
512 url: uri,
513 method: 'POST',
514 form: formRequestData,
515 json: true,
516 auth: {
517 user: self.client_id,
518 pass: self.client_secret
519 },
520 agentOptions: self.agentOptions
521 })
522 .then(function (data) {
523 var verifyClaims = {
524 access_claims: function (done) {
525 AccessToken.verify(data.access_token, {
526 key: self.jwks.keys[0],
527 issuer: self.issuer
528 }, function (err, claims) {
529 if (err) { return done(err) }
530 done(null, claims)
531 })
532 }
533 }
534 // when requesting a token using client credentials no ID information is
535 // returned
536 if (formRequestData.grant_type !== 'client_credentials') {
537 verifyClaims.id_claims = function (done) {
538 IDToken.verify(data.id_token, {
539 iss: self.issuer,
540 aud: self.client_id,
541 key: self.jwks.keys[0]
542 }, function (err, token) {
543 if (err) { return done(err) }
544 done(null, token.payload)
545 })
546 }
547 }
548 // verify tokens
549 async.parallel(verifyClaims, function (err, result) {
550 if (err) {
551 return reject(err)
552 }
553
554 data.id_claims = result.id_claims
555 data.access_claims = result.access_claims
556
557 resolve(data)
558 })
559 })
560 .catch(function (err) {
561 reject(err)
562 })
563 })
564}
565AnvilConnect.prototype.token = token
566
567/**
568 * Retrieves user info / profile from the OIDC Provider (requires a valid access
569 * token).
570 * @method userInfo
571 * @param options {Object} Options hashmap
572 * @param options.token {String} Access token to exchange for userinfo. Required.
573 * @return {Promise<Object>} Resolves to a userInfo hashmap object (or to an
574 * Error when the access token is missing)
575 */
576function userInfo (options) {
577 options = options || {}
578 var uri = this.configuration.userinfo_endpoint
579 var agentOptions = this.agentOptions
580
581 if (!options.token) {
582 return Promise.reject(new Error('Missing access token'))
583 }
584 // Access token is present
585 return Promise.resolve()
586 .then(function () {
587 // return a request promise
588 return request({
589 url: uri,
590 method: 'GET',
591 headers: {
592 'Authorization': 'Bearer ' + options.token
593 },
594 json: true,
595 agentOptions: agentOptions
596 })
597 })
598}
599AnvilConnect.prototype.userInfo = userInfo
600
601/**
602 * Verifies a given OIDC token
603 * @method verify
604 * @param token {String} JWT AccessToken for OpenID Connect (base64 encoded)
605 * @param [options={}] {Object} Options hashmap
606 * @param [options.issuer] {String}
607 * @param [options.key]
608 * @param [options.client_id] {String}
609 * @param [options.client_secret {String}
610 * @param [options.scope]
611 * @throws {UnauthorizedError} HTTP 401 or 403 errors (invalid tokens etc)
612 * @return {Promise}
613 */
614function verify (token, options) {
615 options = options || {}
616 options.issuer = options.issuer || this.issuer
617 options.client_id = options.client_id || this.client_id
618 options.client_secret = options.client_secret || this.client_secret
619 options.scope = options.scope || this.scope
620 options.key = options.key || this.jwks.sig
621
622 return new Promise(function (resolve, reject) {
623 AccessToken.verify(token, options, function (err, claims) {
624 if (err) { return reject(err) }
625 resolve(claims)
626 })
627 })
628}
629AnvilConnect.prototype.verify = verify
630
631/**
632 * Exports
633 */
634module.exports = AnvilConnect