Adding authentication providers
Passport
We chose Passport as our authentication platform due to its comprehensive set of supported authentication strategies.
How to add a new strategy provider
Quick guide
1. Install the passport-js based provider package.
2. Create a new folder structure for the provider.
3. Implement the provider, extending the suitable framework if needed.
4. Add the provider to the backend.
Installing the dependencies:
cd plugins/auth-backend
yarn add passport-provider-a
yarn add @types/passport-provider-a
Create implementation
Make a new folder with the name of the provider following the below file structure:
plugins/auth-backend/src/providers/providerA
├── index.ts
└── provider.ts
plugins/auth-backend/src/providers/providerA/provider.ts
defines the
provider class which implements a handler for the chosen framework.
Adding an OAuth based provider
If we're adding an OAuth
based provider we would implement the
OAuthProviderHandlers interface. By implementing this
interface we can use the OAuthProvider
class provided by lib/oauth
, meaning
we don't need to implement the full
AuthProviderRouteHandlers interface that providers
otherwise need to implement.
The provider class takes the provider's options as a class parameter. It also
imports the Strategy
from the passport package.
import { Strategy as ProviderAStrategy } from 'passport-provider-a';
export type ProviderAProviderOptions = OAuthProviderOptions & {
// extra options here
}
export class ProviderAAuthProvider implements OAuthProviderHandlers {
private readonly _strategy: ProviderAStrategy;
constructor(options: ProviderAProviderOptions) {
this._strategy = new ProviderAStrategy(
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
passReqToCallback: false as true,
response_type: 'code',
/// ... etc
}
verifyFunction, // See the "Verify Callback" section
);
}
async start() {}
async handler() {}
}
Adding an non-OAuth based provider
Note: We have prioritized OAuth-based providers and non-OAuth providers should be considered experimental.
An non-OAuth
based provider could implement
AuthProviderRouteHandlers instead.
type ProviderAOptions = {
// ...
};
export class ProviderAAuthProvider implements AuthProviderRouteHandlers {
private readonly _strategy: ProviderAStrategy;
constructor(options: ProviderAOptions) {
this._strategy = new ProviderAStrategy(
{
// ...
},
verifyFunction, // See the "Verify Callback" section
);
}
async start() {}
async frameHandler() {}
async logout() {}
async refresh() {} // If supported
}
Factory function
Each provider exports a factory function that instantiates the provider. The factory should implement AuthProviderFactory, which passes in a object with utilities for configuration, logging, token issuing, etc. The factory should return an implementation of AuthProviderRouteHandlers.
The factory is what decides the mapping from
static configuration to the creation of auth providers. For
example, OAuth providers use OAuthEnvironmentHandler
to allow for multiple
different configurations, one for each environment, which looks like this;
export const createOktaProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
// read options from config
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
// instantiate our OAuthProviderHandlers implementation
const provider = new OktaAuthProvider({
audience,
clientId,
clientSecret,
callbackUrl,
});
// Wrap the OAuthProviderHandlers with OAuthProvider, which implements AuthProviderRouteHandlers
return OAuthProvider.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
The purpose of the different environments is to allow for a single auth-backend to serve as the authentication service for multiple different frontend environments, such as local development, staging, and production.
The factory function for other providers can be a lot simpler, as they might not have configuration for each environment. Looking something like this:
export const createProviderAProvider: AuthProviderFactory = ({ config }) => {
const a = config.getString('a');
const b = config.getString('b');
return new ProviderAAuthProvider({ a, b });
};
Verify Callback
Strategies require what is known as a verify callback. The purpose of a verify callback is to find the user that possesses a set of credentials. When Passport authenticates a request, it parses the credentials contained in the request. It then invokes the verify callback with those credentials as arguments [...]. If the credentials are valid, the verify callback invokes done to supply Passport with the user that authenticated.
If the credentials are not valid (for example, if the password is incorrect), done should be invoked with false instead of a user to indicate an authentication failure.
plugins/auth-backend/src/providers/providerA/index.ts
is simply
re-exporting the factory function to be used for hooking the provider up to the
backend.
export { createProviderAProvider } from './provider';
Hook it up to the backend
plugins/auth-backend/src/providers/factories.ts
When the auth-backend
starts it sets up routing for all the available providers by calling
createAuthProviderRouter
on each provider. You need to import the factory
function from the provider and add it to the factory:
import { createProviderAProvider } from './providerA';
const factories: { [providerId: string]: AuthProviderFactory } = {
providerA: createProviderAProvider,
};
By doing this auth-backend
automatically adds these endpoints:
router.get('/auth/providerA/start');
router.get('/auth/providerA/handler/frame');
router.post('/auth/providerA/handler/frame');
router.post('/auth/providerA/logout');
router.get('/auth/providerA/refresh'); // if supported
As you can see each endpoint is prefixed with both /auth
and its provider
name.
Test the new provider
You can curl -i localhost:7000/api/auth/providerA/start
and which should
provide a 302
redirect with a Location
header. Paste the url from that
header into a web browser and you should be able to trigger the authorization
flow.
OAuthProviderHandlers
export interface OAuthProviderHandlers {
start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo>;
handler(
req: express.Request,
): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
refresh?(
refreshToken: string,
scope: string,
): Promise<AuthResponse<OAuthProviderInfo>>;
logout?(): Promise<void>;
}
AuthProviderRouteHandlers
export interface AuthProviderRouteHandlers {
start(req: express.Request, res: express.Response): Promise<any>;
frameHandler(req: express.Request, res: express.Response): Promise<any>;
refresh?(req: express.Request, res: express.Response): Promise<any>;
logout(req: express.Request, res: express.Response): Promise<any>;
}
AuthProviderFactory
export type AuthProviderFactoryOptions = {
globalConfig: AuthProviderConfig;
config: Config;
logger: Logger;
tokenIssuer: TokenIssuer;
};
export type AuthProviderFactory = (
options: AuthProviderFactoryOptions,
) => AuthProviderRouteHandlers;