From e41f89cde57858b76df61a7ba6316f5ac0a4498d Mon Sep 17 00:00:00 2001 From: Tobias Laundal Date: Sat, 28 Jan 2023 15:56:24 +0000 Subject: [PATCH] feat: expose client principal through platform (#107) Makes the client principal, as passed from SWA in the `x-ms-client-principal` header, available in the SvelteKit applications through the `event.platform` field in hooks and server routes. --- README.md | 16 +++++++++ demo/package-lock.json | 2 +- demo/src/app.d.ts | 2 ++ files/entry.js | 10 +++++- files/headers.js | 20 +++++++++++ index.d.ts | 26 ++++++++++++-- test/headers.test.js | 27 ++++++++++++++- types/swa.d.ts | 78 +++++++++++++++++++++++++++++++++++++++++- 8 files changed, 175 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e19b0c0..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 +/// +``` + :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 @@ -165,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 @@ +/// + // 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; @@ -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/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(); + }); +}); 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; +}