Skip to content

Commit

Permalink
feat: expose client principal through platform (#107)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
tlaundal authored Jan 28, 2023
1 parent 6d48963 commit e41f89c
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 6 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion demo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions demo/src/app.d.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion files/entry.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
});

Expand Down
20 changes: 20 additions & 0 deletions files/headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
26 changes: 24 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
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'>;
apiDir?: string;
};

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;
}
}
}
27 changes: 26 additions & 1 deletion test/headers.test.js
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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();
});
});
78 changes: 77 additions & 1 deletion types/swa.d.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}

0 comments on commit e41f89c

Please sign in to comment.