diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index ce1fb829..4c60c5f9 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -68,6 +68,16 @@ export enum CertificateFormat { JWK = 'JWK', } +/** + * The client authentication type. Supported values are basic, post, and none. + * https://datatracker.ietf.org/doc/html/rfc7591#section-2 + */ +export enum ClientAuthentication { + ClientSecretPost = 'ClientSecretPost', + ClientSecretBasic = 'ClientSecretBasic', + None = 'None', +} + export interface GetTokenOptions { code: string; codeVerifier?: string; @@ -86,6 +96,19 @@ export interface GetTokenOptions { redirect_uri?: string; } +/** + * An interface for preparing {@link GetTokenOptions} as a querystring. + */ +interface GetTokenQuery { + client_id?: string; + client_secret?: string; + code_verifier?: string; + code: string; + grant_type: 'authorization_code'; + redirect_uri?: string; + [key: string]: string | undefined; +} + export interface TokenInfo { /** * The application that is the intended user of the access token. @@ -475,6 +498,12 @@ export interface OAuth2ClientOptions extends AuthClientOptions { * The allowed OAuth2 token issuers. */ issuers?: string[]; + /** + * The client authentication type. Supported values are basic, post, and none. + * Defaults to post if not provided. + * https://datatracker.ietf.org/doc/html/rfc7591#section-2 + */ + clientAuthentication?: ClientAuthentication; } // Re-exporting here for backwards compatibility @@ -491,6 +520,7 @@ export class OAuth2Client extends AuthClient { protected refreshTokenPromises = new Map>(); readonly endpoints: Readonly; readonly issuers: string[]; + readonly clientAuthentication: ClientAuthentication; // TODO: refactor tests to make this private _clientId?: string; @@ -542,6 +572,8 @@ export class OAuth2Client extends AuthClient { oauth2IapPublicKeyUrl: 'https://www.gstatic.com/iap/verify/public_key', ...opts.endpoints, }; + this.clientAuthentication = + opts.clientAuthentication || ClientAuthentication.ClientSecretPost; this.issuers = opts.issuers || [ 'accounts.google.com', @@ -660,20 +692,30 @@ export class OAuth2Client extends AuthClient { options: GetTokenOptions ): Promise { const url = this.endpoints.oauth2TokenUrl.toString(); - const values = { - code: options.code, + const headers: Headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + const values: GetTokenQuery = { client_id: options.client_id || this._clientId, - client_secret: this._clientSecret, - redirect_uri: options.redirect_uri || this.redirectUri, - grant_type: 'authorization_code', code_verifier: options.codeVerifier, + code: options.code, + grant_type: 'authorization_code', + redirect_uri: options.redirect_uri || this.redirectUri, }; + if (this.clientAuthentication === ClientAuthentication.ClientSecretBasic) { + const basic = Buffer.from(`${this._clientId}:${this._clientSecret}`); + + headers['Authorization'] = `Basic ${basic.toString('base64')}`; + } + if (this.clientAuthentication === ClientAuthentication.ClientSecretPost) { + values.client_secret = this._clientSecret; + } const res = await this.transporter.request({ ...OAuth2Client.RETRY_CONFIG, method: 'POST', url, data: querystring.stringify(values), - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + headers, }); const tokens = res.data as Credentials; if (res.data && res.data.expires_in) { diff --git a/src/index.ts b/src/index.ts index 1ab32948..fe3b69eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ export { RefreshOptions, TokenInfo, VerifyIdTokenOptions, + ClientAuthentication, } from './auth/oauth2client'; export {LoginTicket, TokenPayload} from './auth/loginticket'; export { diff --git a/test/test.oauth2.ts b/test/test.oauth2.ts index b836656f..ebd42ef0 100644 --- a/test/test.oauth2.ts +++ b/test/test.oauth2.ts @@ -23,7 +23,12 @@ import * as path from 'path'; import * as qs from 'querystring'; import * as sinon from 'sinon'; -import {CodeChallengeMethod, Credentials, OAuth2Client} from '../src'; +import { + CodeChallengeMethod, + Credentials, + OAuth2Client, + ClientAuthentication, +} from '../src'; import {LoginTicket} from '../src/auth/loginticket'; nock.disableNetConnect(); @@ -1366,6 +1371,7 @@ describe('oauth2', () => { reqheaders: {'Content-Type': 'application/x-www-form-urlencoded'}, }) .post('/token') + .matchHeader('authorization', value => value === undefined) .reply(200, { access_token: 'abc', refresh_token: '123', @@ -1421,6 +1427,92 @@ describe('oauth2', () => { assert.strictEqual(params.client_id, 'overridden'); }); + it('getToken should use basic header auth if provided in options', async () => { + const authurl = 'https://sts.googleapis.com/v1/'; + const basic_auth = + 'Basic ' + + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'); + const scope = nock(authurl) + .post('/oauthtoken') + .matchHeader('Authorization', basic_auth) + .reply(200, { + access_token: 'abc', + refresh_token: '123', + expires_in: 10, + }); + const opts = { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + redirectUri: REDIRECT_URI, + endpoints: { + oauth2AuthBaseUrl: 'https://auth.cloud.google/authorize', + oauth2TokenUrl: 'https://sts.googleapis.com/v1/oauthtoken', + tokenInfoUrl: 'https://sts.googleapis.com/v1/introspect', + }, + clientAuthentication: ClientAuthentication.ClientSecretBasic, + }; + const oauth2client = new OAuth2Client(opts); + const res = await oauth2client.getToken({ + code: 'code here', + client_id: CLIENT_ID, + }); + scope.done(); + assert(res.res); + assert.equal(res.res.data.access_token, 'abc'); + }); + + it('getToken should not use basic header auth if provided none in options and fail', async () => { + const authurl = 'https://some.example.auth/'; + const scope = nock(authurl) + .post('/token') + .matchHeader('Authorization', val => val === undefined) + .reply(401); + const opts = { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + redirectUri: REDIRECT_URI, + endpoints: { + oauth2AuthBaseUrl: 'https://auth.cloud.google/authorize', + oauth2TokenUrl: 'https://some.example.auth/token', + }, + clientAuthentication: ClientAuthentication.None, + }; + const oauth2client = new OAuth2Client(opts); + assert.equal( + oauth2client.clientAuthentication, + ClientAuthentication.None + ); + + try { + await oauth2client.getToken({ + code: 'code here', + client_id: CLIENT_ID, + }); + throw new Error('Expected an error'); + } catch (err) { + assert(err instanceof GaxiosError); + assert.equal(err.response?.status, 401); + } finally { + scope.done(); + } + }); + + it('getToken should use auth secret post if not provided in options', async () => { + const opts = { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + redirectUri: REDIRECT_URI, + endpoints: { + oauth2TokenUrl: 'mytokenurl', + }, + }; + const oauth2client = new OAuth2Client(opts); + assert.equal( + oauth2client.clientAuthentication, + ClientAuthentication.ClientSecretPost + ); + }); + it('should return expiry_date', done => { const now = new Date().getTime(); const scope = nock(baseUrl, {