diff --git a/docs/README.md b/docs/README.md index 90d6507f..98b67f4a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) @@ -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`: `` The token type as the Authorization Header scheme. **Default:** 'Bearer' or the `token_type` property from a passed in TokenSet. + - `params`: `` additional parameters to send with the userinfo request (as query string + when GET, as x-www-form-urlencoded body when POST). - Returns: `Promise` Parsed userinfo response. --- -#### `client.resource(resourceUrl, accessToken[, options])` +#### `client.requestResource(resourceUrl, accessToken[, options])` Fetches an arbitrary resource with the provided Access Token. -- `resourceUrl`: `` Resource URL to request a response from. +- `resourceUrl`: `` | `` Resource URL to request a response from. - `accessToken`: `` | `` Access Token value. When TokenSet instance is provided its `access_token` property will be used automatically. - `options`: `` - - `headers`: `` HTTP Headers to include in the request - - `verb`: `` The HTTP verb to use for the request 'GET' or 'POST'. **Default:** 'GET' - - `via`: `` The mechanism to use to attach the Access Token to the request. Valid values - are `header`, `body`, or `query`. **Default:** 'header'. + - `headers`: `` HTTP Headers to include in the request. + - `body`: `` | `` HTTP Body to include in the request. + - `method`: `` The HTTP verb to use for the request. **Default:** 'GET' - `tokenType`: `` The token type as the Authorization Header scheme. **Default:** 'Bearer' or the `token_type` property from a passed in TokenSet. - Returns: `Promise` Response is a [Got Response](https://github.com/sindresorhus/got/tree/v9.6.0#response) diff --git a/lib/client.js b/lib/client.js index 6aaaa696..5db1260e 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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'); @@ -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; - } - - 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 }); - } + accessToken = accessToken.access_token; // eslint-disable-line no-param-reassign } - 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 }); } @@ -1042,8 +1016,24 @@ 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, + } = {}) { 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 @@ -1064,9 +1054,41 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base } catch (err) {} } - targetUrl = targetUrl || this.issuer.userinfo_endpoint; + targetUrl = new url.URL(targetUrl || this.issuer.userinfo_endpoint); + + if (via === 'query') { + targetUrl.searchParams.append('access_token', accessToken); + options.headers.Authorization = undefined; + } else if (via === 'body') { + options.headers.Authorization = undefined; + options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + options.body = new URLSearchParams(); + options.body.append('access_token', accessToken); + } + + 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 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.resource(targetUrl, accessToken, options); + const response = await this.requestResource(targetUrl, accessToken, options); let parsed = processResponse(response, { bearer: true }); @@ -1528,4 +1550,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; diff --git a/lib/helpers/is_absolute_url.js b/lib/helpers/is_absolute_url.js index 3fab5428..4bfbe440 100644 --- a/lib/helpers/is_absolute_url.js +++ b/lib/helpers/is_absolute_url.js @@ -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) { diff --git a/test/client/client_instance.test.js b/test/client/client_instance.test.js index 06b8892f..92134069 100644 --- a/test/client/client_instance.test.js +++ b/test/client/client_instance.test.js @@ -345,7 +345,7 @@ describe('Client', () => { grant_type: 'authorization_code', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(200, {}); return this.client.callback('https://rp.example.com/cb', { @@ -455,7 +455,7 @@ describe('Client', () => { grant_type: 'authorization_code', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(200, {}); await client.callback('https://rp.example.com/cb', { @@ -495,7 +495,7 @@ describe('Client', () => { grant_type: 'authorization_code', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(200, {}); await client.callback('https://rp.example.com/cb', { @@ -637,7 +637,7 @@ describe('Client', () => { grant_type: 'authorization_code', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(200, { access_token: 'tokenValue', }); @@ -684,7 +684,7 @@ describe('Client', () => { grant_type: 'authorization_code', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(200, {}); await client.oauthCallback('https://rp.example.com/cb', { @@ -724,7 +724,7 @@ describe('Client', () => { grant_type: 'authorization_code', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(200, {}); await client.oauthCallback('https://rp.example.com/cb', { @@ -889,7 +889,7 @@ describe('Client', () => { grant_type: 'refresh_token', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(200, {}); return this.client.refresh('refreshValue').then(() => { @@ -919,7 +919,7 @@ describe('Client', () => { grant_type: 'refresh_token', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(200, {}); return this.client.refresh(new TokenSet({ @@ -985,6 +985,16 @@ describe('Client', () => { }); }); + it('only GET and POST is supported', function () { + const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); + const client = new issuer.Client({ client_id: 'identifier', token_endpoint_auth_method: 'none' }); + + return client.userinfo('tokenValue', { verb: 'PUT' }).then(fail, (error) => { + expect(error).to.be.instanceof(TypeError); + expect(error.message).to.eql('#userinfo() verb can only be POST or a GET'); + }); + }); + it('takes a string token and a tokenType option', function () { const issuer = new Issuer({ userinfo_endpoint: 'https://op.example.com/me' }); const client = new issuer.Client({ client_id: 'identifier', token_endpoint_auth_method: 'none' }); @@ -1103,7 +1113,8 @@ describe('Client', () => { access_token: 'tokenValue', }); }) - .post('/me').reply(200, {}); + .post('/me', () => true) // to make sure filteringRequestBody works + .reply(200, {}); return client.userinfo('tokenValue', { verb: 'POST', via: 'body' }).then(() => { expect(nock.isDone()).to.be.true; @@ -1121,7 +1132,8 @@ describe('Client', () => { foo: 'bar', }); }) - .post('/me').reply(200, {}); + .post('/me', () => true) // to make sure filteringRequestBody works + .reply(200, {}); return client.userinfo('tokenValue', { verb: 'POST', @@ -1174,7 +1186,7 @@ describe('Client', () => { const client = new issuer.Client({ client_id: 'identifier', token_endpoint_auth_method: 'none' }); return client.userinfo('tokenValue', { via: 'query', verb: 'post' }).then(fail, ({ message }) => { - expect(message).to.eql('resource servers should only parse query strings for GET requests'); + expect(message).to.eql('userinfo endpoints will only parse query strings for GET requests'); }); }); @@ -1331,9 +1343,10 @@ describe('Client', () => { .filteringRequestBody(function (body) { expect(querystring.parse(body)).to.eql({ token: 'tokenValue', + client_id: 'identifier', }); }) - .post('/token/introspect') + .post('/token/introspect', () => true) // to make sure filteringRequestBody works .reply(200, { endpoint: 'response', }); @@ -1351,11 +1364,12 @@ describe('Client', () => { nock('https://op.example.com') .filteringRequestBody(function (body) { expect(querystring.parse(body)).to.eql({ + client_id: 'identifier', token: 'tokenValue', token_type_hint: 'access_token', }); }) - .post('/token/introspect') + .post('/token/introspect', () => true) // to make sure filteringRequestBody works .reply(200, { endpoint: 'response', }); @@ -1441,10 +1455,11 @@ describe('Client', () => { nock('https://op.example.com') .filteringRequestBody(function (body) { expect(querystring.parse(body)).to.eql({ + client_id: 'identifier', token: 'tokenValue', }); }) - .post('/token/revoke') + .post('/token/revoke', () => true) // to make sure filteringRequestBody works .reply(200, { endpoint: 'response', }); @@ -1462,11 +1477,12 @@ describe('Client', () => { nock('https://op.example.com') .filteringRequestBody(function (body) { expect(querystring.parse(body)).to.eql({ + client_id: 'identifier', token: 'tokenValue', token_type_hint: 'access_token', }); }) - .post('/token/revoke') + .post('/token/revoke', () => true) // to make sure filteringRequestBody works .reply(200, { endpoint: 'response', }); diff --git a/test/client/device_flow.test.js b/test/client/device_flow.test.js index cf9ab2b2..e5309c9c 100644 --- a/test/client/device_flow.test.js +++ b/test/client/device_flow.test.js @@ -40,7 +40,7 @@ describe('Device Flow features', () => { foo: 'bar', }); }) - .post('/auth/device', () => true) + .post('/auth/device', () => true) // to make sure filteringRequestBody works .reply(200, { verification_uri: 'https://op.example.com/device', user_code: 'AAAA-AAAA', @@ -139,7 +139,7 @@ describe('Device Flow features', () => { device_code: 'foobar', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(200, { expires_in: 300, access_token: 'at', @@ -173,7 +173,7 @@ describe('Device Flow features', () => { device_code: 'foobar', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(400, { error: 'slow_down' }) .post('/token') .reply(200, { @@ -210,7 +210,7 @@ describe('Device Flow features', () => { device_code: 'foobar', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(400, { error: 'authorization_pending' }) .post('/token') .reply(200, { @@ -275,7 +275,7 @@ describe('Device Flow features', () => { device_code: 'foobar', }); }) - .post('/token', () => true) + .post('/token', () => true) // to make sure filteringRequestBody works .reply(400, { error: 'authorization_pending' }) .post('/token') .reply(400, { diff --git a/test/client/register_client.test.js b/test/client/register_client.test.js index 7c369371..c99e9de9 100644 --- a/test/client/register_client.test.js +++ b/test/client/register_client.test.js @@ -102,7 +102,7 @@ describe('Client#register', () => { jwks: keystore.toJWKS(), }); }) - .post('/client/registration', () => true) + .post('/client/registration', () => true) // to make sure filteringRequestBody works .reply(201, { client_id: 'identifier', client_secret: 'secure', @@ -120,7 +120,7 @@ describe('Client#register', () => { jwks: 'whatever', }); }) - .post('/client/registration', () => true) + .post('/client/registration', () => true) // to make sure filteringRequestBody works .reply(201, { client_id: 'identifier', client_secret: 'secure', @@ -140,7 +140,7 @@ describe('Client#register', () => { jwks_uri: 'https://rp.example.com/certs', }); }) - .post('/client/registration', () => true) + .post('/client/registration', () => true) // to make sure filteringRequestBody works .reply(201, { client_id: 'identifier', client_secret: 'secure', diff --git a/types/index.d.ts b/types/index.d.ts index 0952982f..890b5e80 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -12,6 +12,7 @@ import * as http2 from 'http2'; import * as tls from 'tls'; import { GotOptions, GotPromise } from 'got'; +import { URL } from 'url'; import { JWKS, JSONWebKeySet } from 'jose'; export type HttpOptions = GotOptions; @@ -403,7 +404,12 @@ export class Client { * will be used automatically. * @param options Options for the UserInfo request. */ - userinfo(accessToken: TokenSet | string, options?: { verb?: 'GET' | 'POST', via?: 'header' | 'body' | 'query', tokenType?: string }): Promise; + userinfo(accessToken: TokenSet | string, options?: { verb?: 'GET' | 'POST', via?: 'header' | 'body' | 'query', tokenType?: string, params?: object }): Promise; + + /** + * @deprecated in favor of client.requestResource + */ + resource(resourceUrl: string, accessToken: TokenSet | string, options?: { headers?: object, verb?: 'GET' | 'POST', via?: 'header' | 'body' | 'query', tokenType?: string }): GotPromise; /** * Fetches an arbitrary resource with the provided Access Token. @@ -413,7 +419,12 @@ export class Client { * will be used automatically. * @param options Options for the request. */ - resource(resourceUrl: string, accessToken: TokenSet | string, options?: { headers?: object, verb?: 'GET' | 'POST', via?: 'header' | 'body' | 'query', tokenType?: string }): GotPromise; + requestResource(resourceUrl: string | URL, accessToken: TokenSet | string, options?: { + headers?: object + body: string | Buffer + method?: 'GET' | 'POST' | 'PUT' | 'HEAD' | 'DELETE' | 'OPTIONS' | 'TRACE' + tokenType?: string + }): GotPromise; /** * Performs an arbitrary grant_type exchange at the token_endpoint.