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 packages/libs/sdk-mixins/src/mixins/configMixin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export type FlowConfig = {

export type ProjectConfiguration = {
componentsVersion: string;
componentsVersionSri?: string;
cssTemplate: Style;
flows: {
[key: string]: FlowConfig; // dynamic key names for flows
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ export const descopeUiMixin = createSingletonMixin(
return componentsVersion;
}

async #getComponentsVersionSri() {
const config = await this.config;
const componentsVersionSri =
config?.projectConfig?.componentsVersionSri;

if (componentsVersionSri) {
this.logger.debug('SRI hash available for components');
}

return componentsVersionSri;
}

#descopeUi: Promise<any>;

get descopeUi() {
Expand Down Expand Up @@ -90,11 +102,15 @@ export const descopeUiMixin = createSingletonMixin(
}

try {
const componentsVersion = await this.#getComponentsVersion();
const componentsVersionSri = await this.#getComponentsVersionSri();

await this.injectNpmLib(
WEB_COMPONENTS_UI_LIB_NAME,
await this.#getComponentsVersion(),
componentsVersion,
JS_FILE_PATH,
[LOCAL_STORAGE_OVERRIDE],
componentsVersionSri,
);
this.logger.debug('DescopeUI was loaded');
return globalThis.DescopeUI;
Expand Down
70 changes: 49 additions & 21 deletions packages/libs/sdk-mixins/src/mixins/injectNpmLibMixin/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,25 @@ const hashUrl = (url: URL) => {
return `${Math.abs(hash).toString()}`;
};

const setupScript = (id: string) => {
const setupScript = (id: string, integrity?: string) => {
const scriptEle = document.createElement('script');
scriptEle.id = id;

return scriptEle;
};
if (integrity) {
Copy link
Member

Choose a reason for hiding this comment

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

Why not to set enabled by default?

scriptEle.integrity = integrity;
scriptEle.crossOrigin = 'anonymous';
}

type ScriptData = {
id: string;
url: URL;
if ((window as any).DESCOPE_NONCE) {
Copy link
Member

Choose a reason for hiding this comment

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

Who sets this DESCOPE_NONCE? is it documented?

scriptEle.setAttribute('nonce', (window as any).DESCOPE_NONCE);
}

return scriptEle;
};

const injectScript = (scriptId: string, url: URL) => {
const injectScript = (scriptId: string, url: URL, integrity?: string) => {
return new Promise((res, rej) => {
const scriptEle = setupScript(scriptId);
const scriptEle = setupScript(scriptId, integrity);

scriptEle.onerror = (error) => {
scriptEle.setAttribute('status', 'error');
Expand All @@ -54,7 +58,21 @@ const injectScript = (scriptId: string, url: URL) => {
});
};

const handleExistingScript = (existingScript: HTMLScriptElement) => {
const handleExistingScript = (
existingScript: HTMLScriptElement,
expectedIntegrity?: string,
) => {
if (expectedIntegrity) {
const actualIntegrity = existingScript.integrity;
if (actualIntegrity !== expectedIntegrity) {
return Promise.reject(
new Error(
`Integrity mismatch: expected ${expectedIntegrity}, found ${actualIntegrity}`,
),
);
}
}

if (isScriptLoaded(existingScript)) {
return Promise.resolve(existingScript);
}
Expand All @@ -74,23 +92,29 @@ const handleExistingScript = (existingScript: HTMLScriptElement) => {
});
};

export type ScriptData = {
id: string;
url: URL;
integrity?: string;
};

export const injectScriptWithFallbacks = async (
scriptsData: ScriptData[],
onError: (scriptData: ScriptData, existingScript: boolean) => void,
) => {
for (const scriptData of scriptsData) {
const { id, url } = scriptData;
const { id, url, integrity } = scriptData;
const existingScript = getExistingScript(id);
if (existingScript) {
try {
await handleExistingScript(existingScript);
await handleExistingScript(existingScript, integrity);
return scriptData;
} catch (e) {
onError(scriptData, true);
}
} else {
try {
await injectScript(id, url);
await injectScript(id, url, integrity);
return scriptData;
} catch (e) {
onError(scriptData, false);
Expand All @@ -105,6 +129,7 @@ export const generateLibUrls = (
libName: string,
version: string,
path = '',
integrity?: string,
) =>
baseUrls.reduce((prev, curr) => {
const baseUrl = curr;
Expand All @@ -125,13 +150,16 @@ export const generateLibUrls = (
url.pathname = `/npm/${libName}@${version}/${path}`;
}

return [
...prev,
{
url: url,
id: `npmlib-${libName
.replaceAll('@', '')
.replaceAll('/', '_')}-${hashUrl(url)}`,
},
];
const scriptData: ScriptData = {
url: url,
id: `npmlib-${libName.replaceAll('@', '').replaceAll('/', '_')}-${hashUrl(
url,
)}`,
};

if (integrity) {
scriptData.integrity = integrity;
}

return [...prev, scriptData];
}, []);
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,20 @@ export const injectNpmLibMixin = createSingletonMixin(
version: string,
filePath = '',
overrides: string[] = [],
integrity?: string,
) {
this.logger.debug(
`Injecting npm lib: "${libName}" with version: "${version}"`,
`Injecting npm lib: "${libName}" with version: "${version}"${
integrity ? ' with SRI integrity check' : ''
}`,
);
return injectScriptWithFallbacks(
generateLibUrls(
[...overrides, this.baseCdnUrl, ...BASE_URLS],
libName,
version,
filePath,
integrity,
),
(scriptData, existingScript) => {
if (existingScript) {
Expand Down
107 changes: 107 additions & 0 deletions packages/libs/sdk-mixins/test/injectNpmLibMixin.sri.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
generateLibUrls,
type ScriptData,
} from '../src/mixins/injectNpmLibMixin/helpers';

describe('injectNpmLibMixin - SRI support', () => {
const baseUrls = ['https://cdn1.example.com', 'https://cdn2.example.com'];
const libName = '@descope/web-components-ui';
const version = '1.0.0';
const path = 'dist/umd/index.js';

describe('generateLibUrls with SRI', () => {
it('should include integrity in generated script data when provided', () => {
const sriHash =
'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';

const result = generateLibUrls(baseUrls, libName, version, path, sriHash);

expect(result).toHaveLength(2);
result.forEach((scriptData) => {
expect(scriptData.integrity).toBe(sriHash);
expect(scriptData.url).toBeDefined();
expect(scriptData.id).toBeDefined();
});
});

it('should not include integrity when not provided', () => {
const result = generateLibUrls(baseUrls, libName, version, path);

expect(result).toHaveLength(2);
result.forEach((scriptData) => {
expect(scriptData.integrity).toBeUndefined();
expect(scriptData.url).toBeDefined();
expect(scriptData.id).toBeDefined();
});
});

it('should not include integrity when empty string is provided', () => {
const result = generateLibUrls(baseUrls, libName, version, path, '');

expect(result).toHaveLength(2);
result.forEach((scriptData) => {
expect(scriptData.integrity).toBeUndefined();
expect(scriptData.url).toBeDefined();
expect(scriptData.id).toBeDefined();
});
});

it('should apply same integrity to all CDN URLs', () => {
const sriHash = 'sha384-test123';
const multipleCdns = [
'https://descopecdn.com',
'https://static.descope.com',
'https://cdn.jsdelivr.net',
];

const result = generateLibUrls(
multipleCdns,
libName,
version,
path,
sriHash,
);

expect(result).toHaveLength(3);
result.forEach((scriptData) => {
expect(scriptData.integrity).toBe(sriHash);
});
});

it('should generate correct URLs with integrity', () => {
const sriHash = 'sha256-abc123';
const result = generateLibUrls(
[baseUrls[0]],
libName,
version,
path,
sriHash,
);

expect(result).toHaveLength(1);
expect(result[0].url.toString()).toContain(libName);
expect(result[0].url.toString()).toContain(version);
expect(result[0].url.toString()).toContain(path);
expect(result[0].integrity).toBe(sriHash);
});
});

describe('ScriptData type', () => {
it('should support optional integrity field', () => {
const scriptDataWithIntegrity = {
id: 'test-script',
url: new URL('https://example.com/script.js'),
integrity: 'sha384-test',
};

const scriptDataWithoutIntegrity: ScriptData = {
id: 'test-script',
url: new URL('https://example.com/script.js'),
};

// TypeScript should accept both forms
expect(scriptDataWithIntegrity.integrity).toBe('sha384-test');
expect(scriptDataWithoutIntegrity.integrity).toBeUndefined();
});
});
});
44 changes: 43 additions & 1 deletion packages/sdks/web-component/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,48 @@ DESCOPE_LOCALE=<locale>

NOTE: This package is a part of a monorepo. so if you make changes in a dependency, you will have to rerun `npm run start` / `pnpm run start-web-sample` (this is a temporary solution until we improve the process to fit to monorepo).

## Security Features

### Subresource Integrity (SRI)

The web component automatically applies [Subresource Integrity (SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) checks to the web-components-ui loader when the SRI hash is available in the project configuration. This provides cryptographic verification that the loaded script has not been tampered with.

**How it works:**

1. The Descope orchestration service generates SRI hashes for each version of the web-components-ui library
2. The hash is included in the `config.json` as `componentsVersionSri`
3. The web component automatically adds `integrity` and `crossorigin` attributes to the script tag
4. The browser verifies the script integrity before executing it

**Example generated script tag:**

```html
<script src="https://descopecdn.com/npm/@descope/web-components-ui@1.0.0/dist/umd/index.js" integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K..." crossorigin="anonymous"></script>
```

**Content Security Policy (CSP) compatibility:**

SRI works seamlessly with CSP. For strict CSP configurations, combine SRI with the `nonce` attribute:

```html
<descope-wc project-id="myProjectId" flow-id="sign-up-or-in" nonce="random-nonce-value"></descope-wc>
```

**CSP header example:**

```
Content-Security-Policy:
script-src 'self'
https://descopecdn.com
https://static.descope.com
https://cdn.jsdelivr.net
'nonce-random-nonce-value';
```

**Backward compatibility:**

If the SRI hash is not available in the configuration (older projects), the component will load normally without integrity checks. No breaking changes required.

## Optional Attributes

| Attribute | Available options | Default value |
Expand All @@ -84,7 +126,7 @@ NOTE: This package is a part of a monorepo. so if you make changes in a dependen
| store-last-authenticated-user | **"true"** - Stores last-authenticated user details in local storage when flow is completed</br>**"false"** - Do not store last-auth user details. Disabling this flag may cause last-authenticated user features to not function properly | **"true"** |
| keep-last-authenticated-user-after-logout | **"true"** - Do not clear the last authenticated user details from the browser storage after logout</br>**"false"** - Clear the last authenticated user details from the browser storage after logout | **"false"** |
| style-id | **"String"** - Set a specific style to load rather then the default style | **""** |
| nonce | **"String"** - Set a CSP nonce that will be used for style and script tags | **""** |
| nonce | **"String"** - Set a CSP nonce that will be used for style and script tags. Works with SRI for enhanced security | **""** |
| popup-origin | **"String"** - Sets the expected origin for OAuth popup communication when redirect URL is on different origin than the main application. Required for cross-origin OAuth popup flows | **""** |
| dismiss-screen-error-on-input | **"true"** - Clear screen error message on user input </br> **"false"** - Do not clear screen error message on user input | **"false"** |
| outbound-app-id | **"String"** - Outbound application ID to use for connecting to external services | **""** |
Expand Down
2 changes: 1 addition & 1 deletion packages/sdks/web-component/src/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

<meta
http-equiv="Content-Security-Policy"
content="connect-src 'self' static.descope.com api.descope.com;
content="connect-src 'self' static.descope.com api.descope.com descopecdn.com;
style-src fonts.googleapis.com 'nonce-rAnd0m';
img-src static.descope.com content.app.descope.com imgs.descope.com data:;
font-src fonts.gstatic.com;
Expand Down
Loading