-
Notifications
You must be signed in to change notification settings - Fork 1.6k
fix(security): redact invalid workload identity access tokens #2425
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
HAYDEN-OAI
merged 5 commits into
main
from
autoimprove-workload-access-token-privacy-mt0pc2hu
Aug 20, 2026
Merged
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e7f6bf3
fix(security): redact invalid workload identity access tokens
HAYDEN-OAI 19186b4
fix(auth): reject leading and trailing workload token whitespace
HAYDEN-OAI 7ef9e82
fix(auth): validate one immutable workload token snapshot
HAYDEN-OAI 7142b6e
fix(auth): reject Bun-rewritten workload bearer credentials
HAYDEN-OAI 43a5299
test(bun): exercise workload bearer privacy in offline CI
HAYDEN-OAI File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
376 changes: 376 additions & 0 deletions
376
tests/auth/workload-identity-access-token-privacy.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,376 @@ | ||
| import { once } from 'node:events'; | ||
| import { createServer } from 'node:http'; | ||
| import type { Server } from 'node:http'; | ||
|
|
||
| import { vi } from 'vitest'; | ||
|
|
||
| import OpenAI, { OAuthError, OpenAIError } from 'openai'; | ||
| import { WorkloadIdentityAuth } from 'openai/auth/workload-identity-auth'; | ||
| import type { WorkloadIdentity } from 'openai/auth/types'; | ||
| import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; | ||
|
|
||
| const OAUTH_URL = 'https://auth.openai.com/oauth/token'; | ||
| const ACCESS_SECRET = 'private-openai-access-token-44b9'; | ||
| const PRIVATE_PATIENT = 'private-patient-record-c7e1'; | ||
| const SAFE_ERROR = "Token exchange response missing 'access_token' field"; | ||
|
|
||
| type Surface = 'direct-auth' | 'public-client'; | ||
| type TokenType = WorkloadIdentity['provider']['tokenType']; | ||
|
|
||
| const surfaces: readonly Surface[] = ['direct-auth', 'public-client']; | ||
| const tokenTypes: readonly TokenType[] = ['jwt', 'id']; | ||
| const malformedCharacters = [ | ||
| ...Array.from({ length: 0x20 }, (_, code) => code) | ||
| .filter((code) => code !== 0x09) | ||
| .map((code) => ({ name: 'C0 control', code, character: String.fromCodePoint(code) })), | ||
| { name: 'DEL', code: 0x7f, character: String.fromCodePoint(0x7f) }, | ||
| { name: 'non-ByteString Unicode', code: 0x01_00, character: String.fromCodePoint(0x01_00) }, | ||
| { name: 'astral Unicode', code: 0x01_f6_80, character: String.fromCodePoint(0x01_f6_80) }, | ||
| { name: 'unpaired surrogate', code: 0xd8_00, character: String.fromCodePoint(0xd8_00) }, | ||
| { name: 'carriage-return line-feed', code: -1, character: String.fromCodePoint(0x0d, 0x0a) }, | ||
| ] as const; | ||
| const malformedCases = surfaces.flatMap((surface) => | ||
| tokenTypes.flatMap((tokenType) => | ||
| malformedCharacters.map(({ name, code, character }) => ({ surface, tokenType, name, code, character })), | ||
| ), | ||
| ); | ||
| const validTokens = [ | ||
| { name: 'ordinary bearer token', token: 'safe-access-token-0ac8' }, | ||
| { name: 'horizontal tab', token: ['safe', String.fromCodePoint(0x09), 'access-token'].join('') }, | ||
| { name: 'space', token: 'safe access-token' }, | ||
| { name: 'lowest obs-text', token: ['safe', String.fromCodePoint(0x80), 'token'].join('') }, | ||
| { name: 'highest obs-text', token: ['safe', String.fromCodePoint(0xff), 'token'].join('') }, | ||
| ] as const; | ||
|
|
||
| function oauthResponse(accessToken: string, expiresIn = 3600): Response { | ||
| return Response.json({ | ||
| access_token: accessToken, | ||
| issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', | ||
| token_type: 'Bearer', | ||
| expires_in: expiresIn, | ||
| }); | ||
| } | ||
|
|
||
| function createHarness(accessToken: string, tokenType: TokenType = 'jwt') { | ||
| const subjectToken = vi.fn(async () => 'external-subject-token'); | ||
| const exchange = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => oauthResponse(accessToken)); | ||
| const api = vi.fn(async (_url: RequestInfo, _init?: RequestInit) => | ||
| Response.json({ object: 'list', data: [] }), | ||
| ); | ||
| const fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => | ||
| String(url) === OAUTH_URL ? exchange(url, init) : api(url, init), | ||
| ); | ||
| const config: WorkloadIdentity = { | ||
| identityProviderId: 'safe-identity-provider', | ||
| serviceAccountId: 'safe-service-account', | ||
| provider: { tokenType, getToken: subjectToken }, | ||
| }; | ||
|
|
||
| return { config, fetch, exchange, api, subjectToken }; | ||
| } | ||
|
|
||
| type Harness = ReturnType<typeof createHarness>; | ||
|
|
||
| function createPublicClient(harness: Harness, logger?: ReturnType<typeof createLogger>): OpenAI { | ||
| return new OpenAI({ | ||
| apiKey: null, | ||
| workloadIdentity: harness.config, | ||
| fetch: harness.fetch, | ||
| maxRetries: 0, | ||
| logLevel: logger ? 'debug' : 'off', | ||
| ...(logger ? { logger } : {}), | ||
| }); | ||
| } | ||
|
|
||
| function createLogger() { | ||
| return { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; | ||
| } | ||
|
|
||
| function operationFor(surface: Surface, harness: Harness): () => Promise<unknown> { | ||
| if (surface === 'direct-auth') { | ||
| const auth = new WorkloadIdentityAuth(harness.config, harness.fetch); | ||
| return () => auth.getToken(); | ||
| } | ||
|
|
||
| const client = createPublicClient(harness); | ||
| return () => client.models.list(); | ||
| } | ||
|
|
||
| async function expectPrivateFailure( | ||
| run: () => Promise<unknown>, | ||
| accessToken: string, | ||
| surface: Surface, | ||
| ): Promise<Error> { | ||
| let failure: unknown; | ||
| try { | ||
| await run(); | ||
| } catch (error) { | ||
| failure = error; | ||
| } | ||
|
|
||
| expect(failure).toBeInstanceOf(OpenAIError); | ||
| if (!(failure instanceof OpenAIError)) { | ||
| throw new Error(`Invalid ${surface} OAuth access tokens must preserve the existing SDK error class.`); | ||
| } | ||
|
|
||
| expect(failure.message).toBe(SAFE_ERROR); | ||
| expect((failure as Error & { cause?: unknown }).cause).toBeUndefined(); | ||
|
|
||
| let current: unknown = failure; | ||
| while (current instanceof Error) { | ||
| for (const diagnostic of [current.message, current.stack ?? '']) { | ||
| expect(diagnostic).not.toContain(accessToken); | ||
| expect(diagnostic).not.toContain(ACCESS_SECRET); | ||
| expect(diagnostic).not.toContain(PRIVATE_PATIENT); | ||
| } | ||
| current = (current as Error & { cause?: unknown }).cause; | ||
| } | ||
|
|
||
| return failure; | ||
| } | ||
|
|
||
| async function closeServer(server: Server): Promise<void> { | ||
| if (!server.listening) { | ||
| return; | ||
| } | ||
| const closed = once(server, 'close'); | ||
| server.close(); | ||
| server.closeAllConnections(); | ||
| await closed; | ||
| } | ||
|
|
||
| describe('workload identity OAuth access-token confidentiality and integrity', () => { | ||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| vi.unstubAllEnvs(); | ||
| }); | ||
|
|
||
| test.each(malformedCases)( | ||
| '$surface rejects $tokenType $name U+$code before caching or attaching the bearer credential', | ||
| async ({ surface, tokenType, character }) => { | ||
| const token = [ACCESS_SECRET, character, PRIVATE_PATIENT].join(''); | ||
| const harness = createHarness(token, tokenType); | ||
|
|
||
| await expectPrivateFailure(operationFor(surface, harness), token, surface); | ||
|
|
||
| expect(harness.exchange).toHaveBeenCalledTimes(1); | ||
| expect(harness.subjectToken).toHaveBeenCalledTimes(1); | ||
| expect(harness.api).not.toHaveBeenCalled(); | ||
| expect(harness.exchange.mock.calls[0]?.[1]?.redirect).toBe('manual'); | ||
| }, | ||
| ); | ||
|
|
||
| test.each(surfaces)('never caches malformed access tokens across repeated %s calls', async (surface) => { | ||
| const token = [ACCESS_SECRET, String.fromCodePoint(0x0a), PRIVATE_PATIENT].join(''); | ||
| const harness = createHarness(token); | ||
| const operation = operationFor(surface, harness); | ||
|
|
||
| await expectPrivateFailure(operation, token, surface); | ||
| await expectPrivateFailure(operation, token, surface); | ||
|
|
||
| expect(harness.exchange).toHaveBeenCalledTimes(2); | ||
| expect(harness.subjectToken).toHaveBeenCalledTimes(2); | ||
| expect(harness.api).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test.each(surfaces)('recovers with a valid replacement after an invalid %s exchange', async (surface) => { | ||
| const token = [ACCESS_SECRET, String.fromCodePoint(0x0d), PRIVATE_PATIENT].join(''); | ||
| const harness = createHarness(token); | ||
| harness.exchange | ||
| .mockResolvedValueOnce(oauthResponse(token)) | ||
| .mockResolvedValueOnce(oauthResponse('safe-replacement-token')); | ||
| const operation = operationFor(surface, harness); | ||
|
|
||
| await expectPrivateFailure(operation, token, surface); | ||
| await expect(operation()).resolves.toBeDefined(); | ||
|
|
||
| expect(harness.exchange).toHaveBeenCalledTimes(2); | ||
| expect(harness.api).toHaveBeenCalledTimes(surface === 'public-client' ? 1 : 0); | ||
| if (surface === 'public-client') { | ||
| expect(new Headers(harness.api.mock.calls[0]?.[1]?.headers).get('authorization')).toBe( | ||
| 'Bearer safe-replacement-token', | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| test('shares one failed exchange among concurrent callers and retries on the next call', async () => { | ||
| const token = [ACCESS_SECRET, String.fromCodePoint(0x01), PRIVATE_PATIENT].join(''); | ||
| const harness = createHarness(token); | ||
| harness.exchange | ||
| .mockResolvedValueOnce(oauthResponse(token)) | ||
| .mockResolvedValueOnce(oauthResponse('fresh-safe-token')); | ||
| const auth = new WorkloadIdentityAuth(harness.config, harness.fetch); | ||
|
|
||
| const attempts = await Promise.allSettled(Array.from({ length: 24 }, async () => auth.getToken())); | ||
|
|
||
| expect(attempts.every((attempt) => attempt.status === 'rejected')).toBe(true); | ||
| for (const attempt of attempts) { | ||
| if (attempt.status === 'rejected') { | ||
| expect(attempt.reason).toBeInstanceOf(OpenAIError); | ||
| expect((attempt.reason as Error).message).toBe(SAFE_ERROR); | ||
| } | ||
| } | ||
| expect(harness.exchange).toHaveBeenCalledTimes(1); | ||
| await expect(auth.getToken()).resolves.toBe('fresh-safe-token'); | ||
| expect(harness.exchange).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| test('keeps a valid cached token when a background refresh returns a malformed bearer', async () => { | ||
| const token = [ACCESS_SECRET, String.fromCodePoint(0x7f), PRIVATE_PATIENT].join(''); | ||
| const harness = createHarness(token); | ||
| harness.exchange | ||
| .mockResolvedValueOnce(oauthResponse('still-valid-cached-token', 60)) | ||
| .mockResolvedValueOnce(oauthResponse(token)) | ||
| .mockResolvedValueOnce(oauthResponse('refreshed-safe-token')); | ||
| const auth = new WorkloadIdentityAuth(harness.config, harness.fetch); | ||
|
|
||
| await expect(auth.getToken()).resolves.toBe('still-valid-cached-token'); | ||
| await expect(auth.getToken()).resolves.toBe('still-valid-cached-token'); | ||
| await vi.waitFor(() => expect(harness.exchange).toHaveBeenCalledTimes(2)); | ||
| await vi.waitFor(() => expect(harness.subjectToken).toHaveBeenCalledTimes(2)); | ||
|
|
||
| await vi.waitFor(async () => { | ||
| await expect(auth.getToken()).resolves.toBe('still-valid-cached-token'); | ||
| expect(harness.exchange).toHaveBeenCalledTimes(3); | ||
| }); | ||
| await vi.waitFor(async () => expect(await auth.getToken()).toBe('refreshed-safe-token')); | ||
| }); | ||
|
|
||
| test('retries after invalidating an expired token whose replacement was malformed', async () => { | ||
| const token = [ACCESS_SECRET, String.fromCodePoint(0x1f), PRIVATE_PATIENT].join(''); | ||
| const harness = createHarness(token); | ||
| harness.exchange | ||
| .mockResolvedValueOnce(oauthResponse('expiring-safe-token', 1)) | ||
| .mockResolvedValueOnce(oauthResponse(token)) | ||
| .mockResolvedValueOnce(oauthResponse('replacement-safe-token')); | ||
| const now = Date.now(); | ||
| const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now); | ||
| const auth = new WorkloadIdentityAuth(harness.config, harness.fetch); | ||
|
|
||
| await expect(auth.getToken()).resolves.toBe('expiring-safe-token'); | ||
| dateNow.mockReturnValue(now + 1000); | ||
| await expectPrivateFailure(() => auth.getToken(), token, 'direct-auth'); | ||
| auth.invalidateToken(); | ||
| await expect(auth.getToken()).resolves.toBe('replacement-safe-token'); | ||
| expect(harness.exchange).toHaveBeenCalledTimes(3); | ||
| }); | ||
|
|
||
| test.each(surfaces.flatMap((surface) => validTokens.map(({ name, token }) => ({ surface, name, token }))))( | ||
| '$surface preserves a valid $name credential, cache, and redirect behavior', | ||
| async ({ surface, token }) => { | ||
| const harness = createHarness(token); | ||
| const operation = operationFor(surface, harness); | ||
|
|
||
| await expect(operation()).resolves.toBeDefined(); | ||
| await expect(operation()).resolves.toBeDefined(); | ||
|
|
||
| expect(harness.exchange).toHaveBeenCalledTimes(1); | ||
| expect(harness.subjectToken).toHaveBeenCalledTimes(1); | ||
| expect(harness.exchange.mock.calls[0]?.[1]?.redirect).toBe('manual'); | ||
| if (surface === 'public-client') { | ||
| expect(harness.api).toHaveBeenCalledTimes(2); | ||
| expect(new Headers(harness.api.mock.calls[0]?.[1]?.headers).get('authorization')).toBe( | ||
| ['Bearer ', token].join(''), | ||
| ); | ||
| } else { | ||
| expect(harness.api).not.toHaveBeenCalled(); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| test('preserves the identity of subject-token-provider failures', async () => { | ||
| const original = new OpenAIError('The subject token provider failed safely.'); | ||
| const harness = createHarness('unused-safe-token'); | ||
| harness.subjectToken.mockRejectedValueOnce(original); | ||
| const auth = new WorkloadIdentityAuth(harness.config, harness.fetch); | ||
|
|
||
| await expect(auth.getToken()).rejects.toBe(original); | ||
| expect(harness.exchange).not.toHaveBeenCalled(); | ||
| expect(harness.api).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test.each([400, 401, 403] as const)( | ||
| 'preserves OAuthError status %i and safe diagnostics', | ||
| async (status) => { | ||
| const harness = createHarness('unused-safe-token'); | ||
| harness.exchange.mockResolvedValueOnce( | ||
| Response.json({ error: 'invalid_grant', error_description: 'Identity was rejected.' }, { status }), | ||
| ); | ||
| const auth = new WorkloadIdentityAuth(harness.config, harness.fetch); | ||
|
|
||
| await expect(auth.getToken()).rejects.toBeInstanceOf(OAuthError); | ||
| expect(harness.api).not.toHaveBeenCalled(); | ||
| }, | ||
| ); | ||
|
|
||
| test('keeps every debug-log sink free of OAuth bearer material', async () => { | ||
| const token = [ACCESS_SECRET, String.fromCodePoint(0x0a), PRIVATE_PATIENT].join(''); | ||
| const harness = createHarness(token); | ||
| const logger = createLogger(); | ||
| const client = createPublicClient(harness, logger); | ||
|
|
||
| await expectPrivateFailure(() => client.models.list(), token, 'public-client'); | ||
|
|
||
| for (const sink of [logger.debug, logger.info, logger.warn, logger.error]) { | ||
| for (const argumentsList of sink.mock.calls) { | ||
| const diagnostic = JSON.stringify(argumentsList); | ||
| expect(diagnostic).not.toContain(ACCESS_SECRET); | ||
| expect(diagnostic).not.toContain(PRIVATE_PATIENT); | ||
| } | ||
| } | ||
| expect(harness.api).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test.each([ | ||
| { name: 'line-feed', character: String.fromCodePoint(0x0a) }, | ||
| { name: 'accepted C0 byte', character: String.fromCodePoint(0x01) }, | ||
| ])('blocks a $name access token at the real public OAuth and HTTP boundary', async ({ character }) => { | ||
| const token = [ACCESS_SECRET, character, PRIVATE_PATIENT].join(''); | ||
| let exchangeRequests = 0; | ||
| let apiRequests = 0; | ||
| const server = createServer((request, response) => { | ||
| if (request.url === '/oauth/token') { | ||
| exchangeRequests += 1; | ||
| response.writeHead(200, { 'content-type': 'application/json' }); | ||
| response.end(JSON.stringify({ access_token: token, expires_in: 3600 })); | ||
| } else { | ||
| apiRequests += 1; | ||
| response.writeHead(200, { 'content-type': 'application/json' }); | ||
| response.end(JSON.stringify({ object: 'list', data: [] })); | ||
| } | ||
| }); | ||
| const listening = once(server, 'listening'); | ||
| server.listen(0, '127.0.0.1'); | ||
| await listening; | ||
| const address = server.address(); | ||
| if (!address || typeof address === 'string') { | ||
| throw new Error('Expected an authenticated loopback TCP address.'); | ||
| } | ||
| const baseURL = ['http://127.0.0.1:', String(address.port)].join(''); | ||
| const nativeFetch = globalThis.fetch; | ||
| const transport = vi.fn((url: RequestInfo, init?: RequestInit) => { | ||
| const target = String(url) === OAUTH_URL ? [baseURL, '/oauth/token'].join('') : url; | ||
| return nativeFetch(target, init); | ||
| }); | ||
| const client = new OpenAI({ | ||
| apiKey: null, | ||
| baseURL: [baseURL, '/v1'].join(''), | ||
| maxRetries: 0, | ||
| fetch: transport, | ||
| workloadIdentity: { | ||
| identityProviderId: 'safe-identity-provider', | ||
| serviceAccountId: 'safe-service-account', | ||
| provider: { tokenType: 'jwt', getToken: async () => 'subject-token' }, | ||
| }, | ||
| }); | ||
|
|
||
| try { | ||
| await expectPrivateFailure(() => client.models.list(), token, 'public-client'); | ||
| expect(exchangeRequests).toBe(1); | ||
| expect(apiRequests).toBe(0); | ||
| expect(transport).toHaveBeenCalledTimes(1); | ||
| } finally { | ||
| await closeServer(server); | ||
| } | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.