Skip to content
Merged
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
1 change: 1 addition & 0 deletions core/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function build({
if (platform === 'browser' && bundle) {
buildOptions.alias = {
'node:async_hooks': './src/utils/async_hooks_shim.ts',
'node:crypto': './src/utils/crypto_shim.ts',
};
}

Expand Down
23 changes: 23 additions & 0 deletions core/src/utils/crypto_shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Browser stand-in for the `node:crypto` builtin, wired up by the alias in
* `build.js` so that the Node fallback in `env_aware_utils.ts` does not pull a
* Node builtin into the web bundle.
*
* `randomUUID` is reached here only after both `globalThis.crypto` branches in
* `env_aware_utils.ts` have been ruled out. In a browser that means the Web
* Crypto API is genuinely absent, so there is no secure source left to fall
* back to and the only correct move is to fail rather than degrade.
*/
export function randomUUID(): string {
throw new Error(
'randomUUID: no cryptographically secure source of randomness is ' +
'available. Neither crypto.randomUUID() nor crypto.getRandomValues() is ' +
'present in this environment.',
);
}
18 changes: 13 additions & 5 deletions core/src/utils/env_aware_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {randomUUID as nodeRandomUUID} from 'node:crypto';

/**
* Returns true if the environment is a browser.
*/
Expand All @@ -19,6 +21,16 @@ export function isBrowser() {
* `crypto.getRandomValues()` carries no such restriction, so it is used as the
* fallback rather than `Math.random()`.
*
* In Node the `globalThis.crypto` global was added in v17.4.0 and stayed behind
* `--experimental-global-webcrypto` until v19.0.0, so neither of those branches
* matches on a default Node 18 or earlier. `node:crypto` carries no such gate —
* its `randomUUID` has existed since v14.17.0 — so it is the last resort. In
* the bundled web build the import is aliased to `crypto_shim.ts`, which
* throws, because a browser without the Web Crypto API has no secure source
* left. The non-bundle `dist/web` output that `package.json#browser` points
* at keeps the import verbatim, as it already does for `node:async_hooks`
* and `node:path`.
*
* Some callers use this value to make security decisions — the OAuth2 `state`
* parameter in `AuthHandler` and the session identifiers minted by the session
* services — so this function must not silently degrade to a non-cryptographic
Expand Down Expand Up @@ -48,11 +60,7 @@ export function randomUUID(): string {
.join('-');
}

throw new Error(
'randomUUID: no cryptographically secure source of randomness is ' +
'available. Neither crypto.randomUUID() nor crypto.getRandomValues() is ' +
'present in this environment.',
);
return nodeRandomUUID();
}

/**
Expand Down
22 changes: 20 additions & 2 deletions core/test/utils/env_aware_utils_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import {afterEach, describe, expect, it} from 'vitest';
import {randomUUID as shimRandomUUID} from '../../src/utils/crypto_shim.js';
import {getBooleanEnvVar, randomUUID} from '../../src/utils/env_aware_utils.js';

describe('env_aware_utils', () => {
Expand Down Expand Up @@ -109,10 +110,27 @@ describe('env_aware_utils', () => {
expect(randomUUID()).toBe('abababab-abab-4bab-abab-abababababab');
});

it('throws instead of degrading when no secure source exists', () => {
// globalThis.crypto was added in Node v17.4.0 and stayed behind
// --experimental-global-webcrypto until v19.0.0, so on a default Node 18 or
// earlier neither globalThis branch matches.
it('falls back to node:crypto when globalThis.crypto is absent', () => {
setCrypto(undefined);

expect(() => randomUUID()).toThrow(
expect(randomUUID()).toMatch(UUID_V4);
});

it('does not repeat itself across calls without globalThis.crypto', () => {
setCrypto(undefined);

const ids = new Set(Array.from({length: 1000}, () => randomUUID()));

expect(ids.size).toBe(1000);
});

// The web build aliases node:crypto to this shim, so it stands in for the
// Node fallback in a browser that has no Web Crypto API at all.
it('throws instead of degrading in the browser shim', () => {
expect(() => shimRandomUUID()).toThrow(
/no cryptographically secure source of randomness/,
);
});
Expand Down
Loading