Authentication
What is Authentication?
Authentication is the process to identify who submitted the request. In httpc terms, to associate a user to the request context. A request is authenticated when it has a user associated. Otherwise, it’s an anonymous request.
The user is an IUser object. httpc provides no predefined attribute for the user. For type safety, you can define the user properties by expanding the IUser interface
A user can authenticate itself in several ways. It’s up to you to specify which and how many authentication types you want to support.
When building an API, the authentication process is involved in three areas:
1. Setup
For the first step, you need to specify which authentication types the server will support. Each authentication has configuration options to control its behavior.
For an httpc server, an authentication type is added through a middleware at application level. Meaning, it has a global scope and works for all incoming requests.
import { Application, AuthenticationBearerMiddleware, AuthenticationApiKeyMiddleware } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware(),
AuthenticationApiKeyMiddleware(),
]
});
You can support multiple authentications. The server will execute them in order.
There’re several builtin middlewares covering the most common authentication types. You can also create a custom middleware to support your use case.
2. Guard
Once the authentication is set, you want to protect areas of your application where only an authenticated user can have access to.
You can protect the whole application, one or more sub groups or go down to specific functions. Just apply the Authenticated
middleware to the section you want to guard.
import { httpCall, Authenticated } from "@httpc/kit";
const getMyProfile = httpCall(
Authenticated(), // <-- guard the function: request must be authenticated
async () => {
// do something
}
);
import { httpCall, httpGroup, Authenticated } from "@httpc/kit";
const getMyProfile = httpCall(/* omitted */);
const getMyOrders = httpCall(/* omitted */);
const getMyWallet = httpCall(/* omitted */);
export default httpGroup(
Authenticated(), // <-- guard the whole group
{
getMyProfile,
getMyOrders,
getMyWallet,
}
);
import { Application, AuthenticationBearerMiddleware, Authenticated } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware(), // use the bearer authentication
Authenticated(), // <-- all request must be authenticated
]
});
You can be more selective and guard some execution paths with a condition inside functions. The useIsAuthenticated
hook returns true
/false
if the request is authenticated.
import { httpCall, useIsAuthenticated, useUser } from "@httpc/kit";
const getDiscounts = httpCall(
// no guard! anonymous request can access
async () => {
if (!useIsAuthenticated()) {
// the user is not authenticated, return the signup 5% discount
return [{ name: "signup-offer", discount: 5 }];
} else {
// the user is authenticated, returns its own active discounts
const user = useUser();
return await db.getUserDiscounts(user.id);
}
}
);
3. Access
Once a request passes the authentication guard, you need to know which user is sending the request. You can use the useUser
hook to get the user associated to the request.
import { httpCall, Authenticated, useUser } from "@httpc/kit";
const getMyOrders = httpCall(
Authenticated(),
async () => {
const user = useUser();
return await db.getOrdersByUser(user.id);
}
);
The useUser
throws an UnauthorizedError
when the request is not authenticated;
Builtin authentications
@httpc/kit offers builtin support for:
- Bearer authentication to handle JWTs
- Basic authentication to handle the standard http basic credentials
- ApiKey authentication to handle api keys
For each authentication, the framework provides a core middleware and a predefined service. You need to assign the middleware to the application to activate the relative authentication.
import { Application, AuthenticationBearerMiddleware } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware()
]
});
Often you don’t need to deal with the authentication service as it works under the hood covering most common use cases.
You can add any number of authentication to your application. The server executes middlewares in ordered sequence. If an authentication middleware doesn’t recognize the request, it will pass the request to the next one. The same happens when the authentication is already performed: if a request has already a user associated, an authenticated middleware skips its execution to the next step in the pipeline.
Usually you should register an authentication middleware at application level, that is, with a global scope. But you can also restrict an authentication to a sub group to limit its perimeter.
Bearer authentication (JWT)
Allow the server to use a JWT to perform authentication. JWTs are also called Bearer tokens from the authorization schema they employ.
The Bearer authentication requires a secret to decrypt tokens. Usually you can load it from an environment variable although other way are possible.
The main component is AuthenticationBearerMiddleware
.
Basic Usage
Leverage the default behavior. Just add the middleware to the application and configure the secret.
import { Application, AuthenticationBearerMiddleware } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware({
jwtSecret: process.env.JWT_SECRET, // load the secret from env
})
]
});
By default, the server will:
- Look for a JWT in the request. If not found, the authentication is skipped
- Decrypt the JWT and validate it:
- the encryption algorithm must be HS256
- check expiration, if
exp
attribute is present
- If the JWT is malformed or the decryption or validation fails, throw an
UnauthorizedError
- From the decrypted payload, construct a user object from:
- the
sub
attribute, become theuser.id
- discard any standard JWT attributes like exp, aud, iss, …
- the remaining attributes, become user properties
- the
- If the
sub
attribute is missing or empty, throw anUnauthorizedError
- Associate the user to the request
Property customization
If you need to customize how the user attributes are extracted from the JWT payload, use the onDecode
callback.
import { Application, AuthenticationBearerMiddleware, JwtPayload } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware({
jwtSecret: process.env.JWT_SECRET, // load the secret from env
onDecode: (payload: JwtPayload): IUser => {
return { }; // return the user
}
})
]
});
The object returned from onDecode
will be the user associated to the request. It’s your responsibility to throw an error if something is wrong, i.e. some attribute is missing or invalid.
Full customization
If you need full control on how the JWT is handled, you can use onAuthenticate
callback. The server will only extract the token for you.
import { Application, AuthenticationBearerMiddleware } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware({
onAuthenticate: (token: string): IUser => {
// any custom logic to
// 1. decrypt
// 2. validate
// 3. extract the user
return { }; // return the user
}
})
]
});
The object returned from onAuthenticate
will be the user associated to the request. It’s your responsibility to decrypt, validate and extract the user and, if something fails, to throw an error.
The framework provides a JwtService
to help dealing with JWTs. The JwtService
offers common operation like decode and validate with advanced options to meet advanced needs.
Custom service
For advanced scenarios, you can define a custom service to perform the authentication. A dedicated service is useful when you need other services and want to rely on dependency injection to get them. Or you want to scope the logic inside a class.
When you use a custom service, the AuthenticationBearerMiddleware
can be used with no options as all the logic is deferred to the service.
import { Application, AuthenticationBearerMiddleware } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware()
]
});
You can define a custom bearer service:
From scratch
You have to implementIAuthenticationService<string>
interface witch defines theauthenticate
method. Receive the token, return the user.import { IAuthenticationService, alias, KEY, JwtService } from "@httpc/kit"; import { BlackListService } from "./services"; @alias(KEY("BearerAuthentication")) export class CustomBearerService implements IAuthenticationService<string> { constructor( protected jwt: JwtService, protected blackList: BlackListService, ) { } async authenticate(token: string): Promise<IUser> { // custom logic to // 1. decrypt // 2. validate // 3. extract the user return { /* user attributes */}; } }
Extend the builtin
BearerAuthenticationService
You want to make little tweaks on the predefined behavior. You can overrideonDecode
to customize the user attribute extraction. Or overrideauthenticate
to perform the authentication yourself. By default, the secret is read from theJWT_SECRET
environment variable.import { BearerAuthenticationService, JwtPayload, alias, KEY } from "@httpc/kit"; @alias(KEY("BearerAuthentication")) export class CustomBearerService extends BearerAuthenticationService { protected override async onDecode(payload: JwtPayload): Promise<IUser> { // extract user form the payload return { /* user attributes */}; } }
Key components
AuthenticationBearerMiddleware
The middleware performing the authenticationBearerAuthenticationService
Optional service if deep customization is neededJwtService
Builtin helper to deal with JWTs
Examples
import { Application, AuthenticationBearerMiddleware } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware({
jwtSecret: process.env.JWT_SECRET
})
]
});
import { Application, AuthenticationBearerMiddleware } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware({
jwtSecret: process.env.JWT_SECRET,
onDecode: payload => {
return {
userId: payload.sub,
firstName: payload.first_name,
lastName: payload.last_name,
roles: [payload.role],
};
}
})
]
});
import { Application, AuthenticationBearerMiddleware, JwtService, useInjected } from "@httpc/kit";
import { TokenService } from "./services";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware({
onAuthenticate: async token => {
const [jwt, tokens] = useInjected(JwtService, TokenService);
const result = jwt.validate(token, {
secret: process.env.JWT_SECRET,
});
if (!result.success) {
throw new UnauthorizedError();
}
const payload = result.payload;
if (!payload.jti) { // missing required attribute
throw new UnauthorizedError();
}
if (await tokens.isRevoked(payload.jti)) {
throw new UnauthorizedError();
}
const user = {
id: payload.userId,
};
return user;
}
})
]
});
import { BearerAuthenticationService, JwtService, alias, KEY, ILogger, logger } from "@httpc/kit";
import { SecretManager } from "./services";
@alias(KEY("BearerAuthentication"))
export class CustomBearerService extends BearerAuthenticationService {
constructor(
@logger() logger: ILogger,
protected jwt: JwtService,
protected secretManager: SecretManager,
) {
super(logger, jwt, {});
}
override async authenticate(token: string): Promise<IUser> {
if (!this.options.jwtSecret) {
this.options.jwtSecret = await this.secretManager.retrieve("JWT_SECRET");
}
return await super.authenticate(token);
}
}
import { Application, AuthenticationBearerMiddleware } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware(})
]
});
import { Application, AuthenticationBearerMiddleware } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware({
jwtSecret: process.env.JWT_SECRET,
onDecode: payload => {
return {
userId: payload.sub,
firstName: payload.first_name,
lastName: payload.last_name,
roles: [payload.role],
};
}
})
]
});
import { Application, AuthenticationBearerMiddleware, JwtService, useInjected } from "@httpc/kit";
import { TokenService } from "./services";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware({
onAuthenticate: async token => {
const [jwt, tokens] = useInjected(JwtService, TokenService);
const result = jwt.validate(token, {
secret: process.env.JWT_SECRET,
});
if (!result.success) {
throw new UnauthorizedError();
}
const payload = result.payload;
if (!payload.jti) { // missing required attribute
throw new UnauthorizedError();
}
if (await tokens.isRevoked(payload.jti)) {
throw new UnauthorizedError();
}
const user = {
id: payload.userId,
};
return user;
}
})
]
});
import { BearerAuthenticationService, JwtService, alias, KEY, ILogger, logger } from "@httpc/kit";
import { SecretManager } from "./services";
@alias(KEY("BearerAuthentication"))
export class CustomBearerService extends BearerAuthenticationService {
constructor(
@logger() logger: ILogger,
protected jwt: JwtService,
protected secretManager: SecretManager,
) {
super(logger, jwt, {});
}
override async authenticate(token: string): Promise<IUser> {
if (!this.options.jwtSecret) {
this.options.jwtSecret = await this.secretManager.retrieve("JWT_SECRET");
}
return await super.authenticate(token);
}
}
import { Application, AuthenticationBearerMiddleware } from "@httpc/kit";
const app = new Application({
middlewares: [
AuthenticationBearerMiddleware(})
]
});
Basic authentication
// TODO
ApiKey authentication
// TODO
Hooks
useUser
// TODO
The useUser
hooks works like a hard guard.
useIsAuthenticated
// TODO
useAuthentication
// TODO
Interfaces
IUser
// TODO
IAuthenticationService<T>
// TODO
Custom authentication
// TODO