Skip to content

Commit

Permalink
feat: new API for fetching arbitrary resources with the access token
Browse files Browse the repository at this point in the history
This deprecates the `client.resource()` method.

closes #222
  • Loading branch information
panva committed Jan 23, 2020
1 parent 6567c73 commit c981ed6
Show file tree
Hide file tree
Showing 8 changed files with 239 additions and 96 deletions.
19 changes: 10 additions & 9 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Performs [OpenID Provider Issuer Discovery][webfinger-discovery] based on End-Us
- [client.callback(redirectUri, parameters[, checks[, extras]])](#clientcallbackredirecturi-parameters-checks-extras)
- [client.refresh(refreshToken[, extras])](#clientrefreshrefreshtoken-extras)
- [client.userinfo(accessToken[, options])](#clientuserinfoaccesstoken-options)
- [client.resource(resourceUrl, accessToken, [, options])](#clientresourceresourceurl-accesstoken-options)
- [client.requestResource(resourceUrl, accessToken, [, options])](#clientrequestresourceresourceurl-accesstoken-options)
- [client.grant(body[, extras])](#clientgrantbody-extras)
- [client.introspect(token[, tokenTypeHint[, extras]])](#clientintrospecttoken-tokentypehint-extras)
- [client.revoke(token[, tokenTypeHint[, extras]])](#clientrevoketoken-tokentypehint-extras)
Expand Down Expand Up @@ -338,22 +338,23 @@ will also be checked to match the on in the TokenSet's ID Token.
are `header`, `body`, or `query`. **Default:** 'header'.
- `tokenType`: `<string>` The token type as the Authorization Header scheme. **Default:** 'Bearer'
or the `token_type` property from a passed in TokenSet.
- `params`: `<Object>` additional parameters to send with the userinfo request (as query string
when GET, as x-www-form-urlencoded body when POST).
- Returns: `Promise<Object>` Parsed userinfo response.

---

#### `client.resource(resourceUrl, accessToken[, options])`
#### `client.requestResource(resourceUrl, accessToken[, options])`

Fetches an arbitrary resource with the provided Access Token.
Fetches an arbitrary resource with the provided Access Token in an Authorization header.

- `resourceUrl`: `<string>` Resource URL to request a response from.
- `accessToken`: `<string>` &vert; `<TokenSet>` Access Token value. When TokenSet instance is
- `resourceUrl`: `<URL>` &vert; `<string>` Resource URL to request a response from.
- `accessToken`: `<string>` &vert; `<string>` Access Token value. When TokenSet instance is
provided its `access_token` property will be used automatically.
- `options`: `<Object>`
- `headers`: `<Object>` HTTP Headers to include in the request
- `verb`: `<string>` The HTTP verb to use for the request 'GET' or 'POST'. **Default:** 'GET'
- `via`: `<string>` The mechanism to use to attach the Access Token to the request. Valid values
are `header`, `body`, or `query`. **Default:** 'header'.
- `headers`: `<Object>` HTTP Headers to include in the request.
- `body`: `<string>` &vert; `<Buffer>` HTTP Body to include in the request.
- `method`: `<string>` The HTTP verb to use for the request. **Default:** 'GET'
- `tokenType`: `<string>` The token type as the Authorization Header scheme. **Default:** 'Bearer'
or the `token_type` property from a passed in TokenSet.
- Returns: `Promise<Response>` Response is a [Got Response](https://github.com/sindresorhus/got/tree/v9.6.0#response)
Expand Down
199 changes: 144 additions & 55 deletions lib/client.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable max-classes-per-file */

const { inspect } = require('util');
const { inspect, deprecate } = require('util');
const stdhttp = require('http');
const crypto = require('crypto');
const { strict: assert } = require('assert');
Expand Down Expand Up @@ -977,63 +977,37 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
return tokenset;
}

async resource(resourceUrl, accessToken, options) {
let token = accessToken;
const opts = merge({
verb: 'GET',
via: 'header',
}, options);

if (token instanceof TokenSet) {
if (!token.access_token) {
async requestResource(
resourceUrl,
accessToken,
{
method,
headers,
body,
tokenType = accessToken instanceof TokenSet ? accessToken.token_type : 'Bearer',
} = {},
) {
if (accessToken instanceof TokenSet) {
if (!accessToken.access_token) {
throw new TypeError('access_token not present in TokenSet');
}
opts.tokenType = opts.tokenType || token.token_type;
token = token.access_token;
accessToken = accessToken.access_token; // eslint-disable-line no-param-reassign
}

const verb = String(opts.verb).toUpperCase();
let requestOpts;

switch (opts.via) {
case 'query':
if (verb !== 'GET') {
throw new TypeError('resource servers should only parse query strings for GET requests');
}
requestOpts = { query: { access_token: token } };
break;
case 'body':
if (verb !== 'POST') {
throw new TypeError('can only send body on POST');
}
requestOpts = { form: true, body: { access_token: token } };
break;
default:
requestOpts = {
headers: {
Authorization: authorizationHeaderValue(token, opts.tokenType),
},
};
}

if (opts.params) {
if (verb === 'POST') {
defaultsDeep(requestOpts, { body: opts.params });
} else {
defaultsDeep(requestOpts, { query: opts.params });
}
}

if (opts.headers) {
defaultsDeep(requestOpts, { headers: opts.headers });
}
const requestOpts = {
headers: {
Authorization: authorizationHeaderValue(accessToken, tokenType),
...headers,
},
body,
};

const mTLS = !!this.tls_client_certificate_bound_access_tokens;

return request.call(this, {
...requestOpts,
encoding: null,
method: verb,
method,
url: resourceUrl,
}, { mTLS });
}
Expand All @@ -1042,8 +1016,25 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
* @name userinfo
* @api public
*/
async userinfo(accessToken, options = {}) {
async userinfo(accessToken, {
verb = 'GET', via = 'header', tokenType, params,
} = {}) {
// TODO: in v4.x remove verb in favour of method
assertIssuerConfiguration(this.issuer, 'userinfo_endpoint');
const options = {
tokenType,
method: String(verb).toUpperCase(),
};

if (options.method !== 'GET' && options.method !== 'POST') {
throw new TypeError('#userinfo() verb can only be POST or a GET');
}

if (via === 'query' && options.method !== 'GET') {
throw new TypeError('userinfo endpoints will only parse query strings for GET requests');
} else if (via === 'body' && options.method !== 'POST') {
throw new TypeError('can only send body on POST');
}

const jwt = !!(this.userinfo_signed_response_alg
|| this.userinfo_encrypted_response_alg);
Expand All @@ -1057,15 +1048,48 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
const mTLS = !!this.tls_client_certificate_bound_access_tokens;

let targetUrl;
if (mTLS) {
try {
targetUrl = this.issuer.mtls_endpoint_aliases.userinfo_endpoint;
} catch (err) {}
if (mTLS && this.issuer.mtls_endpoint_aliases) {
targetUrl = this.issuer.mtls_endpoint_aliases.userinfo_endpoint;
}

targetUrl = targetUrl || this.issuer.userinfo_endpoint;
targetUrl = new url.URL(targetUrl || this.issuer.userinfo_endpoint);

const response = await this.resource(targetUrl, accessToken, options);
// when via is not header we clear the Authorization header and add either
// query string parameters or urlencoded body access_token parameter
if (via === 'query') {
options.headers.Authorization = undefined;
targetUrl.searchParams.append('access_token', accessToken instanceof TokenSet ? accessToken.access_token : accessToken);
} else if (via === 'body') {
options.headers.Authorization = undefined;
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
options.body = new url.URLSearchParams();
options.body.append('access_token', accessToken instanceof TokenSet ? accessToken.access_token : accessToken);
}

// handle additional parameters, GET via querystring, POST via urlencoded body
if (params) {
if (options.method === 'GET') {
Object.entries(params).forEach(([key, value]) => {
targetUrl.searchParams.append(key, value);
});
} else if (options.body) { // POST && via body
Object.entries(params).forEach(([key, value]) => {
options.body.append(key, value);
});
} else { // POST && via header
options.body = new url.URLSearchParams();
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
Object.entries(params).forEach(([key, value]) => {
options.body.append(key, value);
});
}
}

if (options.body) {
options.body = options.body.toString();
}

const response = await this.requestResource(targetUrl, accessToken, options);

let parsed = processResponse(response, { bearer: true });

Expand Down Expand Up @@ -1527,4 +1551,69 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base
}
};

// TODO: remove in 4.x
BaseClient.prototype.resource = deprecate(
/* istanbul ignore next */
async function resource(resourceUrl, accessToken, options) {
let token = accessToken;
const opts = merge({
verb: 'GET',
via: 'header',
}, options);

if (token instanceof TokenSet) {
if (!token.access_token) {
throw new TypeError('access_token not present in TokenSet');
}
opts.tokenType = opts.tokenType || token.token_type;
token = token.access_token;
}

const verb = String(opts.verb).toUpperCase();
let requestOpts;

switch (opts.via) {
case 'query':
if (verb !== 'GET') {
throw new TypeError('resource servers should only parse query strings for GET requests');
}
requestOpts = { query: { access_token: token } };
break;
case 'body':
if (verb !== 'POST') {
throw new TypeError('can only send body on POST');
}
requestOpts = { form: true, body: { access_token: token } };
break;
default:
requestOpts = {
headers: {
Authorization: authorizationHeaderValue(token, opts.tokenType),
},
};
}

if (opts.params) {
if (verb === 'POST') {
defaultsDeep(requestOpts, { body: opts.params });
} else {
defaultsDeep(requestOpts, { query: opts.params });
}
}

if (opts.headers) {
defaultsDeep(requestOpts, { headers: opts.headers });
}

const mTLS = !!this.tls_client_certificate_bound_access_tokens;

return request.call(this, {
...requestOpts,
encoding: null,
method: verb,
url: resourceUrl,
}, { mTLS });
}, 'client.resource() is deprecated, use client.requestResource() instead, see docs for API details',
);

module.exports.BaseClient = BaseClient;
6 changes: 2 additions & 4 deletions lib/helpers/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,8 @@ async function authenticatedPost(endpoint, opts, {
|| (endpoint === 'token' && this.tls_client_certificate_bound_access_tokens);

let targetUrl;
if (mTLS) {
try {
targetUrl = this.issuer.mtls_endpoint_aliases[`${endpoint}_endpoint`];
} catch (err) {}
if (mTLS && this.issuer.mtls_endpoint_aliases) {
targetUrl = this.issuer.mtls_endpoint_aliases[`${endpoint}_endpoint`];
}

targetUrl = targetUrl || this.issuer[`${endpoint}_endpoint`];
Expand Down
4 changes: 2 additions & 2 deletions lib/helpers/is_absolute_url.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const { parse } = require('url');
const url = require('url');
const { strict: assert } = require('assert');

module.exports = (target) => {
try {
const { protocol } = parse(target);
const { protocol } = new url.URL(target);
assert(protocol.match(/^(https?:)$/));
return true;
} catch (err) {
Expand Down
Loading

0 comments on commit c981ed6

Please sign in to comment.