diff --git a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js index 477b64e751d..ef90de873f9 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js @@ -6,6 +6,7 @@ import querystring from 'querystring'; import url from 'url'; +import {EventEmitter} from 'events'; import {base64, oneFlight, whileInFlight} from '@webex/common'; import {grantErrors, WebexPlugin} from '@webex/webex-core'; @@ -66,6 +67,18 @@ const Authorization = WebexPlugin.extend({ namespace: 'Credentials', + + /** + * Stores the interval ID for QR code polling + * @instance + * @memberof AuthorizationBrowserFirstParty + * @type {?number} + * @private + */ + pollingRequest: null, + + eventEmitter: new EventEmitter(), + /** * Initializer * @instance @@ -240,6 +253,147 @@ const Authorization = WebexPlugin.extend({ }); }, + /** + * Get an OAuth Login URL for QRCode. Generate QR code based on the returned URL. + * @instance + * @memberof AuthorizationBrowserFirstParty + * @throws {Error} When the request fails + * @returns {Promise<{verification_uri_complete: string, verification_uri: string, user_code: string, device_code: string, interval: number, expires_in: number}>} + */ + initQRCodeLogin() { + return this.webex + .request({ + method: 'POST', + service: 'oauth-helper', + resource: '/actions/device/authorize', + form: { + client_id: this.config.client_id, + scope: this.config.scope, + }, + auth: { + user: this.config.client_id, + pass: this.config.client_secret, + sendImmediately: true, + }, + }) + .then((res) => { + const {user_code, verification_uri, verification_uri_complete} = res.body; + this.eventEmitter.emit('qrcode-login', { + eventType: 'user-code', + userData: { + user_code, + verification_uri, + verification_uri_complete, + } + }); + // if device authorization success, then start to poll server to check whether the user has completed authorization + this.startQRCodePolling(res.body); + return res.body; + }) + .catch((res) => { + return Promise.reject(res); + }); + }, + + /** + * Polling the server to check whether the user has completed authorization + * @instance + * @memberof AuthorizationBrowserFirstParty + * @param {Object} options + * @throws {Error} When the request fails + * @returns {Promise} + */ + startQRCodePolling(options = {}) { + if (!options.device_code) { + return Promise.reject(new Error('A deviceCode is required')); + } + + if (this.pollingRequest) { + return Promise.reject(new Error('There is already a polling request')); + } + + const {device_code: deviceCode, interval = 2, expires_in: expiresIn = 300} = options; + + let attempts = 0; + const maxAttempts = expiresIn / interval; + + return new Promise((resolve, reject) => { + this.pollingRequest = setInterval(() => { + attempts += 1; + + const currentAttempts = attempts; + this.webex + .request({ + method: 'POST', + service: 'oauth-helper', + resource: '/actions/device/token', + form: { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + client_id: this.config.client_id, + }, + auth: { + user: this.config.client_id, + pass: this.config.client_secret, + sendImmediately: true, + }, + }) + .then((res) => { + this.eventEmitter.emit('qrcode-login', { + eventType: 'authorization_success', + authorized: true, + }); + this.cancelQRCodePolling(); + resolve(res.body); + }) + .catch((res) => { + if (currentAttempts >= maxAttempts) { + reject(new Error('Authorization timed out')); + this.eventEmitter.emit('qrcode-login', { + eventType: 'authorization_failed', + error: 'Authorization timed out', + }); + this.cancelQRCodePolling(); + return; + } + // if the statusCode is 428 which means that the authorization request is still pending + // as the end user hasn't yet completed the user-interaction steps. So keep polling. + if (res.statusCode === 428) { + this.eventEmitter.emit('qrcode-login', { + eventType: 'authorization_pending', + error: res.body + }); + return; + } + + this.cancelQRCodePolling(); + + reject(res); + this.eventEmitter.emit('qrcode-login', { + eventType: 'authorization_failed', + error: res.body + }); + }); + }, interval * 1000); + }); + }, + + /** + * cancel polling request + * @instance + * @memberof AuthorizationBrowserFirstParty + * @returns {void} + */ + cancelQRCodePolling() { + if (this.pollingRequest) { + clearInterval(this.pollingRequest); + this.eventEmitter.emit('qrcode-login', { + eventType: 'polling_canceled', + }); + this.pollingRequest = null; + } + }, + /** * Extracts the orgId from the returned code from idbroker * Description of how to parse the code can be found here: diff --git a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js index 6069b45ac15..45bfee6105c 100644 --- a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js +++ b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js @@ -443,6 +443,257 @@ describe('plugin-authorization-browser-first-party', () => { }); }); + describe('#initQRCodeLogin()', () => { + it('should send correct request parameters to the API', async () => { + const clock = sinon.useFakeTimers(); + const testClientId = 'test_client_id'; + const testScope = 'test-scope'; + const sampleData = { + device_code: "test123", + expires_in: 300, + user_code: "421175", + verification_uri: "http://example.com", + verification_uri_complete: "http://example.com", + interval: 2 + }; + + const webex = makeWebex('http://example.com', undefined, undefined, { + credentials: { + client_id: testClientId, + scope: testScope, + } + }); + webex.request.onFirstCall().resolves({statusCode: 200, body: sampleData}); + sinon.spy(webex.authorization, 'startQRCodePolling'); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + const promise = webex.authorization.initQRCodeLogin(); + clock.tick(2000); + await promise; + + assert.calledOnce(webex.request); + assert.calledOnce(webex.authorization.startQRCodePolling); + assert.calledOnce(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'user-code'); + + const request = webex.request.getCall(0); + + assert.equal(request.args[0].form.client_id, testClientId); + assert.equal(request.args[0].form.scope, testScope); + }); + + it('should use POST method and correct endpoint', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const sampleData = { + device_code: "test123", + expires_in: 300, + user_code: "421175", + verification_uri: "http://example.com", + verification_uri_complete: "http://example.com", + interval: 2 + }; + webex.request.resolves().resolves({statusCode: 200, body: sampleData}); + + const promise = webex.authorization.initQRCodeLogin(); + clock.tick(2000); + await promise; + + const request = webex.request.getCall(0); + assert.equal(request.args[0].method, 'POST'); + assert.equal(request.args[0].service, 'oauth-helper'); + assert.equal(request.args[0].resource, '/actions/device/authorize'); + }); + + it('should handle API error response', () => { + const webex = makeWebex('http://example.com'); + webex.request.rejects(new Error('API Error')); + assert.isRejected(webex.authorization.initQRCodeLogin(), /API Error/); + }); + }); + + describe('#startQRCodePolling()', () => { + it('requires a deviceCode', () => { + const webex = makeWebex('http://example.com'); + assert.isRejected(webex.authorization.startQRCodePolling({}), /A deviceCode is required/); + }); + + it('should send correct request parameters to the API', async () => { + const clock = sinon.useFakeTimers(); + const testClientId = 'test_client_id'; + const testDeviceCode = 'test-device-code'; + const testInterval = 2; + const testExpiresIn = 300; + + const options = { + device_code: testDeviceCode, + interval: testInterval, + expires_in: testExpiresIn, + }; + + const webex = makeWebex('http://example.com', undefined, undefined, { + credentials: { + client_id: testClientId, + } + }); + + webex.request.onFirstCall().resolves({statusCode: 200, body: {access_token: 'token'}}); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + sinon.spy(webex.authorization, 'cancelQRCodePolling'); + + const promise = webex.authorization.startQRCodePolling(options); + clock.tick(2000); + await promise; + + assert.calledOnce(webex.request); + + const request = webex.request.getCall(0); + + assert.equal(request.args[0].form.client_id, testClientId); + assert.equal(request.args[0].form.device_code, testDeviceCode); + assert.equal(request.args[0].form.grant_type, 'urn:ietf:params:oauth:grant-type:device_code'); + + assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledTwice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorization_success'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'polling_canceled'); + + clock.restore(); + }); + + it('should respect polling interval', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test_code', + interval: 2, + expires_in: 300 + }; + + webex.request.onFirstCall().rejects({statusCode: 428, body: {message: 'authorization_pending'}}); + webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}}); + sinon.spy(webex.authorization, 'cancelQRCodePolling'); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + const promise = webex.authorization.startQRCodePolling(options); + clock.tick(4000); + await promise; + + assert.calledTwice(webex.request); + assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledThrice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorization_success'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'polling_canceled'); + assert.equal(emitSpy.getCall(2).args[1].eventType, 'authorization_pending'); + clock.restore(); + }); + + it('should timeout after expires_in seconds', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test_code', + interval: 5, + expires_in: 10 + }; + + webex.request.rejects({statusCode: 428, body: {message: 'authorization_pending'}}); + sinon.spy(webex.authorization, 'cancelQRCodePolling'); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + const promise = webex.authorization.startQRCodePolling(options); + clock.tick(10000); + + await assert.isRejected(promise, /Authorization timed out/); + assert.calledTwice(webex.request); + assert.calledOnce(webex.authorization.cancelQRCodePolling); + assert.calledThrice(emitSpy); + assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorization_pending'); + assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorization_failed'); + assert.equal(emitSpy.getCall(2).args[1].eventType, 'polling_canceled'); + clock.restore(); + }); + + it('should prevent concurrent polling attempts', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test_code', + interval: 2, + expires_in: 300 + }; + + webex.request.rejects({statusCode: 428, body: {message: 'authorization_pending'}}); + + // Start first polling request + webex.authorization.startQRCodePolling(options); + // Attempt second polling request + const secondPromise = webex.authorization.startQRCodePolling(options); + + await assert.isRejected(secondPromise, /There is already a polling request/); + clock.restore(); + }); + }); + + describe('#cancelQRCodePolling()', () => { + it('should stop polling after cancellation', async () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + const options = { + device_code: 'test_code', + interval: 2, + expires_in: 300 + }; + + webex.request.rejects({statusCode: 428, body: {message: 'authorization_pending'}}); + const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit'); + + webex.authorization.startQRCodePolling(options); + // First poll + clock.tick(2000); + assert.calledOnce(webex.request); + + webex.authorization.cancelQRCodePolling(); + // Wait for next interval + clock.tick(2000); + + const eventArgs = emitSpy.getCall(0).args; + + // Verify no additional requests were made + assert.calledOnce(webex.request); + assert.calledOnce(emitSpy); + assert.equal(eventArgs[1].eventType, 'polling_canceled'); + clock.restore(); + }); + it('should clear interval and reset polling request', () => { + const clock = sinon.useFakeTimers(); + const webex = makeWebex('http://example.com'); + + const options = { + device_code: 'test_device_code', + interval: 2, + expires_in: 300, + }; + + webex.authorization.startQRCodePolling(options); + assert.isDefined(webex.authorization.pollingRequest); + + webex.authorization.cancelQRCodePolling(); + assert.isNull(webex.authorization.pollingRequest); + + clock.restore(); + }); + + it('should handle cancellation when no polling is in progress', () => { + const webex = makeWebex('http://example.com'); + assert.isNull(webex.authorization.pollingRequest); + + webex.authorization.cancelQRCodePolling(); + assert.isNull(webex.authorization.pollingRequest); + }); + + }); + describe('#_generateCodeChallenge', () => { const expectedCodeChallenge = 'code challenge'; // eslint-disable-next-line no-underscore-dangle