feat(auth): prepend 'Bearer' to auth token generated by tokenHelper (#11097)

* fix(auth-header): decode _password from base64 for default registry auth

* fix(auth): prepend 'Bearer ' to auth token generated by tokenHelper

* test: skip flaky parallel dlx test on Node 25

* fix(auth): improve tokenHelper Bearer prefix with validation and generic scheme detection

- Throw an error when the token helper returns an empty token instead of
  producing an invalid "Bearer " header
- Use a generic auth scheme regex instead of hardcoding only Bearer/Basic,
  so other schemes (Token, Negotiate, etc.) are preserved as-is
- Add tests for raw token prefixing, existing scheme preservation, and
  empty token error

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Burra Karthikeya
2026-03-26 20:03:02 +05:30
committed by GitHub
parent 659e0ea0cc
commit b1ad9c7d83
8 changed files with 63 additions and 2 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/network.auth-header": patch
"pnpm": patch
---
Prepended `Bearer` to the authorization token generated by `tokenHelper` executable if it is missing, properly aligning pnpm's handling of token helpers with npm.

View File

@@ -72,5 +72,14 @@ export function loadToken (helperPath: string, settingName: string): string {
if (spawnResult.status !== 0) {
throw new PnpmError('TOKEN_HELPER_ERROR_STATUS', `Error running "${helperPath}" as a token helper, configured as ${settingName}. Exit code ${spawnResult.status?.toString() ?? ''}`)
}
return spawnResult.stdout.toString('utf8').trimEnd()
const token = spawnResult.stdout.toString('utf8').trimEnd()
if (!token) {
throw new PnpmError('TOKEN_HELPER_EMPTY_TOKEN', `Token helper "${helperPath}", configured as ${settingName}, returned an empty token`)
}
// If the token already contains an auth scheme (e.g. "Bearer ...", "Basic ..."),
// return it as-is.
if (/^[A-Z]+ /i.test(token)) {
return token
}
return `Bearer ${token}`
}

View File

@@ -10,6 +10,16 @@ const osTokenHelper = {
win32: path.join(import.meta.dirname, 'utils/test-exec.bat'),
}
const osRawTokenHelper = {
linux: path.join(import.meta.dirname, 'utils/test-exec-raw-token.js'),
win32: path.join(import.meta.dirname, 'utils/test-exec-raw-token.bat'),
}
const osEmptyTokenHelper = {
linux: path.join(import.meta.dirname, 'utils/test-exec-empty-token.js'),
win32: path.join(import.meta.dirname, 'utils/test-exec-empty-token.bat'),
}
const osErrorTokenHelper = {
linux: path.join(import.meta.dirname, 'utils/test-exec-error.js'),
win32: path.join(import.meta.dirname, 'utils/test-exec-error.bat'),
@@ -112,6 +122,30 @@ describe('getAuthHeadersFromConfig()', () => {
}
expect(getAuthHeadersFromConfig({ allSettings, userSettings: {} })).toStrictEqual({})
})
it('should prepend Bearer to raw token from tokenHelper', () => {
const userSettings = {
'//registry.foobar.eu/:tokenHelper': osRawTokenHelper[osFamily],
}
expect(getAuthHeadersFromConfig({ allSettings: {}, userSettings })).toStrictEqual({
'//registry.foobar.eu/': 'Bearer raw-token-no-scheme',
})
})
it('should not modify token that already has an auth scheme', () => {
const userSettings = {
'//registry.foobar.eu/:tokenHelper': osTokenHelper[osFamily],
}
expect(getAuthHeadersFromConfig({ allSettings: {}, userSettings })).toStrictEqual({
'//registry.foobar.eu/': 'Bearer token-from-spawn',
})
})
it('should throw an error if the token helper returns an empty token', () => {
expect(() => getAuthHeadersFromConfig({
allSettings: {},
userSettings: {
'//reg.com:tokenHelper': osEmptyTokenHelper[osFamily],
},
})).toThrow('returned an empty token')
})
})
function encodeBase64 (s: string) {

View File

@@ -0,0 +1,2 @@
@echo off
REM Outputs nothing

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
// Outputs nothing

View File

@@ -0,0 +1,2 @@
@echo off
echo raw-token-no-scheme

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
console.log('raw-token-no-scheme')

View File

@@ -92,7 +92,9 @@ test('dlx should work with npm_config_save_dev env variable', async () => {
})
})
test('parallel dlx calls of the same package', async () => {
const testParallel = process.version.startsWith('v25.') ? test.skip : test
testParallel('parallel dlx calls of the same package', async () => {
prepareEmpty()
// parallel dlx calls without cache