Skip to content
This repository has been archived by the owner on Jun 13, 2022. It is now read-only.

feat: Breaking changes to SmartAuthProvider #170

Merged
merged 1 commit into from
Dec 15, 2021
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 src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
SmartAuthRedirectQuerystring,
SmartAuthUrlQuerystring,
getAccessTokenFromClientCredentialFlow,
GrantFlow
} from "./smart-auth/index.js";

export {
Expand Down
91 changes: 58 additions & 33 deletions src/smart-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,19 @@ const fastify = Fastify();
// Our Smart Health ID Provider config
const smartAuthProviderExample: SmartAuthProvider = {
name: "idp",
scope: ["launch"],
client: {
id: "123",
secret: "somesecret",
},
auth: {
grantFlow: "authorization_code",
scope: ["launch"],
tokenHost: "http://external.localhost",
authorizePath: "/smart/oauth/authorize",
redirect: {
host: "http://localhost:3000",
}
},
redirect: {
host: "http://localhost:3000",
},
iss: "http://external.localhost/issuer",
};

// Initialize the plugin with our Smart Health ID Provider config
Expand All @@ -68,16 +68,22 @@ fastify.listen(3000)

## Features

### SMART App Launch + Client Credentials

SMART Auth profiles can be configured for both the Authorization Code grant flow (i.e. standard SMART App Launch) and, separately, for the Client Credentials grant flow introduced in some SMART + FHIR systems even though it is not standard.

The rest of this document will assume the perspective of the SMART App Launch flow. See more config details in [Smart Health ID Provider Config](#Smart-Health-ID-Provider-Config).

### Routes

By default, the above example will scaffold an authorization URL in the format:
By default, the above example in [Usage](#Usage) will scaffold an authorization URL from the `const smartAuthProviderExample` configuration object in this format:

`/{prefix}/{SmartAuthProvider.name}/auth`
`/{prefix}/{smartAuthProviderExample.name}/auth`

* Prefix by default is `smart` - you can customize in the SmartAuthProvider config
* `SmartAuthProvider.name` is the id field of the config
* Prefix by default is `smart` - you can customize in `const smartAuthProviderExample`
* `smartAuthProviderExample.name` is the id field of the config

For the above example the authorization URL will be:
For the above example in [Usage](#Usage) the authorization URL will be:

`/smart/smartHealthIdProvider/auth`

Expand Down Expand Up @@ -187,43 +193,62 @@ generateAuthorizationUri(
The supported configuration for the provider config is just a TypeScript interface:

```typescript
export type SmartAuthProvider = {
export interface SmartAuthProvider {
/** A name to label the provider */
name: string;
/** @todo this could be typed to the FHIR spec */
scope: string[];
/** Client registration */
client: {
id: string;
secret: string;
}
/** Auth related config */
auth?: {
/** An optional prefix to add to every route path */
pathPrefix?: string;
/** Optional params to append to the authorization redirect */
authorizeParams?: Record<string, any>;
/** String used to set the host to request the tokens to. Required. */
tokenHost: string;
/** String path to request an access token. Default to /oauth/token. */
tokenPath?: string;
/** String path to revoke an access token. Default to /oauth/revoke. */
revokePath?: string;
/** String used to set the host to request an "authorization code". Default to the value set on auth.tokenHost. */
authorizeHost?: string;
/** String path to request an authorization code. Default to /oauth/authorize. */
authorizePath?: string;
};
/** Auth related config */
auth: AuthCodeConfig | ClientCredentialsConfig;
}
```

SMART Auth profiles can be configured for both the Authorization Code grant flow (i.e. as in CARIN BlueButton 2.0's SMART support) and, separately, for the Client Credentials grant flow introduced in some SMART + FHIR systems even though it is not standard.

These are typed this way:

```typescript
interface SmartAuthConfig {
/** Supported grant flow */
grantFlow: GrantFlow;
/** String used to set the host to request the tokens to. Required. */
tokenHost: string;
/** String path to request an access token. Default to /oauth/token. */
tokenPath?: string;
/** Optional params to post to the token exchange */
tokenParams?: Record<string, any>;
}

interface ClientCredentialsConfig extends SmartAuthConfig {
grantFlow: "client_credentials"
};
Copy link
Contributor

Choose a reason for hiding this comment

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

for the client_credientials grant flow, are we banking on tokenParams having a scope property?

Copy link
Member Author

Choose a reason for hiding this comment

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

No - if it has it, we will use it. But it doesn't need it.

In most OAuth 2.0 libraries, including simple-oauth2, you get to specify the "default" scope you want to use for a given Authorization Code grant flow request. When you want to support different grant flows with the same config, things get a little trickier because scope is also used in the Credentials Grant flow.

How do they each work?

In the Authorization Code grant flow the scope value is used during the authorization URL creation step (the place we redirect the users to first). scope is commonly used as a global config value that is used for every single authorization URL, but there's no reason that you can't change it per request.

This might make sense for example when you have an app that flexibly lets the user select which scopes they want to give to the app. You could in advance ask the user what they want to give you, before redirecting them to the authorization URL.

The param scope also happens to be used by the Client Credentials grant flow but it's very rarely ever going to make sense to have the same default values. Making a Client Credentials grant flow with scopes that are the same as a user is sort of disjoint from the point of the CC grant flow: to give you application instead of user-level access to some resources. Worse, SMART App Launch as a spec says absolutely nothing about Client Credentials.

Copy link
Member Author

Choose a reason for hiding this comment

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

So, if you set up a SmartAuthProvider with the Client Credentials grant flow, we're going to listen to anything you want to specify in tokenParams but no other defaults.


interface AuthCodeConfig extends SmartAuthConfig {
grantFlow: "authorization_code"
scope: SmartAuthScope[];
/** An optional prefix to add to every route path */
pathPrefix?: string;
/** Optional params to append to the authorization redirect */
authorizeParams?: Record<string, any>;
/** String used to set the host to request an "authorization code". Default to the value set on auth.tokenHost. */
authorizeHost?: string;
/** String path to request an authorization code. Default to /oauth/authorize. */
authorizePath?: string;
/** String path to revoke an access token. Default to /oauth/revoke. */
revokePath?: string;
/** Where should users (with the auth code) be redirected to? */
redirect: {
/** A required host name for the auth code exchange redirect path. */
host: string;
/** An optional authorize path override. */
path?: string;
};
/** The default host name for the authorization service. Used to redirect users to the authorization URL. */
iss: string;
}
};
```

### Redirect/Callback

By default, you need to define a redirect/callback path for your provider. This plugin is flexible about what you can do in this route.
Expand Down
20 changes: 2 additions & 18 deletions src/smart-auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { fastify as app } from "../../test/fixtures/authServer"
import { ClientCredentialsExample } from "../../test/smart-auth/idp"

import {
SmartAuthProvider,
getAccessTokenFromClientCredentialFlow,
} from './index';

Expand Down Expand Up @@ -73,24 +73,8 @@ describe("a provider with a weird name", () => {
})

describe("getAccessTokenFromClientCredentialFlow", () => {
const stubSmartAuthProvider = {
name: 'smart-stub',
scope: ['fhirUser'],
client: {
id: 'foo',
secret: 'bar',
},
auth: {
tokenHost: 'http://localhost/token',
},
redirect: {
host: 'http://localhost:3000/smart/smart-stub/auth',
},
iss: '',
} as SmartAuthProvider;

it("returns an access token", async () => {
const token = await getAccessTokenFromClientCredentialFlow(stubSmartAuthProvider);
const token = await getAccessTokenFromClientCredentialFlow(ClientCredentialsExample);
expect(ClientCredentials).toHaveBeenCalled();
expect(token).toEqual('faketoken');
});
Expand Down
89 changes: 57 additions & 32 deletions src/smart-auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,43 +20,56 @@ type Resources = `${"patient" | "user"}/${FHIRResourceList | "*"}.${"read" | "wr

export type SmartAuthScope = LaunchContext | Profile | Refresh | Resources

export type SmartAuthProvider = {
export type GrantFlow = "authorization_code" | "client_credentials"

export interface SmartAuthProvider {
/** A name to label the provider */
name: string;
scope: SmartAuthScope[];
/** Client registration */
client: {
id: string;
secret: string;
}
/** Auth related config */
auth: {
/** An optional prefix to add to every route path */
pathPrefix?: string;
/** Optional params to append to the authorization redirect */
authorizeParams?: Record<string, any>;
/** String used to set the host to request an "authorization code". Default to the value set on auth.tokenHost. */
authorizeHost?: string;
/** String path to request an authorization code. Default to /oauth/authorize. */
authorizePath?: string;
/** Optional params to post to the token exchange */
tokenParams?: Record<string, any>;
/** String used to set the host to request the tokens to. Required. */
tokenHost: string;
/** String path to request an access token. Default to /oauth/token. */
tokenPath?: string;
/** String path to revoke an access token. Default to /oauth/revoke. */
revokePath?: string;
};
/** Auth related config */
auth: AuthCodeConfig | ClientCredentialsConfig;
}

interface SmartAuthConfig {
/** Supported grant flow */
grantFlow: GrantFlow;
/** String used to set the host to request the tokens to. Required. */
tokenHost: string;
/** String path to request an access token. Default to /oauth/token. */
tokenPath?: string;
/** Optional params to post to the token exchange */
tokenParams?: Record<string, any>;
}

interface ClientCredentialsConfig extends SmartAuthConfig {
grantFlow: "client_credentials"
};

interface AuthCodeConfig extends SmartAuthConfig {
grantFlow: "authorization_code"
scope: SmartAuthScope[];
/** An optional prefix to add to every route path */
pathPrefix?: string;
/** Optional params to append to the authorization redirect */
authorizeParams?: Record<string, any>;
/** String used to set the host to request an "authorization code". Default to the value set on auth.tokenHost. */
authorizeHost?: string;
/** String path to request an authorization code. Default to /oauth/authorize. */
authorizePath?: string;
/** String path to revoke an access token. Default to /oauth/revoke. */
revokePath?: string;
/** Where should users (with the auth code) be redirected to? */
redirect: {
/** A required host name for the auth code exchange redirect path. */
host: string;
/** An optional authorize path override. */
path?: string;
};
/** The default host name for the authorization service. Used to redirect users to the authorization URL. */
iss: string;
}
};

export interface SmartAuthNamespace {
authorizationCodeFlow: AuthorizationCode;
Expand Down Expand Up @@ -100,7 +113,11 @@ export interface SmartAuthUrlQuerystring {
Querystring?: {
scope?: SmartAuthScope[]
}
}
}

function supports(provider: SmartAuthProvider, flow: GrantFlow): boolean {
return provider.auth.grantFlow === flow
}

const defaultState = randomBytes(10).toString('hex')

Expand All @@ -119,14 +136,17 @@ function routeCase(value: string): string {
}

const oauthPlugin: FastifyPluginCallback<SmartAuthProvider> = function (http, options, next) {
const { name, auth, client, scope: defaultScope, redirect } = options
const { name, client } = options;
supports(options, "authorization_code");
jdjkelly marked this conversation as resolved.
Show resolved Hide resolved
const auth = options.auth as AuthCodeConfig;
const { scope: defaultScope, redirect } = auth;

const prefix = auth?.pathPrefix || "/smart";
const tokenParams = auth?.tokenParams || {}
const tokenHost = auth.tokenHost;
const authorizeParams = auth?.authorizeParams || {}
const authorizeRedirectPath = `${prefix}/${routeCase(name)}/auth`
const redirectPath = redirect.path || `${prefix}/${routeCase(name)}/redirect`
const redirectPath = redirect?.path || `${prefix}/${routeCase(name)}/redirect`
const redirectUri = `${redirect.host}${redirectPath}`

function generateAuthorizationUri(scope?: SmartAuthScope[]) {
Expand Down Expand Up @@ -209,6 +229,10 @@ export const getAccessTokenFromClientCredentialFlow = async (
smartAuthProvider: SmartAuthProvider,
scope?: string[]
jdjkelly marked this conversation as resolved.
Show resolved Hide resolved
): Promise<AccessToken | undefined> => {
if (!supports(smartAuthProvider, "client_credentials")) {
throw new Error(`SmartAuthProvider ${smartAuthProvider.name} does not support client_credentials - client_credentials must be explicitly set as a suppoedGrantFlow`)
}

const clientCredentialsOptions = {
client: smartAuthProvider.client,
auth: {
Expand All @@ -218,12 +242,13 @@ export const getAccessTokenFromClientCredentialFlow = async (
};

const client = new ClientCredentials(clientCredentialsOptions);
const tokenParams = {
scope: scope || smartAuthProvider.scope,
};

const params = {
scope,
...smartAuthProvider.auth.tokenParams
}

try {
return await client.getToken(tokenParams);
return await client.getToken(params);
} catch (error: any) {
console.log('Access Token error', error.message);
}
Expand Down
6 changes: 3 additions & 3 deletions test/fixtures/authServer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { smartAuthProviderExample, badNameProviderExample } from "../smart-auth/idp";
import { AuthorizationCodeExample, badNameExample } from "../smart-auth/idp";
import Fastify from "fastify";
import {
SmartAuth,
Expand All @@ -15,8 +15,8 @@ declare module 'fastify' {

export const fastify = Fastify();

fastify.register(SmartAuth, { prefix: '/smart', ...smartAuthProviderExample })
fastify.register(SmartAuth, { prefix: '/smart', ...badNameProviderExample })
fastify.register(SmartAuth, { prefix: '/smart', ...AuthorizationCodeExample })
fastify.register(SmartAuth, { prefix: '/smart', ...badNameExample })

fastify.get<Querystring>("/smart/idp/redirect", QuerystringAjvSchema, async function (request, reply) {
const response = await this.idp.getAccessTokenFromAuthorizationCodeFlow(request)
Expand Down
Loading