diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc37167..4d3a225d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [v1.20.0](https://github.com/contentstack/contentstack-management-javascript/tree/v1.20.0) (2025-04-01) + - Feature + - Added OAuth support + - Added the Unit Test cases and added sanity test case for OAuth + - Handle retry the requests that were pending due to token expiration + - Updated Axios Version + ## [v1.19.6](https://github.com/contentstack/contentstack-management-javascript/tree/v1.19.6) (2025-03-24) - Enhancement - Added stack headers in global fields response diff --git a/lib/contentstackClient.js b/lib/contentstackClient.js index a83d0693..830be9b6 100644 --- a/lib/contentstackClient.js +++ b/lib/contentstackClient.js @@ -6,6 +6,7 @@ import { Organization } from './organization/index' import cloneDeep from 'lodash/cloneDeep' import { User } from './user/index' import error from './core/contentstackError' +import OAuthHandler from './core/oauthHandler' export default function contentstackClient ({ http }) { /** @@ -172,12 +173,44 @@ export default function contentstackClient ({ http }) { }, error) } + /** + * @description The oauth call is used to sign in to your Contentstack account and obtain the accesstoken. + * @memberof ContentstackClient + * @func oauth + * @param {Object} parameters - oauth parameters + * @prop {string} parameters.appId - appId of the application + * @prop {string} parameters.clientId - clientId of the application + * @prop {string} parameters.clientId - clientId of the application + * @prop {string} parameters.responseType - responseType + * @prop {string} parameters.scope - scope + * @prop {string} parameters.clientSecret - clientSecret of the application + * @returns {OAuthHandler} Instance of OAuthHandler + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client() + * + * client.oauth({ appId: , clientId: , redirectUri: , clientSecret: , responseType: , scope: }) + * .then(() => console.log('Logged in successfully')) + * + */ + function oauth (params = {}) { + http.defaults.versioningStrategy = 'path' + const appId = params.appId || '6400aa06db64de001a31c8a9' + const clientId = params.clientId || 'Ie0FEfTzlfAHL4xM' + const redirectUri = params.redirectUri || 'http://localhost:8184' + const responseType = params.responseType || 'code' + const scope = params.scope + const clientSecret = params.clientSecret + return new OAuthHandler(http, appId, clientId, redirectUri, clientSecret, responseType, scope) + } + return { login: login, logout: logout, getUser: getUser, stack: stack, organization: organization, - axiosInstance: http + axiosInstance: http, + oauth } } diff --git a/lib/core/concurrency-queue.js b/lib/core/concurrency-queue.js index be36c2b7..868307e3 100644 --- a/lib/core/concurrency-queue.js +++ b/lib/core/concurrency-queue.js @@ -1,4 +1,5 @@ import Axios from 'axios' +import OAuthHandler from './oauthHandler' const defaultConfig = { maxRequests: 5, retryLimit: 5, @@ -75,17 +76,17 @@ export function ConcurrencyQueue ({ axios, config }) { request.formdata = request.data request.data = transformFormData(request) } - request.retryCount = request.retryCount || 0 - if (request.headers.authorization && request.headers.authorization !== undefined) { - if (this.config.authorization && this.config.authorization !== undefined) { - request.headers.authorization = this.config.authorization - request.authorization = this.config.authorization + if (axios?.oauth?.accessToken) { + const isTokenExpired = axios.oauth.tokenExpiryTime && Date.now() > axios.oauth.tokenExpiryTime + if (isTokenExpired) { + return refreshAccessToken().catch((error) => { + throw new Error('Failed to refresh access token: ' + error.message) + }) } - delete request.headers.authtoken - } else if (request.headers.authtoken && request.headers.authtoken !== undefined && this.config.authtoken && this.config.authtoken !== undefined) { - request.headers.authtoken = this.config.authtoken - request.authtoken = this.config.authtoken } + + request.retryCount = request?.retryCount || 0 + setAuthorizationHeaders(request) if (request.cancelToken === undefined) { const source = Axios.CancelToken.source() request.cancelToken = source.token @@ -108,6 +109,44 @@ export function ConcurrencyQueue ({ axios, config }) { }) } + const setAuthorizationHeaders = (request) => { + if (request.headers.authorization && request.headers.authorization !== undefined) { + if (this.config.authorization && this.config.authorization !== undefined) { + request.headers.authorization = this.config.authorization + request.authorization = this.config.authorization + } + delete request.headers.authtoken + } else if (request.headers.authtoken && request.headers.authtoken !== undefined && this.config.authtoken && this.config.authtoken !== undefined) { + request.headers.authtoken = this.config.authtoken + request.authtoken = this.config.authtoken + } else if (axios?.oauth?.accessToken) { + // If OAuth access token is available in axios instance + request.headers.authorization = `Bearer ${axios.oauth.accessToken}` + request.authorization = `Bearer ${axios.oauth.accessToken}` + delete request.headers.authtoken + } + } + + // Refresh Access Token + const refreshAccessToken = async () => { + try { + // Try to refresh the token + await new OAuthHandler(axios).refreshAccessToken() + this.paused = false // Resume the request queue once the token is refreshed + + // Retry the requests that were pending due to token expiration + this.running.forEach(({ request, resolve, reject }) => { + // Retry the request + axios(request).then(resolve).catch(reject) + }) + this.running = [] // Clear the running queue after retrying requests + } catch (error) { + this.paused = false // stop queueing requests on failure + this.running.forEach(({ reject }) => reject(error)) // Reject all queued requests + this.running = [] // Clear the running queue + } + } + const delay = (time, isRefreshToken = false) => { if (!this.paused) { this.paused = true diff --git a/lib/core/contentstackHTTPClient.js b/lib/core/contentstackHTTPClient.js index 30f8dfad..e5d312c7 100644 --- a/lib/core/contentstackHTTPClient.js +++ b/lib/core/contentstackHTTPClient.js @@ -65,9 +65,31 @@ export default function contentstackHttpClient (options) { config.basePath = `/${config.basePath.split('/').filter(Boolean).join('/')}` } const baseURL = config.endpoint || `${protocol}://${hostname}:${port}${config.basePath}/{api-version}` + let uiHostName = hostname + let developerHubBaseUrl = hostname + + if (uiHostName?.endsWith('io')) { + uiHostName = uiHostName.replace('io', 'com') + } + + if (uiHostName?.startsWith('api')) { + uiHostName = uiHostName.replace('api', 'app') + } + const uiBaseUrl = config.endpoint || `${protocol}://${uiHostName}` + + developerHubBaseUrl = developerHubBaseUrl + ?.replace('api', 'developerhub-api') + .replace(/^dev\d+/, 'dev') // Replaces any 'dev1', 'dev2', etc. with 'dev' + .replace('io', 'com') + .replace(/^http/, '') // Removing `http` if already present + .replace(/^/, 'https://') // Adds 'https://' at the start if not already there + + // set ui host name const axiosOptions = { // Axios baseURL, + uiBaseUrl, + developerHubBaseUrl, ...config, paramsSerializer: function (params) { var query = params.query diff --git a/lib/core/oauthHandler.js b/lib/core/oauthHandler.js new file mode 100644 index 00000000..aa46c8f5 --- /dev/null +++ b/lib/core/oauthHandler.js @@ -0,0 +1,474 @@ +import errorFormatter from './contentstackError' + +/** + * @description OAuthHandler class to handle OAuth authorization and token management + * @class OAuthHandler + * @param {any} axiosInstance + * @param {any} appId + * @param {any} clientId + * @param {any} redirectUri + * @param {any} responseType='code' + * @param {any} clientSecret + * @param {any} scope=[] + * @returns {OAuthHandler} OAuthHandler instance + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + */ +export default class OAuthHandler { + constructor (axiosInstance, appId, clientId, redirectUri, clientSecret, responseType = 'code', scope = []) { + this.appId = appId + this.clientId = clientId + this.redirectUri = redirectUri + this.responseType = responseType + this.scope = scope.join(' ') + this.clientSecret = clientSecret // Optional, if provided, PKCE will be skipped + this.OAuthBaseURL = axiosInstance.defaults.uiBaseUrl + this.axiosInstance = axiosInstance + this.axiosInstance.oauth = axiosInstance?.oauth || {} + this.axiosInstance.oauth.redirectUri = redirectUri + this.axiosInstance.oauth.clientId = clientId + this.axiosInstance.oauth.appId = appId + this.developerHubBaseUrl = axiosInstance.defaults.developerHubBaseUrl + + // Only generate PKCE codeVerifier and codeChallenge if clientSecret is not provided + if (!this.clientSecret) { + this.codeVerifier = this.generateCodeVerifier() + this.codeChallenge = null + } + } + + // Helper function for setting common headers for API requests + _getHeaders () { + return { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + + // Generate a random string (code_verifier) + generateCodeVerifier (length = 128) { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' + return Array.from({ length }, () => charset.charAt(Math.floor(Math.random() * charset.length))).join('') + } + + async generateCodeChallenge (codeVerifier) { + // Check if in a browser environment or Node.js + if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle) { + // Use the native Web Crypto API in the browser + const encoder = new TextEncoder() + const data = encoder.encode(codeVerifier) + const hashBuffer = await window.crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const base64String = btoa(String.fromCharCode(...hashArray)) + return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') // URL-safe Base64 + } else { + // In Node.js: Use the native `crypto` module for hashing + const crypto = require('crypto') + + const hash = crypto.createHash('sha256') + hash.update(codeVerifier) + const hashBuffer = hash.digest() + + // Convert to Base64 and URL-safe Base64 + const base64String = hashBuffer.toString('base64') + return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') // URL-safe Base64 + } + } + + /** + * @description Authorize the user by redirecting to the OAuth provider's authorization page + * @memberof OAuthHandler + * @func authorize + * @returns {any} Authorization URL + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * const authUrl = await oauthHandler.authorize(); + */ + async authorize () { + try { + if (!this.OAuthBaseURL) { + throw new Error('OAuthBaseURL is not set') + } + const baseUrl = `${this.OAuthBaseURL}/#!/apps/${this.appId}/authorize` + const authUrl = new URL(baseUrl) + authUrl.searchParams.set('response_type', 'code') // Using set() to avoid duplicate parameters + authUrl.searchParams.set('client_id', this.clientId) + if (this.clientSecret) { + return authUrl.toString() + } else { + // PKCE flow: add code_challenge to the authorization URL + this.codeChallenge = await this.generateCodeChallenge(this.codeVerifier) + authUrl.searchParams.set('code_challenge', this.codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + return authUrl.toString() + } + } catch (error) { + errorFormatter(error) + } + } + + /** + * @description Exchange the authorization code for an access token + * @memberof OAuthHandler + * @func exchangeCodeForToken + * @param {any} code - Authorization code received from the OAuth provider + * @returns {any} Token data + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * const tokenData = await oauthHandler.exchangeCodeForToken('authorization_code'); + */ + async exchangeCodeForToken (code) { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri, + client_id: this.clientId, + ...(this.clientSecret ? { client_secret: this.clientSecret } : { code_verifier: this.codeVerifier }) // Choose between client_secret and code_verifier + }) + + this.axiosInstance.defaults.headers = this._getHeaders() + try { + const response = await this.axiosInstance.post(`${this.developerHubBaseUrl}/token`, body) + + this._saveTokens(response.data) + return response.data + } catch (error) { + errorFormatter(error) + } + } + + // Save tokens and token expiry details + _saveTokens (data) { + this.axiosInstance.oauth.accessToken = data.access_token + this.axiosInstance.oauth.refreshToken = data.refresh_token || this.axiosInstance.oauth.refreshToken + this.axiosInstance.oauth.organizationUID = data.organization_uid + this.axiosInstance.oauth.userUID = data.user_uid + this.axiosInstance.oauth.tokenExpiryTime = Date.now() + (data.expires_in - 60) * 1000 // Store expiry time + } + + /** + * @description Refreshes the access token using the provided refresh token or the one stored in the axios instance. + * @memberof OAuthHandler + * @func refreshAccessToken + * @param {string|null} [providedRefreshToken=null] - The refresh token to use for refreshing the access token. If not provided, the stored refresh token will be used. + * @returns {Promise} - A promise that resolves to the response data containing the new access token, refresh token, and expiry time. + * @throws {Error} - Throws an error if no refresh token is available or if the token refresh request fails. + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * const tokenData = await oauthHandler.refreshAccessToken(); + */ + async refreshAccessToken (providedRefreshToken = null) { + const refreshToken = providedRefreshToken || this.axiosInstance.oauth.refreshToken + + if (!refreshToken) { + throw new Error('No refresh token available. Please authenticate first.') + } + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: this.axiosInstance.oauth.clientId, + redirect_uri: this.axiosInstance.oauth.redirectUri + }) + + this.axiosInstance.defaults.headers = this._getHeaders() + try { + const response = await this.axiosInstance.post(`${this.developerHubBaseUrl}/token`, body) + + const data = response.data + this.axiosInstance.oauth.accessToken = data.access_token + this.axiosInstance.oauth.refreshToken = data.refresh_token || this.axiosInstance.oauth.refreshToken // Optionally update refresh token + this.axiosInstance.oauth.tokenExpiryTime = Date.now() + (data.expires_in - 60) * 1000 // Update expiry time + return data + } catch (error) { + errorFormatter(error) + } + } + + /** + * @description Logs out the user by revoking the OAuth app authorization + * @memberof OAuthHandler + * @func logout + * @returns {Promise} - A promise that resolves to a success message if the logout was successful. + * @throws {Error} - Throws an error if the logout request fails. + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * const resp = await oauthHandler.logout(); + */ + async logout () { + try { + const authorizationId = await this.getOauthAppAuthorization() + await this.revokeOauthAppAuthorization(authorizationId) + this.axiosInstance.oauth = {} // Clear OAuth data + return 'Logged out successfully' + } catch (error) { + errorFormatter(error) + } + } + + /** + * @description Get the current access token + * @memberof OAuthHandler + * @func getAccessToken + * @returns {any} + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * const accessToken = oauthHandler.getAccessToken(); + */ + getAccessToken () { + return this.axiosInstance.oauth.accessToken + } + + /** + * @description Get the refresh token from the axios instance + * @memberof OAuthHandler + * @func getRefreshToken + * @returns {string|null} + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * const refreshToken = oauthHandler.getRefreshToken(); + */ + getRefreshToken () { + return this.axiosInstance.oauth.refreshToken || null + } + + /** + * @description Get the organization UID from the axios instance + * @memberof OAuthHandler + * @func getOrganizationUID + * @returns {string|null} + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * const orgUID = oauthHandler.getOrganizationUID(); + */ + getOrganizationUID () { + return this.axiosInstance.oauth.organizationUID || null + } + + /** + * @description Get the user UID from the axios instance + * @memberof OAuthHandler + * @func getUserUID + * @returns {string|null} + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * const userId = oauthHandler.getUserUID(); + */ + getUserUID () { + return this.axiosInstance.oauth.userUID || null + } + + /** + * @description Get the token expiry time from the axios instance + * @memberof OAuthHandler + * @func getTokenExpiryTime + * @returns {number|null} + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * const expiryTime = oauthHandler.getTokenExpiryTime(); + */ + getTokenExpiryTime () { + return this.axiosInstance.oauth.tokenExpiryTime || null + } + + /** + * @description Set the access token in the axios instance + * @memberof OAuthHandler + * @func setAccessToken + * @param {*} token + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * oauthHandler.setAccessToken('accessToken'); + */ + setAccessToken (token) { + if (!token) { + throw new Error('Access token is required') + } + this.axiosInstance.oauth.accessToken = token + } + + /** + * @description Set the refresh token in the axios instance + * @memberof OAuthHandler + * @func setRefreshToken + * @param {*} token + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * oauthHandler.setRefreshToken('refreshToken'); + */ + setRefreshToken (token) { + if (!token) { + throw new Error('Refresh token is required') + } + this.axiosInstance.oauth.refreshToken = token + } + + /** + * @description Set the organization UID in the axios instance + * @memberof OAuthHandler + * @func setOrganizationUID + * @param {*} organizationUID + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * oauthHandler.setOrganizationUID('organizationUID'); + */ + setOrganizationUID (organizationUID) { + if (!organizationUID) { + throw new Error('Organization UID is required') + } + this.axiosInstance.oauth.organizationUID = organizationUID + } + + /** + * @description Set the user UID in the axios instance + * @memberof OAuthHandler + * @func setUserUID + * @param {*} userUID + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * oauthHandler.setUserUID('userID'); + */ + setUserUID (userUID) { + if (!userUID) { + throw new Error('User UID is required') + } + this.axiosInstance.oauth.userUID = userUID + } + + /** + * @description Set the token expiry time in the axios instance + * @memberof OAuthHandler + * @func setTokenExpiryTime + * @param {*} expiryTime + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * oauthHandler.setTokenExpiryTime('expiryTime'); // date format + */ + setTokenExpiryTime (expiryTime) { + if (!expiryTime) { + throw new Error('Token expiry time is required') + } + this.axiosInstance.oauth.tokenExpiryTime = expiryTime + } + + /** + * @description Handles the redirect URL after OAuth authorization + * @memberof OAuthHandler + * @func handleRedirect + * @async + * @param {string} url - The URL to handle after the OAuth authorization + * @returns {Promise} - A promise that resolves if the redirect URL is successfully handled + * @throws {Error} - Throws an error if the authorization code is not found in the redirect URL + * @example + * import * as contentstack from '@contentstack/management' + * const client = contentstack.client(); + * const oauthHandler = client.oauth({ appId: 'appId', clientId: 'clientId', redirectUri: 'http://localhost:8184' }); + * await oauthHandler.handleRedirect('http://localhost:8184?code=authorization_code'); + */ + async handleRedirect (url) { + const urlParams = new URLSearchParams(new URL(url).search) + const code = urlParams.get('code') + + if (code) { + try { + await this.exchangeCodeForToken(code) + } catch (error) { + errorFormatter(error) + } + } else { + throw new Error('Authorization code not found in redirect URL.') + } + } + + /** + * @description Get the OAuth app authorization for the current user + * @memberof OAuthHandler + * @func getOauthAppAuthorization + * @returns {any} + */ + async getOauthAppAuthorization () { + const headers = { + authorization: `Bearer ${this.axiosInstance.oauth.accessToken}`, + organization_uid: this.axiosInstance.oauth.organizationUID, + 'Content-type': 'application/json' + } + + this.axiosInstance.defaults.headers = headers + try { + const res = await this.axiosInstance.get( + `${this.developerHubBaseUrl}/manifests/${this.axiosInstance.oauth.appId}/authorizations` + ) + + const data = res.data + if (data?.data?.length > 0) { + const userUid = this.axiosInstance.oauth.userUID + const currentUserAuthorization = data?.data?.filter((element) => element.user.uid === userUid) || [] + if (currentUserAuthorization.length === 0) { + throw new Error('No authorizations found for current user!') + } + return currentUserAuthorization[0].authorization_uid // filter authorizations by current logged in user + } else { + throw new Error('No authorizations found for the app!') + } + } catch (error) { + errorFormatter(error) + } + } + + /** + * @description Revoke the OAuth app authorization for the current user + * @memberof OAuthHandler + * @func revokeOauthAppAuthorization + * @param {any} authorizationId + * @returns {any} + */ + async revokeOauthAppAuthorization (authorizationId) { + if (authorizationId?.length > 1) { + const headers = { + authorization: `Bearer ${this.axiosInstance.oauth.accessToken}`, + organization_uid: this.axiosInstance.oauth.organizationUID, + 'Content-type': 'application/json' + } + + this.axiosInstance.defaults.headers = headers + try { + const res = await this.axiosInstance.delete( + `${this.developerHubBaseUrl}/manifests/${this.axiosInstance.oauth.appId}/authorizations/${authorizationId}` + ) + + return res.data + } catch (error) { + errorFormatter(error) + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 639e3179..95c52f07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,21 @@ { "name": "@contentstack/management", - "version": "1.19.6", + "version": "1.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@contentstack/management", - "version": "1.19.6", + "version": "1.20.0", "license": "MIT", "dependencies": { - "axios": "^1.8.3", + "assert": "^2.1.0", + "axios": "^1.8.4", + "buffer": "^6.0.3", "form-data": "^4.0.2", "lodash": "^4.17.21", - "qs": "^6.14.0" + "qs": "^6.14.0", + "stream-browserify": "^3.0.0" }, "devDependencies": { "@babel/cli": "^7.26.4", @@ -3646,6 +3649,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "dev": true, @@ -3668,7 +3683,6 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -4138,6 +4152,25 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/big.js": { "version": "5.2.2", "dev": true, @@ -4256,6 +4289,29 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "dev": true, @@ -4315,7 +4371,6 @@ }, "node_modules/call-bind": { "version": "1.0.8", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -4847,7 +4902,6 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -4863,7 +4917,6 @@ }, "node_modules/define-properties": { "version": "1.2.1", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -6010,7 +6063,6 @@ }, "node_modules/for-each": { "version": "0.3.5", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -6386,7 +6438,6 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -6421,6 +6472,8 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6512,6 +6565,25 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.3.2", "dev": true, @@ -6639,7 +6711,6 @@ }, "node_modules/inherits": { "version": "2.0.4", - "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -6681,7 +6752,6 @@ }, "node_modules/is-arguments": { "version": "1.2.0", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6797,7 +6867,6 @@ }, "node_modules/is-callable": { "version": "1.2.7", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6896,7 +6965,6 @@ }, "node_modules/is-generator-function": { "version": "1.1.0", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6933,6 +7001,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "dev": true, @@ -7020,7 +7103,6 @@ }, "node_modules/is-regex": { "version": "1.2.1", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7104,7 +7186,6 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -9891,7 +9972,6 @@ }, "node_modules/object-is": { "version": "1.1.6", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -9906,7 +9986,6 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9914,7 +9993,6 @@ }, "node_modules/object.assign": { "version": "4.1.7", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10396,7 +10474,6 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10607,6 +10684,19 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "dev": true, @@ -11040,7 +11130,6 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", - "dev": true, "funding": [ { "type": "github", @@ -11074,7 +11163,6 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -11196,7 +11284,6 @@ }, "node_modules/set-function-length": { "version": "1.2.2", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -11488,6 +11575,23 @@ "node": ">= 0.8" } }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "dev": true, @@ -12339,6 +12443,23 @@ "punycode": "^2.1.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/utils-merge": { "version": "1.0.1", "dev": true, @@ -12689,7 +12810,6 @@ }, "node_modules/which-typed-array": { "version": "1.1.19", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", diff --git a/package.json b/package.json index 76a74ee0..e7c3769a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/management", - "version": "1.19.6", + "version": "1.20.0", "description": "The Content Management API is used to manage the content of your Contentstack account", "main": "./dist/node/contentstack-management.js", "browser": "./dist/web/contentstack-management.js", @@ -52,10 +52,13 @@ "author": "Contentstack", "license": "MIT", "dependencies": { - "axios": "^1.8.3", + "assert": "^2.1.0", + "axios": "^1.8.4", + "buffer": "^6.0.3", "form-data": "^4.0.2", "lodash": "^4.17.21", - "qs": "^6.14.0" + "qs": "^6.14.0", + "stream-browserify": "^3.0.0" }, "keywords": [ "contentstack management api", 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 + }) + }) +}) diff --git a/types/contentstackClient.d.ts b/types/contentstackClient.d.ts index 5374df27..2689c0ed 100644 --- a/types/contentstackClient.d.ts +++ b/types/contentstackClient.d.ts @@ -6,6 +6,7 @@ import { Response } from './contentstackCollection' import { Stack, StackConfig, StackDetails } from './stack' import { Organization, Organizations } from './organization' import { Queryable } from './utility/operations' +import OAuthHandler from './oauthHandler' export interface ProxyConfig { host: string @@ -67,6 +68,8 @@ export interface ContentstackClient { organization(): Organizations organization(uid: string): Organization + + oauth(params?: any): OAuthHandler } export function client(config?: ContentstackConfig): ContentstackClient \ No newline at end of file diff --git a/types/oauthHandler.d.ts b/types/oauthHandler.d.ts new file mode 100644 index 00000000..0a16bf29 --- /dev/null +++ b/types/oauthHandler.d.ts @@ -0,0 +1,121 @@ +// Interface to define the structure of the OAuth response +interface OAuthResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + organization_uid: string; + user_uid: string; + token_type: string; + location: string; + region: string; + authorization_type: string; + stack_api_key: string; +} + +export default class OAuthHandler { + /** + * Generate the authorization URL for OAuth + * @returns A promise that resolves to the authorization URL + */ + authorize(): Promise; + + /** + * Exchange the authorization code for an access token + * @param code - The authorization code + * @returns A promise that resolves to the OAuth response + */ + exchangeCodeForToken(code: string): Promise; + + /** + * Refresh the access token using the refresh token + * @param providedRefreshToken - The refresh token to use (optional) + * @returns A promise that resolves to the OAuth response + */ + refreshAccessToken(providedRefreshToken?: string): Promise; + + /** + * Log the user out by revoking the OAuth app authorization + * @returns A promise that resolves to a success message + */ + logout(): Promise; + + /** + * Get the current access token + * @returns The access token + */ + getAccessToken(): string; + + /** + * Get the current refresh token + * @returns The refresh token + */ + getRefreshToken(): string; + + /** + * Get the current organization UID + * @returns The organization UID + */ + getOrganizationUID(): string; + + /** + * Get the current user UID + * @returns The user UID + */ + getUserUID(): string; + + /** + * Get the token expiry time + * @returns The token expiry time + */ + getTokenExpiryTime(): string; + + /** + * Set the access token + * @param token - The access token + */ + setAccessToken(token: string): void; + + /** + * Set the refresh token + * @param token - The refresh token + */ + setRefreshToken(token: string): void; + + /** + * Set organization UID + * @param organizationUID - The organization UID + */ + setOrganizationUID(organizationUID: string): void; + + /** + * Set user UID + * @param userUID - The user UID + */ + setUserUID(userUID: string): void; + + /** + * Set expiry time + * @param expiryTime - The expiry time + */ + setTokenExpiryTime(expiryTime: Date): void; + + /** + * Handle the OAuth redirect URL and exchange the authorization code for a token + * @param url - The redirect URL containing the authorization code + * @returns A promise that resolves when the OAuth code is exchanged for a token + */ + handleRedirect(url: string): Promise; + + /** + * Get the OAuth app authorization for the current user + * @returns A promise that resolves to the authorization UID + */ + getOauthAppAuthorization(): Promise; + + /** + * Revoke the OAuth app authorization + * @param authorizationId - The authorization ID to revoke + * @returns A promise that resolves to the response from the API + */ + revokeOauthAppAuthorization(authorizationId: string): Promise; +} diff --git a/webpack/webpack.nativescript.js b/webpack/webpack.nativescript.js index 7ab1290d..965876a5 100644 --- a/webpack/webpack.nativescript.js +++ b/webpack/webpack.nativescript.js @@ -12,7 +12,11 @@ module.exports = function (options) { }, resolve: { fallback: { - os: false + os: false, + crypto: false, + stream: require.resolve('stream-browserify'), + assert: require.resolve('assert'), + buffer: require.resolve('buffer') } }, module: { diff --git a/webpack/webpack.node.js b/webpack/webpack.node.js index bb3f87df..aa0cbb11 100644 --- a/webpack/webpack.node.js +++ b/webpack/webpack.node.js @@ -10,6 +10,16 @@ module.exports = function (options) { filename: 'contentstack-management.js' }, target: 'node', + resolve: { + fallback: { + os: require.resolve('os-browserify/browser'), + fs: false, + crypto: false, + stream: require.resolve('stream-browserify'), + assert: require.resolve('assert'), + buffer: require.resolve('buffer') + }, + }, module: { rules: [{ test: /\.js?$/, diff --git a/webpack/webpack.react-native.js b/webpack/webpack.react-native.js index 727d8aa5..e2aaa4c3 100644 --- a/webpack/webpack.react-native.js +++ b/webpack/webpack.react-native.js @@ -12,7 +12,11 @@ module.exports = function (options) { }, resolve: { fallback: { - os: false + os: false, + crypto: false, + stream: require.resolve('stream-browserify'), + assert: require.resolve('assert'), + buffer: require.resolve('buffer') } }, module: { diff --git a/webpack/webpack.web.js b/webpack/webpack.web.js index bbb5ccd8..73cefcf2 100644 --- a/webpack/webpack.web.js +++ b/webpack/webpack.web.js @@ -16,8 +16,12 @@ module.exports = function (options) { resolve: { fallback: { os: require.resolve('os-browserify/browser'), - fs: false - } + fs: false, + crypto: false, + stream: require.resolve('stream-browserify'), + assert: require.resolve('assert'), + buffer: require.resolve('buffer') + }, }, module: { rules: [{