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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 24 additions & 3 deletions src/frontend/src/components/ThemeImage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ export interface Props {

import { Image, getImage } from 'astro:assets';
import { Zoom } from 'starlight-image-zoom/components';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { normalize } from 'node:path';

async function versionDevelopmentImage(image: ImageMetadata): Promise<ImageMetadata> {
if (!import.meta.env.DEV || !image.src.startsWith('/@fs/')) return image;

const source = new URL(image.src, Astro.url);
const filePath = decodeURIComponent(source.pathname.slice('/@fs/'.length));
const path = normalize(/^[a-zA-Z]:/.test(filePath) ? filePath : `/${filePath}`);
const revision = createHash('sha256')
.update(await readFile(path))
.digest('hex')
.slice(0, 16);
source.searchParams.set('v', revision);
return { ...image, src: `${source.pathname}${source.search}` };
}

const {
light,
Expand All @@ -30,10 +47,14 @@ const imageWidth = width ?? dark.width;
const imageHeight = height ?? dark.height;
const imageClass = [classOverride, 'theme-image'].filter(Boolean).join(' ');
const imageOptions = { width: imageWidth, height: imageHeight, fit: 'contain' as const };
const optimizedLight = await getImage({ src: light, ...imageOptions });
const optimizedDark = await getImage({ src: dark, ...imageOptions });
const [lightSource, darkSource] = await Promise.all([
versionDevelopmentImage(light),
versionDevelopmentImage(dark),
]);
const optimizedLight = await getImage({ src: lightSource, ...imageOptions });
const optimizedDark = await getImage({ src: darkSource, ...imageOptions });
const themedImageProps = {
src: dark,
src: darkSource,
alt,
'data-light': optimizedLight.src,
'data-dark': optimizedDark.src,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ import { Aside, Badge, Tabs, TabItem } from '@astrojs/starlight/components';
import ContainerImages from '@components/ContainerImages.astro';
import InstallPackage from '@components/InstallPackage.astro';
import InstallDotNetPackage from '@components/InstallDotNetPackage.astro';
import ThemeImage from '@components/ThemeImage.astro';
import { Image } from 'astro:assets';
import keycloakIcon from '@assets/icons/keycloak-icon.svg';
import ApiReference from '@components/ApiReference.astro';
import authFlowDiagram from '@assets/integrations/security/keycloak/auth-flow-diagram.png';
import authFlowDiagramLight from '@assets/integrations/security/keycloak/auth-flow-diagram-light.png';
import simpleAuthFlowDiagram from '@assets/integrations/security/keycloak/simple-auth-flow-diagram.png';
import simpleAuthFlowDiagramLight from '@assets/integrations/security/keycloak/simple-auth-flow-diagram-light.png';

<Badge text="🧪 Preview" variant="note" size="large" />

Expand Down Expand Up @@ -533,6 +538,24 @@ builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
});
```

### Authentication flows

When the backend also serves the API, the browser signs in through Keycloak and uses an authentication cookie for subsequent requests to the backend:

<ThemeImage
dark={simpleAuthFlowDiagram}
light={simpleAuthFlowDiagramLight}
alt="Authentication sequence with a combined backend and API: the browser is redirected to Keycloak to sign in, receives an authentication cookie from the backend, and uses that cookie to request API data."
/>

When the backend calls a separate API, it also obtains an access token from Keycloak and forwards that token to the API. The API validates the JWT bearer token before returning data:

<ThemeImage
dark={authFlowDiagram}
light={authFlowDiagramLight}
alt="Authentication sequence with a separate API: the browser signs in through Keycloak, the backend obtains an access token and sets an authentication cookie, and the backend forwards the JWT bearer token to the API for validation."
/>

## Production considerations

When deploying Keycloak authentication to production environments, there are several important security considerations:
Expand Down
34 changes: 34 additions & 0 deletions src/frontend/tests/unit/custom-components.vitest.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';

import heroImage from '@assets/aspire-hero.png';
Expand Down Expand Up @@ -1130,6 +1133,37 @@ describe('custom Astro component render coverage', () => {
expect(html).not.toContain('fit="contain"');
});

it('ThemeImage changes development URLs only when image contents change', async () => {
const directory = await mkdtemp(join(tmpdir(), 'theme-image-'));
const path = join(directory, 'diagram.png');
const contents = await readFile(new URL('../../src/assets/aspire-hero.png', import.meta.url));
const image = { ...heroImage, src: `/@fs/${path.replaceAll('\\', '/')}` };
const render = async () =>
normalizeHtml(
await renderComponent(ThemeImage, {
props: { light: image, dark: image, alt: 'Versioned diagram', zoomable: false },
})
);
const sources = (html: string) => [
html.match(/data-light="([^"]+)"/)?.[1],
html.match(/data-dark="([^"]+)"/)?.[1],
];

try {
await writeFile(path, contents);
const first = sources(await render());
expect(first.every(Boolean)).toBe(true);
expect(sources(await render())).toEqual(first);

await writeFile(path, Buffer.concat([contents, Buffer.from('updated')]));
const updated = sources(await render());
expect(updated[0]).not.toEqual(first[0]);
expect(updated[1]).not.toEqual(first[1]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});

it('LoopingImage defers themed requests until the page theme is known', async () => {
const html = normalizeHtml(
await renderComponent(LoopingImage, {
Expand Down
Loading