This is a primary integration for the Auth
module. This is another level of scaffolding allowing for any express
-based authentication framework to integrate.
The integration with the Express
touches multiple levels. Primarily:
- Security information management
- Patterns for auth framework integrations
- Route declaration
- Passport integration
When working with framework's authentication, the user information is exposed via the express
Request
object. The auth functionality is exposed on the request as the property auth
.
declare module "express" {
export interface Request {
auth: {
context: AuthContext<any>;
authenticated: boolean;
unauthenticated: boolean;
checkPermissions(include: string[], exclude: string[]): boolean;
login(providers: symbol[]): Promise<AuthContext<any>|undefined>;
logout: Promise<void>;
}
}
}
This allows for any filters/middleware to access this information without deeper knowledge of the framework itself. Also, for performance benefits, the auth context is stored in the user session as a means to minimize future lookups. Since we are storing the entire principal in the session, it is best to keep the principal as small as possible.
Every external framework integration relies upon the AuthProvider
contract. This contract defines the boundaries between both frameworks and what is needed to pass between. As stated elsewhere, the goal is to be as flexible as possible, and so the contract is as minimal as possible:
export class AuthProvider<U> {
async logout(req: Request, res: Response): Promise<void>;
async login(req: Request, res: Response): Promise<AuthContext<U> | undefined>;
serialize(ctx: AuthContext<U>): string;
async deserialize(serialized: string): Promise<AuthContext<U>>;
}
By default, logout does nothing, as the express
session cleanup will generally suffice. Additionally, the serialize
/deserialize
functionality default to JSON.stringify
/JSON.parse
respectively. These can be overridden as needed, but sensible defaults help to minimize the friction between pieces.
The only required method to be defined is the login
method. This takes in an express
Request
and Response
, and is responsible for:
- Returning an
AuthContext
if authentication was successful - Throwing an error if it failed
- Returning undefined if the authentication is multi-staged and has not completed yet
A sample auth provider would look like:
class DumbProvider extends AuthProvider<any> {
async login(req: Request, res: Response) {
const { username, password } = req.body;
if (username === 'test' && password === 'test') {
return {
id: 'test',
permissions: new Set(),
principal: {
username: 'test'
}
};
} else {
throw new Error(ERR_INVALID_CREDS);
}
}
}
The provider must be registered with a custom symbol to be used within the framework. At startup, all registered AuthProvider
s are collected and stored for reference at runtime, via symbol.
export const FB_AUTH = Symbol('facebook');
export class AppConfig {
@InjectableFactory(FB_AUTH)
static facebookProvider(): AuthProvider<any> {
return new AuthProvider(...);
}
}
The symbol FB_AUTH
is what will be used to reference providers at runtime. This was chosen, over class
references due to the fact that most providers will not be defined via a new class, but via an @InjectableFactory
method.
Like the AuthService
, there are common auth patterns that most users will implement. The framework has codified these into decorators that a developer can pick up and use.
@Authenticate
provides express
middleware that will authenticate the user as defined by the specified providers, or throw an error if authentication is unsuccessful.
@Controller('/auth')
export class Auth {
@Get('/facebook')
@Authenticate(FB_AUTH)
async fbLogin() {}
...
}
@Authenticated
and @Unauthenticated
will simply enforce whether or not a user is logged in and throw the appropriate error messages as needed.
@Controller('/auth')
export class Auth {
...
@Get('/self')
@Authenticated()
async getSelf(req: Request) {
return req.auth.context;
}
@Post('/logout')
@Unauthenticated()
async logout(req: Request, res: Response) {
await req.auth.logout();
}
Within the node ecosystem, the most prevalent express
-baseed auth framework is passport
. With countless integrations, the desire to leverage as much of it as possible, is extremely high. To that end, the there is extension support for passport
baked in, and registering and configuring a strategy is fairly straightforward.
export const FB_AUTH = Symbol('facebook');
export class FbUser {
id: string;
roles: string[];
}
export class AppConfig {
@InjectableFactory(FB_AUTH)
static facebookPassport(): AuthProvider<any> {
return new AuthPassportProvider('facebook',
new FacebookStrategy(
{
clientID: '<clientId>',
clientSecret: '<clientSecret>',
callbackURL: 'http://localhost:3000/auth/facebook/callback',
profileFields: ['id', 'displayName', 'photos', 'email']
},
(accessToken, refreshToken, profile, cb) => {
return cb(undefined, profile);
}
),
new PrincipalConfig(FbUser, {
id: 'id',
permissions: 'roles'
})
);
}
}
As you can see, AuthPassportProvider
will take care of the majority of the work, and all that is required is:
- Provide the name of the strategy (should be unique)
- Provide the strategy instance. NOTE you will need to provide the callback for the strategy to ensure you pass the external principal back into the framework
- The
PrincipalConfig
which defines the mapping between external and local principals.
After that, the provider is no different than any other, and can be used accordingly. Additionally, because passport runs first, in it's entirety, you can use the provider as you normally would any passport middleware.
@Controller('/auth')
export class AppAuth {
@Get('/facebook')
@Authenticate(FB_AUTH)
async fbLogin() {
}
@Get('/facebook/callback')
@Authenticate(FB_AUTH)
async fbLoginComplete() {
return new Redirect('/auth/self', 301);
}
@Get('/self')
@Authenticated()
async getSelf(req: Request) {
return req.auth.context;
}
}