Repository: geoffrich/svelte-adapter-azure-swa (js)

Issue:
Add clientPrincipal to platform
Ref: https://github.com/geoffrich/svelte-adapter-azure-swa/issues/102#issuecomment-1399579017

The adapter should parse the authentication information from the [x-ms-client-principal header](https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=javascript#api-functions) and add it to RequestEvent's [platform](https://kit.svelte.dev/docs/adapters#platform-specific-context).

See the comments in #102 - this header is not always set, and the documentation should reflect that.

Once this is complete, we'll want to take a followup issue to make this information available in local dev. There's a SvelteKit issue somewhere about adapters and local dev, but for now we could provide a function to be used in the `handle` hook that would accomplish the same thing during dev.

Pull request:
feat: expose client principal through platform
Please see the commit messages for details.

Not sure how to / if it's possible to automatically have the Platform types
imported in the apps that use this adapter. Workaround is to add this to
the top of `src/app.d.ts`:

    /// <reference types="svelte-adapter-azure-swa" />

Other adapters seem to put the types in `ambient.d.ts`, but this doesn't
seem to fix the problem.

Fixes #103.

Patch:
diff --git a/README.md b/README.md
index 29b8b99..e776e26 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,12 @@ export default {
 };
 ```
 
+And, if you use TypeScript, add this to the top of your `src/app.d.ts`:
+
+```ts
+/// <reference types="svelte-adapter-azure-swa" />
+```
+
 :warning: **IMPORTANT**: you also need to configure your build so that your SvelteKit site deploys properly. Failing to do so will prevent the project from building and deploying. See the next section for instructions.
 
 ## Azure configuration
@@ -50,7 +56,8 @@ To run the CLI, install `@azure/static-web-apps-cli` and the [Azure Functions Co
 	"configurations": {
 		"app": {
 			"outputLocation": "./build/static",
-			"apiLocation": "./build/server"
+			"apiLocation": "./build/server",
+			"host": "127.0.0.1"
 		}
 	}
 }
@@ -164,3 +171,13 @@ export default {
 	}
 };
 ```
+
+## Platform-specific context
+
+SWA provides some information to the backend functions that this adapter makes available as [platform-specific context](https://kit.svelte.dev/docs/adapters#platform-specific-context). This is available in hooks and server routes through the `platform` property on the `RequestEvent`.
+
+To get typings for the `platform` property, reference this adapter in your `src/app.d.ts` as described in the [usage section](#usage).
+
+### `clientPrincipal`
+
+The client principal as passed in a header from SWA to the render function is available at `platform.clientPrincipal` in the same form it is provided by SWA. See the [official SWA documentation](https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=javascript#api-functions) or [the types](index.d.ts) for further details.
diff --git a/demo/package-lock.json b/demo/package-lock.json
index f14bb33..a5ea1c4 100644
--- a/demo/package-lock.json
+++ b/demo/package-lock.json
@@ -22,7 +22,7 @@
 			}
 		},
 		"..": {
-			"version": "0.12.0",
+			"version": "0.13.0",
 			"dev": true,
 			"license": "MIT",
 			"dependencies": {
diff --git a/demo/src/app.d.ts b/demo/src/app.d.ts
index f59b884..9897ab7 100644
--- a/demo/src/app.d.ts
+++ b/demo/src/app.d.ts
@@ -1,3 +1,5 @@
+/// <reference types="svelte-adapter-azure-swa" />
+
 // See https://kit.svelte.dev/docs/types#app
 // for information about these interfaces
 declare global {
diff --git a/files/entry.js b/files/entry.js
index d95638b..1cd81aa 100644
--- a/files/entry.js
+++ b/files/entry.js
@@ -1,7 +1,11 @@
 import { installPolyfills } from '@sveltejs/kit/node/polyfills';
 import { Server } from 'SERVER';
 import { manifest } from 'MANIFEST';
-import { getClientIPFromHeaders, splitCookiesFromHeaders } from './headers';
+import {
+	getClientIPFromHeaders,
+	getClientPrincipalFromHeaders,
+	splitCookiesFromHeaders
+} from './headers';
 
 // replaced at build time
 // @ts-expect-error
@@ -29,10 +33,14 @@ export async function index(context) {
 	}
 
 	const ipAddress = getClientIPFromHeaders(request.headers);
+	const clientPrincipal = getClientPrincipalFromHeaders(request.headers);
 
 	const rendered = await server.respond(request, {
 		getClientAddress() {
 			return ipAddress;
+		},
+		platform: {
+			clientPrincipal
 		}
 	});
 
diff --git a/files/headers.js b/files/headers.js
index 862464a..df2a7f9 100644
--- a/files/headers.js
+++ b/files/headers.js
@@ -40,3 +40,23 @@ export function getClientIPFromHeaders(headers) {
 
 	return ipAddress;
 }
+
+/**
+ * Gets the client principal from `x-ms-client-principal` header.
+ * @param {Headers} headers
+ * @returns {import('../types/swa').ClientPrincipal | undefined} The client principal
+ */
+export function getClientPrincipalFromHeaders(headers) {
+	// Code adapted from the official SWA documentation
+	// https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=javascript#api-functions
+	const header = headers.get('x-ms-client-principal');
+	if (!header) {
+		return undefined;
+	}
+
+	const encoded = Buffer.from(header, 'base64');
+	const decoded = encoded.toString('ascii');
+	const clientPrincipal = JSON.parse(decoded);
+
+	return clientPrincipal;
+}
diff --git a/index.d.ts b/index.d.ts
index 022a116..90908bf 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,8 +1,10 @@
 import { Adapter } from '@sveltejs/kit';
-import { CustomStaticWebAppConfig } from './types/swa';
+import { ClientPrincipal, CustomStaticWebAppConfig } from './types/swa';
 import esbuild from 'esbuild';
 
-type Options = {
+export * from './types/swa';
+
+export type Options = {
 	debug?: boolean;
 	customStaticWebAppConfig?: CustomStaticWebAppConfig;
 	esbuildOptions?: Pick<esbuild.BuildOptions, 'external'>;
@@ -10,3 +12,23 @@ type Options = {
 };
 
 export default function plugin(options?: Options): Adapter;
+
+declare global {
+	namespace App {
+		export interface Platform {
+			/**
+			 * Client Principal as passed from Azure
+			 *
+			 * @remarks
+			 *
+			 * Due to a possible in bug in SWA, the client principal is only passed
+			 * to the render function on routes specifically designated as
+			 * protected. Protected in this case means that the `allowedRoles`
+			 * field is populated and does not contain the `anonymous` role.
+			 *
+			 * @see The {@link https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=javascript#api-functions SWA documentation}
+			 */
+			clientPrincipal?: ClientPrincipal;
+		}
+	}
+}
diff --git a/types/swa.d.ts b/types/swa.d.ts
index a446ba7..718f3c0 100644
--- a/types/swa.d.ts
+++ b/types/swa.d.ts
@@ -1,4 +1,4 @@
-// types adapted from https://docs.microsoft.com/en-us/azure/static-web-apps/configuration
+// types and documentation adapted from https://docs.microsoft.com/en-us/azure/static-web-apps/configuration
 export interface StaticWebAppConfig {
 	routes?: Route[];
 	navigationFallback?: NavigationFallback;
@@ -47,3 +47,79 @@ export type OverridableResponseCodes = '400' | '401' | '403' | '404';
 export interface Platform {
 	apiRuntime: string;
 }
+
+/**
+ * Client principal as presented to the render functions of a SWA.
+ *
+ * @see The official {@link https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=javascript#client-principal-data documentation}
+ * this was adapted from.
+ */
+export interface ClientPrincipal {
+	/**
+	 * The name of the identity provider.
+	 *
+	 * @remarks
+	 *
+	 * Currently, the default providers use the following values here:
+	 * | Provider | value   |
+	 * |----------|---------|
+	 * | Azure AD | aad     |
+	 * | GitHub   | github  |
+	 * | Twitter  | twitter |
+	 */
+	identityProvider: string;
+
+	/**
+	 * An Azure Static Web Apps-specific unique identifier for the user.
+	 *
+	 *  - The value is unique on a per-app basis. For instance, the same user
+	 *    returns a different userId value on a different Static Web Apps
+	 *    resource.
+	 *  - The value persists for the lifetime of a user. If you delete and add
+	 *    the same user back to the app, a new userId is generated.
+	 */
+	userId: string;
+
+	/**
+	 * Username or email address of the user. Some providers return the user's
+	 * email address, while others send the user handle.
+	 *
+	 * @remarks
+	 *
+	 * Currently, the default providers use the following types of values here:
+	 * | Provider | value         |
+	 * |----------|---------------|
+	 * | Azure AD | email address |
+	 * | GitHub   | username      |
+	 * | Twitter  | username      |
+	 */
+	userDetails: string;
+
+	/**
+	 * An array of the user's assigned roles.
+	 *
+	 * All users (both authenticated and not) will always have the role
+	 * `anonymous` and authenticated users will always have the role
+	 * `authenticated`. Additional custom roles might be present as well.
+	 */
+	userRoles: string[];
+}
+
+export interface ClientPrincipalWithClaims extends ClientPrincipal {
+	claims: ClientPrincipalClaim[];
+}
+
+export interface ClientPrincipalClaim {
+	/**
+	 * The type of claim.
+	 *
+	 * Usually a standardized type like `name` or `ver`, or a schema url like
+	 * `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`.
+	 */
+	typ: string;
+
+	/**
+	 * The value of the claim.
+	 */
+	val: string;
+}


Test patch:
diff --git a/test/headers.test.js b/test/headers.test.js
index c6efefc..e63c5f0 100644
--- a/test/headers.test.js
+++ b/test/headers.test.js
@@ -1,6 +1,10 @@
 import { installPolyfills } from '@sveltejs/kit/node/polyfills';
 import { expect, describe, test } from 'vitest';
-import { splitCookiesFromHeaders, getClientIPFromHeaders } from '../files/headers';
+import {
+	splitCookiesFromHeaders,
+	getClientIPFromHeaders,
+	getClientPrincipalFromHeaders
+} from '../files/headers';
 
 installPolyfills();
 
@@ -94,3 +98,24 @@ describe('client ip address detection', () => {
 		expect(ipAddress).toBe('8.23.191.142');
 	});
 });
+
+describe('client principal parsing', () => {
+	test('parses client principal correctly', () => {
+		const original = {
+			identityProvider: 'aad',
+			userId: '1234',
+			userDetails: 'user@example.net',
+			userRoles: ['authenticated']
+		};
+
+		const headers = new Headers({
+			'x-ms-client-principal': Buffer.from(JSON.stringify(original)).toString('base64')
+		});
+
+		expect(getClientPrincipalFromHeaders(headers)).toStrictEqual(original);
+	});
+
+	test('returns undefined when there is no client principal', () => {
+		expect(getClientPrincipalFromHeaders(new Headers())).toBeUndefined();
+	});
+});