Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .talismanrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
fileignoreconfig:
- filename: lib/core/pkceStorage.js
checksum: e060690c5ed348a6914df7ecc36de5b6b45f9a7c3a9c164c88bd2c7fad2bea08
- filename: test/unit/oauthHandler-test.js
checksum: 95a968c0d72d5bbe9e1acb30ea17ab505938f6174e917d7a25dda8facfda5a49
- filename: test/unit/pkceStorage-test.js
checksum: 567f557d37e8119c22cd4c5c4014c16dd660c03be35f65e803fb340cfd4b2136
- filename: test/unit/globalField-test.js
checksum: 25185e3400a12e10a043dc47502d8f30b7e1c4f2b6b4d3b8b55cdc19850c48bf
- filename: lib/stack/index.js
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Changelog

## [v1.27.5](https://github.com/contentstack/contentstack-management-javascript/tree/v1.27.5) (2026-02-16)
- Enhancement
- OAuth PKCE: store code_verifier in sessionStorage (browser only) so token exchange works after redirect in React and other SPAs; verifier is restored on callback, cleared after successful exchange or on error; 10-minute expiry; Node remains memory-only
- Extracted PKCE storage into `lib/core/pkceStorage.js` module
- Fix
- Skip token refresh on 401 when API returns error_code 161 (environment/permission) so the actual API error is returned instead of triggering refresh and a generic "Unable to refresh token" message
- When token refresh fails after a 401, return the original API error (error_message, error_code) instead of the generic "Unable to refresh token" message
Expand Down
11 changes: 10 additions & 1 deletion lib/core/oauthHandler.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import errorFormatter from './contentstackError'
import { ERROR_MESSAGES } from './errorMessages'
import { getStoredCodeVerifier, storeCodeVerifier, clearStoredCodeVerifier } from './pkceStorage'

/**
* @description OAuthHandler class to handle OAuth authorization and token management
Expand Down Expand Up @@ -35,7 +36,13 @@ export default class OAuthHandler {

// Only generate PKCE codeVerifier and codeChallenge if clientSecret is not provided
if (!this.clientSecret) {
this.codeVerifier = this.generateCodeVerifier()
const stored = getStoredCodeVerifier(this.appId, this.clientId, this.redirectUri)
if (stored) {
this.codeVerifier = stored
} else {
this.codeVerifier = this.generateCodeVerifier()
storeCodeVerifier(this.appId, this.clientId, this.redirectUri, this.codeVerifier)
}
this.codeChallenge = null
}
}
Expand Down Expand Up @@ -139,8 +146,10 @@ export default class OAuthHandler {
const response = await this.axiosInstance.post(`${this.developerHubBaseUrl}/token`, body)

this._saveTokens(response.data)
clearStoredCodeVerifier(this.appId, this.clientId, this.redirectUri) // Clear immediately after successful exchange to prevent replay
return response.data
} catch (error) {
clearStoredCodeVerifier(this.appId, this.clientId, this.redirectUri) // Clear on error to prevent replay attacks
errorFormatter(error)
}
}
Expand Down
68 changes: 68 additions & 0 deletions lib/core/pkceStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* PKCE code_verifier persistence in sessionStorage for browser SPAs.
* Survives OAuth redirects; not used in Node. RFC 7636 / OAuth 2.0 for Browser-Based Apps.
*/

const PKCE_STORAGE_KEY_PREFIX = 'contentstack_oauth_pkce'
const PKCE_STORAGE_EXPIRY_MS = 10 * 60 * 1000 // 10 minutes

function isBrowser () {
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined'
}

function getStorageKey (appId, clientId, redirectUri) {
return `${PKCE_STORAGE_KEY_PREFIX}_${appId}_${clientId}_${redirectUri}`
}

/**
* @param {string} appId
* @param {string} clientId
* @param {string} redirectUri
* @returns {string|null} code_verifier if valid and not expired, otherwise null
*/
export function getStoredCodeVerifier (appId, clientId, redirectUri) {
if (!isBrowser()) return null
try {
const raw = window.sessionStorage.getItem(getStorageKey(appId, clientId, redirectUri))
if (!raw) return null
const { codeVerifier, expiresAt } = JSON.parse(raw)
if (!codeVerifier || !expiresAt || Date.now() > expiresAt) return null
return codeVerifier
} catch {
return null
}
}

/**
* @param {string} appId
* @param {string} clientId
* @param {string} redirectUri
* @param {string} codeVerifier
*/
export function storeCodeVerifier (appId, clientId, redirectUri, codeVerifier) {
if (!isBrowser()) return
try {
const key = getStorageKey(appId, clientId, redirectUri)
const value = JSON.stringify({
codeVerifier,
expiresAt: Date.now() + PKCE_STORAGE_EXPIRY_MS
})
window.sessionStorage.setItem(key, value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nadeem-cs , can we use httpcookies instead of session storage? Session storage might create security issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aman19K Actually using sessionStorage is more recommended approach for the issue we're trying to resolve here - browser compatibility. It does not give any security concern. It will be only risky if the app has XSS bug , only then it can read the session cookie. Otherwise this is more recommended approach.

} catch {
// Ignore storage errors (e.g. private mode); fall back to memory-only
}
}

/**
* @param {string} appId
* @param {string} clientId
* @param {string} redirectUri
*/
export function clearStoredCodeVerifier (appId, clientId, redirectUri) {
if (!isBrowser()) return
try {
window.sessionStorage.removeItem(getStorageKey(appId, clientId, redirectUri))
} catch {
// Ignore
}
}
1 change: 1 addition & 0 deletions test/unit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ require('./ungroupedVariants-test')
require('./variantsWithVariantsGroup-test')
require('./variants-entry-test')
require('./oauthHandler-test')
require('./pkceStorage-test')
140 changes: 140 additions & 0 deletions test/unit/oauthHandler-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,4 +329,144 @@ describe('OAuthHandler', () => {
expect(deleteStub.called).to.be.false
})
})

describe('PKCE sessionStorage (browser)', () => {
let sessionStorageStub

beforeEach(() => {
sessionStorageStub = {
getItem: sandbox.stub(),
setItem: sandbox.stub(),
removeItem: sandbox.stub()
}
global.window = { sessionStorage: sessionStorageStub }
})

afterEach(() => {
delete global.window
})

it('should store code_verifier in sessionStorage when generated (browser)', () => {
sessionStorageStub.getItem.returns(null)

const handler = new OAuthHandler(
axiosInstance,
'appId',
'clientId',
'http://localhost:8184',
null
)

expect(handler.codeVerifier).to.be.a('string')
expect(handler.codeVerifier).to.have.lengthOf(128)
expect(sessionStorageStub.setItem.calledOnce).to.equal(true)
const [key, valueStr] = sessionStorageStub.setItem.firstCall.args
expect(key).to.include('contentstack_oauth_pkce')
expect(key).to.include('appId')
expect(key).to.include('clientId')
const value = JSON.parse(valueStr)
expect(value).to.have.property('codeVerifier', handler.codeVerifier)
expect(value).to.have.property('expiresAt')
expect(value.expiresAt).to.be.greaterThan(Date.now())
})

it('should retrieve code_verifier from sessionStorage in constructor when valid', () => {
const storedVerifier = 'stored_code_verifier_xyz'
const storedValue = JSON.stringify({
codeVerifier: storedVerifier,
expiresAt: Date.now() + 600000
})
sessionStorageStub.getItem.returns(storedValue)

const handler = new OAuthHandler(
axiosInstance,
'appId',
'clientId',
'http://localhost:8184',
null
)

expect(handler.codeVerifier).to.equal(storedVerifier)
expect(sessionStorageStub.setItem.called).to.equal(false)
})

it('should not use expired sessionStorage entry and should generate new code_verifier', () => {
const expiredValue = JSON.stringify({
codeVerifier: 'expired_verifier',
expiresAt: Date.now() - 1000
})
sessionStorageStub.getItem.returns(expiredValue)

const handler = new OAuthHandler(
axiosInstance,
'appId',
'clientId',
'http://localhost:8184',
null
)

expect(handler.codeVerifier).to.not.equal('expired_verifier')
expect(handler.codeVerifier).to.have.lengthOf(128)
expect(sessionStorageStub.setItem.calledOnce).to.equal(true)
})

it('should clear sessionStorage after successful token exchange', async () => {
sessionStorageStub.getItem.returns(null)
const handler = new OAuthHandler(
axiosInstance,
'appId',
'clientId',
'http://localhost:8184',
null
)
const tokenData = { access_token: 'accessToken', refresh_token: 'refreshToken', expires_in: 3600 }
sandbox.stub(axiosInstance, 'post').resolves({ data: tokenData })

await handler.exchangeCodeForToken('authorization_code')

expect(sessionStorageStub.removeItem.calledOnce).to.equal(true)
expect(sessionStorageStub.removeItem.firstCall.args[0]).to.include('contentstack_oauth_pkce')
})

it('should clear sessionStorage on token exchange error to prevent replay attacks', async () => {
sessionStorageStub.getItem.returns(null)
const handler = new OAuthHandler(
axiosInstance,
'appId',
'clientId',
'http://localhost:8184',
null
)
sandbox.stub(axiosInstance, 'post').rejects(new Error('invalid_code_verifier'))

try {
await handler.exchangeCodeForToken('authorization_code')
} catch {
// errorFormatter rethrows; we only care that removeItem was called
}
expect(sessionStorageStub.removeItem.calledOnce).to.equal(true)
})
})

describe('PKCE memory-only (Node / no sessionStorage)', () => {
it('should use memory-only code_verifier when window is not defined', () => {
const originalWindow = global.window
delete global.window

const handler = new OAuthHandler(
axiosInstance,
'appId',
'clientId',
'http://localhost:8184',
null
)

expect(handler.codeVerifier).to.be.a('string')
expect(handler.codeVerifier).to.have.lengthOf(128)

if (originalWindow !== undefined) {
global.window = originalWindow
}
})
})
})
119 changes: 119 additions & 0 deletions test/unit/pkceStorage-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { expect } from 'chai'
import sinon from 'sinon'
import {
getStoredCodeVerifier,
storeCodeVerifier,
clearStoredCodeVerifier
} from '../../lib/core/pkceStorage'
import { describe, it, beforeEach, afterEach } from 'mocha'

describe('pkceStorage', () => {
let sessionStorageStub

beforeEach(() => {
sessionStorageStub = {
getItem: sinon.stub(),
setItem: sinon.stub(),
removeItem: sinon.stub()
}
global.window = { sessionStorage: sessionStorageStub }
})

afterEach(() => {
delete global.window
})

describe('getStoredCodeVerifier', () => {
it('returns null when not in browser', () => {
delete global.window
expect(getStoredCodeVerifier('appId', 'clientId', 'http://localhost:8184')).to.equal(null)
})

it('returns null when nothing stored', () => {
sessionStorageStub.getItem.returns(null)
expect(getStoredCodeVerifier('appId', 'clientId', 'http://localhost:8184')).to.equal(null)
})

it('returns code_verifier when valid and not expired', () => {
const stored = JSON.stringify({
codeVerifier: 'stored_verifier_xyz',
expiresAt: Date.now() + 600000
})
sessionStorageStub.getItem.returns(stored)
expect(getStoredCodeVerifier('appId', 'clientId', 'http://localhost:8184')).to.equal('stored_verifier_xyz')
})

it('returns null when stored entry is expired', () => {
const stored = JSON.stringify({
codeVerifier: 'expired_verifier',
expiresAt: Date.now() - 1000
})
sessionStorageStub.getItem.returns(stored)
expect(getStoredCodeVerifier('appId', 'clientId', 'http://localhost:8184')).to.equal(null)
})

it('returns null when storage throws', () => {
sessionStorageStub.getItem.throws(new Error('QuotaExceeded'))
expect(getStoredCodeVerifier('appId', 'clientId', 'http://localhost:8184')).to.equal(null)
})

it('uses key containing appId, clientId, redirectUri', () => {
sessionStorageStub.getItem.returns(null)
getStoredCodeVerifier('myApp', 'myClient', 'https://app.example/cb')
expect(sessionStorageStub.getItem.calledOnce).to.equal(true)
const key = sessionStorageStub.getItem.firstCall.args[0]
expect(key).to.include('contentstack_oauth_pkce')
expect(key).to.include('myApp')
expect(key).to.include('myClient')
expect(key).to.include('https://app.example/cb')
})
})

describe('storeCodeVerifier', () => {
it('does nothing when not in browser', () => {
delete global.window
storeCodeVerifier('appId', 'clientId', 'http://localhost:8184', 'verifier123')
expect(sessionStorageStub.setItem.called).to.equal(false)
})

it('stores codeVerifier and expiresAt in sessionStorage', () => {
const before = Date.now()
storeCodeVerifier('appId', 'clientId', 'http://localhost:8184', 'verifier123')
const after = Date.now()
expect(sessionStorageStub.setItem.calledOnce).to.equal(true)
const [key, valueStr] = sessionStorageStub.setItem.firstCall.args
expect(key).to.include('contentstack_oauth_pkce')
const value = JSON.parse(valueStr)
expect(value.codeVerifier).to.equal('verifier123')
expect(value.expiresAt).to.be.at.least(before + 9 * 60 * 1000)
expect(value.expiresAt).to.be.at.most(after + 10 * 60 * 1000 + 100)
})

it('does not throw when sessionStorage.setItem throws', () => {
sessionStorageStub.setItem.throws(new Error('QuotaExceeded'))
expect(() => storeCodeVerifier('appId', 'clientId', 'http://localhost:8184', 'v')).to.not.throw()
})
})

describe('clearStoredCodeVerifier', () => {
it('does nothing when not in browser', () => {
delete global.window
clearStoredCodeVerifier('appId', 'clientId', 'http://localhost:8184')
expect(sessionStorageStub.removeItem.called).to.equal(false)
})

it('calls sessionStorage.removeItem with correct key', () => {
clearStoredCodeVerifier('appId', 'clientId', 'http://localhost:8184')
expect(sessionStorageStub.removeItem.calledOnce).to.equal(true)
const key = sessionStorageStub.removeItem.firstCall.args[0]
expect(key).to.include('contentstack_oauth_pkce')
expect(key).to.include('appId')
expect(key).to.include('clientId')
})

it('does not throw when sessionStorage.removeItem throws', () => {
sessionStorageStub.removeItem.throws(new Error('SecurityError'))
expect(() => clearStoredCodeVerifier('appId', 'clientId', 'http://localhost:8184')).to.not.throw()
})
})
})
Loading