diff --git a/lib/core/contentstackHTTPClient.js b/lib/core/contentstackHTTPClient.js index 02f590d5..e5d312c7 100644 --- a/lib/core/contentstackHTTPClient.js +++ b/lib/core/contentstackHTTPClient.js @@ -68,11 +68,11 @@ export default function contentstackHttpClient (options) { let uiHostName = hostname let developerHubBaseUrl = hostname - if (hostname.endsWith('io')) { - uiHostName = hostname.replace('io', 'com') + if (uiHostName?.endsWith('io')) { + uiHostName = uiHostName.replace('io', 'com') } - if (hostname.startsWith('api')) { + if (uiHostName?.startsWith('api')) { uiHostName = uiHostName.replace('api', 'app') } const uiBaseUrl = config.endpoint || `${protocol}://${uiHostName}` diff --git a/lib/core/oauthHandler.js b/lib/core/oauthHandler.js index 3a42edb6..aa46c8f5 100644 --- a/lib/core/oauthHandler.js +++ b/lib/core/oauthHandler.js @@ -133,7 +133,7 @@ export default class OAuthHandler { this.axiosInstance.defaults.headers = this._getHeaders() try { - const response = await this.axiosInstance.post(`${this.OAuthBaseURL}/apps-api/apps/token`, body) + const response = await this.axiosInstance.post(`${this.developerHubBaseUrl}/token`, body) this._saveTokens(response.data) return response.data @@ -180,7 +180,7 @@ export default class OAuthHandler { this.axiosInstance.defaults.headers = this._getHeaders() try { - const response = await this.axiosInstance.post(`${this.developerHubBaseUrl}/apps/token`, body) + const response = await this.axiosInstance.post(`${this.developerHubBaseUrl}/token`, body) const data = response.data this.axiosInstance.oauth.accessToken = data.access_token diff --git a/test/sanity-check/api/oauth-test.js b/test/sanity-check/api/oauth-test.js new file mode 100644 index 00000000..c06dc556 --- /dev/null +++ b/test/sanity-check/api/oauth-test.js @@ -0,0 +1,144 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' +import { contentstackClient } from '../../sanity-check/utility/ContentstackClient' +import axios from 'axios' +import dotenv from 'dotenv' + +dotenv.config() +let accessToken = '' +let loggedinUserID = '' +let authUrl = '' +let codeChallenge = '' +let codeChallengeMethod = '' +let authCode +let authtoken = '' +let redirectUrl = '' +let refreshToken = '' +const client = contentstackClient() +const oauthClient = client.oauth({ + clientId: process.env.CLIENT_ID, + appId: process.env.APP_ID, + redirectUri: process.env.REDIRECT_URI +}) + +describe('OAuth Authentication API Test', () => { + it('should login with credentials', done => { + client.login({ email: process.env.EMAIL, password: process.env.PASSWORD }, { include_orgs: true, include_orgs_roles: true, include_stack_roles: true, include_user_settings: true }).then((response) => { + expect(response.notice).to.be.equal('Login Successful.', 'Login success messsage does not match.') + done() + }) + .catch(done) + }) + + it('should get Current user info test', done => { + client.getUser().then((user) => { + authtoken = user.authtoken + done() + }) + .catch(done) + }) + + it('should fail when trying to login with invalid app credentials', () => { + try { + client.oauth({ + clientId: 'clientId', + appId: 'appId', + redirectUri: 'redirectUri' + }) + } catch (error) { + const jsonMessage = JSON.parse(error.message) + expect(jsonMessage.status).to.be.equal(401, 'Status code does not match for invalid credentials') + expect(jsonMessage.errorMessage).to.not.equal(null, 'Error message not proper') + expect(jsonMessage.errorCode).to.be.equal(104, 'Error code does not match') + } + }) + + it('should generate OAuth authorization URL', async () => { + authUrl = await oauthClient.authorize() + const url = new URL(authUrl) + + codeChallenge = url.searchParams.get('code_challenge') + codeChallengeMethod = url.searchParams.get('code_challenge_method') + + // Ensure they are not empty strings + expect(codeChallenge).to.not.equal('') + expect(codeChallengeMethod).to.not.equal('') + expect(authUrl).to.include(process.env.CLIENT_ID, 'Client ID mismatch') + }) + + it('should simulate calling the authorization URL and receive authorization code', async () => { + try { + const authorizationEndpoint = oauthClient.axiosInstance.defaults.developerHubBaseUrl + axios.defaults.headers.common.authtoken = authtoken + axios.defaults.headers.common.organization_uid = process.env.ORGANIZATION + const response = await axios + .post(`${authorizationEndpoint}/manifests/${process.env.APP_ID}/authorize`, { + client_id: process.env.CLIENT_ID, + redirect_uri: process.env.REDIRECT_URI, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + response_type: 'code' + }) + const data = response.data + redirectUrl = data.data.redirect_url + const url = new URL(redirectUrl) + authCode = url.searchParams.get('code') + oauthClient.axiosInstance.oauth.appId = process.env.APP_ID + oauthClient.axiosInstance.oauth.clientId = process.env.CLIENT_ID + oauthClient.axiosInstance.oauth.redirectUri = process.env.REDIRECT_URI + // Ensure they are not empty strings + expect(redirectUrl).to.not.equal('') + expect(url).to.not.equal('') + } catch (error) { + console.log(error) + } + }) + + it('should exchange authorization code for access token', async () => { + const response = await oauthClient.exchangeCodeForToken(authCode) + accessToken = response.access_token + loggedinUserID = response.user_uid + refreshToken = response.refresh_token + + expect(response.organization_uid).to.be.equal(process.env.ORGANIZATION, 'Organization mismatch') + // eslint-disable-next-line no-unused-expressions + expect(response.access_token).to.not.be.null + // eslint-disable-next-line no-unused-expressions + expect(response.refresh_token).to.not.be.null + }) + + it('should get the logged-in user info using the access token', async () => { + const user = await client.getUser({ + authorization: `Bearer ${accessToken}` + }) + expect(user.uid).to.be.equal(loggedinUserID) + expect(user.email).to.be.equal(process.env.EMAIL, 'Email mismatch') + }) + + it('should refresh the access token using refresh token', async () => { + const response = await oauthClient.refreshAccessToken(refreshToken) + + accessToken = response.access_token + refreshToken = response.refresh_token + // eslint-disable-next-line no-unused-expressions + expect(response.access_token).to.not.be.null + // eslint-disable-next-line no-unused-expressions + expect(response.refresh_token).to.not.be.null + }) + + it('should logout successfully after OAuth authentication', async () => { + const response = await oauthClient.logout() + expect(response).to.be.equal('Logged out successfully') + }) + + it('should fail to make an API request with an expired token', async () => { + try { + await client.getUser({ + authorization: `Bearer ${accessToken}` + }) + } catch (error) { + expect(error.status).to.be.equal(401, 'API request should fail with status 401') + expect(error.errorMessage).to.be.equal('The provided access token is invalid or expired or revoked', 'Error message mismatch') + } + }) +}) diff --git a/test/sanity-check/sanity.js b/test/sanity-check/sanity.js index bf309516..8ee08d31 100644 --- a/test/sanity-check/sanity.js +++ b/test/sanity-check/sanity.js @@ -29,3 +29,4 @@ require('./api/contentType-delete-test') require('./api/delete-test') require('./api/team-test') require('./api/auditlog-test') +require('./api/oauth-test') diff --git a/test/unit/index.js b/test/unit/index.js index a49ffca1..6cafbd0c 100644 --- a/test/unit/index.js +++ b/test/unit/index.js @@ -44,3 +44,4 @@ require('./variantGroup-test') require('./ungroupedVariants-test') require('./variantsWithVariantsGroup-test') require('./variants-entry-test') +require('./oauthHandler-test') diff --git a/test/unit/oauthHandler-test.js b/test/unit/oauthHandler-test.js new file mode 100644 index 00000000..f1e1e15b --- /dev/null +++ b/test/unit/oauthHandler-test.js @@ -0,0 +1,331 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import axios from 'axios' +import OAuthHandler from '../../lib/core/oauthHandler' +import { describe, it, beforeEach, afterEach } from 'mocha' + +describe('OAuthHandler', () => { + let axiosInstance + let oauthHandler + let sandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + axiosInstance = axios.create({ + uiBaseUrl: 'https://example.com', // Make sure this is correctly set + developerHubBaseUrl: 'https://developer.example.com', + baseURL: 'https://api.example.com' + }) + oauthHandler = new OAuthHandler(axiosInstance, 'appId', 'clientId', 'http://localhost:8184', 'clientSecret') + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should initialize OAuthHandler with correct properties', () => { + expect(oauthHandler.appId).to.equal('appId') + expect(oauthHandler.clientId).to.equal('clientId') + expect(oauthHandler.redirectUri).to.equal('http://localhost:8184') + expect(oauthHandler.responseType).to.equal('code') + expect(oauthHandler.scope).to.equal('') + expect(oauthHandler.clientSecret).to.equal('clientSecret') + expect(oauthHandler.OAuthBaseURL).to.equal('https://example.com') + expect(oauthHandler.axiosInstance).to.equal(axiosInstance) + }) + + it('should generate code verifier', () => { + const codeVerifier = oauthHandler.generateCodeVerifier() + expect(codeVerifier).to.have.lengthOf(128) + }) + + it('should generate code challenge', async () => { + const codeVerifier = 'testCodeVerifier' + const codeChallenge = await oauthHandler.generateCodeChallenge(codeVerifier) + // eslint-disable-next-line no-unused-expressions + expect(codeChallenge).to.exist + }) + + it('should authorize and return authorization URL', async () => { + const authUrl = await oauthHandler.authorize() + expect(authUrl).to.include('https://example.com/') + expect(authUrl).to.include('response_type=code') + expect(authUrl).to.include('client_id=clientId') + }) + + it('should exchange code for token', async () => { + const tokenData = { access_token: 'accessToken', refresh_token: 'refreshToken', expires_in: 3600 } + sandbox.stub(axiosInstance, 'post').resolves({ data: tokenData }) + + const result = await oauthHandler.exchangeCodeForToken('authorization_code') + expect(result).to.deep.equal(tokenData) + }) + + it('should refresh access token', async () => { + const tokenData = { access_token: 'newAccessToken', refresh_token: 'newRefreshToken', expires_in: 3600 } + sandbox.stub(axiosInstance, 'post').resolves({ data: tokenData }) + + const result = await oauthHandler.refreshAccessToken('refreshToken') + expect(result).to.deep.equal(tokenData) + }) + + it('should logout successfully', async () => { + sandbox.stub(oauthHandler, 'getOauthAppAuthorization').resolves('authorizationId') + sandbox.stub(oauthHandler, 'revokeOauthAppAuthorization').resolves({}) + + const result = await oauthHandler.logout() + expect(result).to.equal('Logged out successfully') + }) + + it('should handle redirect and exchange code for token', async () => { + const exchangeStub = sandbox.stub(oauthHandler, 'exchangeCodeForToken').resolves({}) + + await oauthHandler.handleRedirect('http://localhost:8184?code=authorization_code') + // eslint-disable-next-line no-unused-expressions + expect(exchangeStub.calledWith('authorization_code')).to.be.true + }) + + it('should get access token', () => { + oauthHandler.axiosInstance.oauth = { accessToken: 'accessToken' } + const accessToken = oauthHandler.getAccessToken() + expect(accessToken).to.equal('accessToken') + }) + + it('should get refresh token', () => { + oauthHandler.axiosInstance.oauth = { refreshToken: 'refreshToken' } + const refreshToken = oauthHandler.getRefreshToken() + expect(refreshToken).to.equal('refreshToken') + }) + + it('should get organization UID', () => { + oauthHandler.axiosInstance.oauth = { organizationUID: 'organizationUID' } + const organizationUID = oauthHandler.getOrganizationUID() + expect(organizationUID).to.equal('organizationUID') + }) + + it('should get user UID', () => { + oauthHandler.axiosInstance.oauth = { userUID: 'userUID' } + const userUID = oauthHandler.getUserUID() + expect(userUID).to.equal('userUID') + }) + + it('should get token expiry time', () => { + oauthHandler.axiosInstance.oauth = { tokenExpiryTime: 1234567890 } + const tokenExpiryTime = oauthHandler.getTokenExpiryTime() + expect(tokenExpiryTime).to.equal(1234567890) + }) + + it('should set access token', () => { + oauthHandler.setAccessToken('newAccessToken') + expect(oauthHandler.axiosInstance.oauth.accessToken).to.equal('newAccessToken') + }) + + it('should set refresh token', () => { + oauthHandler.setRefreshToken('newRefreshToken') + expect(oauthHandler.axiosInstance.oauth.refreshToken).to.equal('newRefreshToken') + }) + + it('should set organization UID', () => { + oauthHandler.setOrganizationUID('newOrganizationUID') + expect(oauthHandler.axiosInstance.oauth.organizationUID).to.equal('newOrganizationUID') + }) + + it('should set user UID', () => { + oauthHandler.setUserUID('newUserUID') + expect(oauthHandler.axiosInstance.oauth.userUID).to.equal('newUserUID') + }) + + it('should set token expiry time', () => { + oauthHandler.setTokenExpiryTime(1234567890) + expect(oauthHandler.axiosInstance.oauth.tokenExpiryTime).to.equal(1234567890) + }) + + it('should generate codeVerifier and set codeChallenge to null if clientSecret is not provided', () => { + const oauthHandlerWithoutClientSecret = new OAuthHandler( + axiosInstance, + 'appId', + 'clientId', + 'http://localhost:8184', + null // No clientSecret + ) + + // Ensure codeVerifier is generated + // eslint-disable-next-line no-unused-expressions + expect(oauthHandlerWithoutClientSecret.codeVerifier).to.exist + + // Ensure codeChallenge is null initially + expect(oauthHandlerWithoutClientSecret.codeChallenge).to.equal(null) + }) + + it('should not generate codeVerifier or codeChallenge if clientSecret is provided', () => { + const oauthHandlerWithClientSecret = new OAuthHandler( + axiosInstance, + 'appId', + 'clientId', + 'http://localhost:8184', + 'clientSecret' // clientSecret is provided + ) + + // codeVerifier and codeChallenge should not be set if clientSecret is provided + expect(oauthHandlerWithClientSecret.codeVerifier).to.equal(undefined) + expect(oauthHandlerWithClientSecret.codeChallenge).to.equal(undefined) + }) + + it('should generate codeChallenge after calling generateCodeChallenge when clientSecret is not provided', async () => { + const oauthHandlerWithoutClientSecret = new OAuthHandler( + axiosInstance, + 'appId', + 'clientId', + 'http://localhost:8184', + null // No clientSecret + ) + + const codeVerifier = oauthHandlerWithoutClientSecret.codeVerifier + // eslint-disable-next-line no-unused-expressions + expect(codeVerifier).to.exist // Ensure codeVerifier is generated + + const codeChallenge = await oauthHandlerWithoutClientSecret.generateCodeChallenge(codeVerifier) + + // Ensure the codeChallenge is a URL-safe Base64 string + expect(codeChallenge).to.match(/^[A-Za-z0-9-_]+$/) // URL-safe Base64 + }) + + it('should use the Web Crypto API in a browser environment', async () => { + // Mock the browser environment + global.window = { + crypto: { + subtle: { + digest: sinon.stub().resolves(new Uint8Array([1, 2, 3, 4])) // Mock hash result + } + } + } + + const oauthHandlerWithoutClientSecret = new OAuthHandler( + axiosInstance, + 'appId', + 'clientId', + 'http://localhost:8184', + null // No clientSecret + ) + + const codeVerifier = oauthHandlerWithoutClientSecret.codeVerifier + // eslint-disable-next-line no-unused-expressions + expect(codeVerifier).to.exist // Ensure codeVerifier is generated + + const codeChallenge = await oauthHandlerWithoutClientSecret.generateCodeChallenge(codeVerifier) + + // Ensure the codeChallenge is a URL-safe Base64 string + expect(codeChallenge).to.match(/^[A-Za-z0-9-_]+$/) // URL-safe Base64 + + // Clean up after the test to avoid affecting other tests + delete global.window + }) + + it('should generate authorization URL with code_challenge when clientSecret is not provided', async () => { + // Mock OAuthHandler without clientSecret + oauthHandler = new OAuthHandler( + axiosInstance, + 'appId', + 'clientId', + 'http://localhost:8184', + null // No clientSecret (PKCE) + ) + + // Stub the generateCodeChallenge to return a dummy value + const codeChallenge = 'dummyCodeChallenge' + sandbox.stub(oauthHandler, 'generateCodeChallenge').resolves(codeChallenge) + + const authUrl = await oauthHandler.authorize() + + // Check that code_challenge and code_challenge_method are included in the URL + expect(authUrl).to.include('https://example.com/') + expect(authUrl).to.include('response_type=code') + expect(authUrl).to.include('client_id=clientId') + expect(authUrl).to.include('code_challenge=dummyCodeChallenge') + expect(authUrl).to.include('code_challenge_method=S256') + }) + + // Test cases for getOauthAppAuthorization + describe('getOauthAppAuthorization', () => { + it('should return authorization_uid when authorizations exist for the current user', async () => { + const mockResponse = { + data: { + data: [ + { + user: { uid: 'currentUserUid' }, + authorization_uid: 'authorizationUid1' + } + ] + } + } + + sandbox.stub(axiosInstance, 'get').resolves(mockResponse) + + oauthHandler.axiosInstance.oauth.userUID = 'currentUserUid' + const authorizationUid = await oauthHandler.getOauthAppAuthorization() + + expect(authorizationUid).to.equal('authorizationUid1') + }) + + it('should throw an error when no authorizations found for the current user', async () => { + const mockResponse = { + data: { + data: [ + { + user: { uid: 'otherUserUid' }, + authorization_uid: 'authorizationUid2' + } + ] + } + } + + sandbox.stub(axiosInstance, 'get').resolves(mockResponse) + + oauthHandler.axiosInstance.oauth.userUID = 'currentUserUid' + + try { + await oauthHandler.getOauthAppAuthorization() + throw new Error('Expected error not thrown') + } catch (error) { + expect(error.message).to.equal('No authorizations found for current user!') + } + }) + + it('should throw an error when no authorizations found for the app', async () => { + const mockResponse = { data: { data: [] } } + + sandbox.stub(axiosInstance, 'get').resolves(mockResponse) + + try { + await oauthHandler.getOauthAppAuthorization() + throw new Error('Expected error not thrown') + } catch (error) { + expect(error.message).to.equal('No authorizations found for the app!') + } + }) + }) + + describe('revokeOauthAppAuthorization', () => { + it('should make a DELETE request to revoke authorization when valid authorizationId is provided', async () => { + const authorizationId = 'authorizationUid1' + const mockResponse = { data: { success: true } } + + sandbox.stub(axiosInstance, 'delete').resolves(mockResponse) + const result = await oauthHandler.revokeOauthAppAuthorization(authorizationId) + + // eslint-disable-next-line no-unused-expressions + expect(result.success).to.be.true + // eslint-disable-next-line no-unused-expressions + expect(axiosInstance.delete.calledOnce).to.be.true + }) + + it('should not make a DELETE request when authorizationId is invalid or empty', async () => { + const invalidAuthorizationId = '' + const deleteStub = sandbox.stub(axiosInstance, 'delete') + + await oauthHandler.revokeOauthAppAuthorization(invalidAuthorizationId) + // eslint-disable-next-line no-unused-expressions + expect(deleteStub.called).to.be.false + }) + }) +})