How to Set Up an API with JWT, req.User and Route Security
After reading this article, you’ll understand how to create an API in Node.js/TypeScript with functions such as:
- On calling
/login
, generate and return a Bearer/JWT to the user - Use middleware to decode the Authentication header (Bearer/JWT)
- Modify the Express namespace to add support for
req.User
- Add security on routes (ie, “Only
Admin
may access this Route”)
PS: There is a link to a working git at the very end.
Basic Express setup
There are very few exciting things about something that has been documented a thousand times, but we need to get through this part:
import Express from 'express';
import http from 'http';
import cors from 'cors';const PORT = process.env.PORT !== undefined ? parseInt(process.env.PORT) : 3001;let app: Express.Application | undefined = undefined;app = Express();app.use(cors({ exposedHeaders: 'Authorization' }));
app.use(Express.urlencoded({ extended: true }));
app.use(Express.json());// -----> MORE CODE WILL BE ADDED HERE! <--------http.createServer(app).listen(PORT, () => console.log(`Webserver running at http://localhost:${PORT}/`));
Add all routes at the line shown above in the order they’re presented. Add the rest of the code somewhere else. A few items should be in their own file(s).
Decoding the JWT
Really early in the routes, the function for decoding the header should be found. If there are truly anonymous routes present (like /robots.txt
, then this function should probably come after that).
app.use(function JWTDecoderMiddleware(req: Express.Request, res: Express.Response, next: Express.NextFunction) { const authorization = req.headers.authorization;
if (authorization && authorization.startsWith("Bearer ")) {
const u = DecodeJWT(authorization.substring(7));
if (u)
req.User = u;
else
console.log("jwt found, but it was not valid.");
}next();
});
This function is dependant on two things; the global namespace being modified making req.User
available and the DecodeJWT
-function (see below)
The `User`, `Bearer` and `AccessLevel` types
Before continuing, there are a couple of types needed. At the API side, we will have some kind of user logged in, and the type/interface for that needs to be handled.
Next, the Bearer. It will be sent to and stored by the client (in the encrypted JWT format). Basically, anything non-sensitive can be stored in the JWT, just be aware that the user can easily decrypt the JWT (but they cannot modify it). Examine JWT at https://jwt.io/.
types.ts
export type User = {
ID: string,
Name: string,
AccessLevel: AccessLevel, /** Never ever store an clear-text pw like this! */
Password: string
}
export type Bearer = {
Name: string,
ID: string,
AccessLevel: AccessLevel
}export type AccessLevel = "Admin" | "User" | "Anonymous";
❕ Store the user's credentials in a safe way, don’t ever use clear text passwords as in this demo.
global.d.ts
Note the global.d.ts
naming. Read more about global variables in https://javascript.plainenglish.io/typescript-and-global-variables-in-node-js-59c4bf40cb31
At the same time as Express.User
is added, we add the types for the environment variables that we intend to use:
import { User } from './types';declare global { namespace Express {
interface Request {
User: User
}
} namespace NodeJS {
interface ProcessEnv {
JWTENCRYPTIONKEY: string;
NODE_ENV: 'development' | 'production';
PORT: string; // All environment variables are strings
}
}
}export { };
❕ Make sure you either use .env
or set the environment variables.
The DecodeJWT function
In order to verify the JWT hasn’t been tampered with, the API must define a secret key. Basically decode it as:
import * as jsonwebtoken from "jsonwebtoken";export function Decode<T extends object>(iJWT: string): T | undefined {
try {
return jsonwebtoken.verify(iJWT,
process.env.JWTENCRYPTIONKEY) as T;
} catch (e) {
return undefined;
}
}export function DecodeJWT(jwt: string): User | undefined { const decoded = Decode<Bearer>(jwt);
if (decoded)
return Users.find(u => u.ID === decoded.ID);
}
Users?
We need some users.
const Users: User[] = [
{ ID: "root", Name: "root", AccessLevel: "Admin", Password: "Banana1" },
{ ID: "foo", Name: "Foo", AccessLevel: "User", Password: "Bar" }
];
❕ Store the user's credentials in a safe way, don’t ever use clear text passwords as in this demo.
Route /
Let’s add some routes, beginning with /
.
app.get('/', function (req: Express.Request, res: Express.Response){
res.status(200).json({
"Foo": "Bar",
"Time": new Date().toISOString()
});
});
Route /login
This route will take the { "username": "Foo", "password" : "Bar" }
and try to find a user matching the credentials. If found, a JWT is generated and sent to the callee.
app.post('/login', function (req: Express.Request, res: Express.Response) { console.log(`U=${req.body.username}, Pwd=<hidden>`); const jwt = Login(req.body);
if (jwt) {
res.append('Authorization', jwt);
res.status(200).json({ Success: true, JWT: jwt });
}
else
res.status(200).json({ Success: false });});
Login
export function Login(arg: { username: string, password: string }): string | undefined { const user = Users.find(u => u.Name === arg.username
&& u.Password === arg.password);
if (user) { const result: Bearer = {
ID: user.ID,
Name: user.Name,
AccessLevel: user.AccessLevel
}; return Encode(result); }
}
Encode function
export function Encode<T extends object>(iPayLoad: T, iTimeoutSeconds = 3600 * 10): string {
return jsonwebtoken.sign(
iPayLoad,
process.env.JWTENCRYPTIONKEY,
{ expiresIn: iTimeoutSeconds }
);
}
So far, so good.
At this point, we have achieved a running web server that exposes /
, /login
and that can decode any valid JWT and through that can return a User.
Now it gets a little bit more complicated. :)
HasAccessLevel — implemented as a function
Checking accesslevel can be done with a function or as Middleware.
/me — with a function to check accesslevel
app.get('/me', function (req: Express.Request, res: Express.Response) { if (!HasAccessLevel(req, res, ["User", "Admin"]))
return; // Response has already been sent res.status(200).json({ "UserName": req.User.Name });});
HasAccessLevel function
AccessLevel is a type, defined as type AccessLevel = "Admin" | "User" | "Anonymous
.
function HasAccessLevel(req: Express.Request, res: Express.Response, iReqLevel: AccessLevel[] = []): boolean { let result = false;
if (req.User) // Has the middleware decoded the jwt properly?
// Yes, does the user match?
result = iReqLevel.includes(req.User.AccessLevel);
else
// No, but maybe "Anonymous" can access this function?
result = iReqLevel.includes("Anonymous");
// Not allowed to access? Send 401!
if (!result) {
res.status(401).json(
{ Success: false, Error: "Access denied" });
res.end();
} return result;
}
HasAccessLevelMiddleware — implemented as Middleware
/me — using middleware
Middlewares has to have three parameters, but that can easily be avoided with the following syntax:
app.get('/me2',
(rq, rs, n) => HasAccessLevelMiddleware(rq, rs, n, ["User", "Admin"]),
(req: Express.Request, res: Express.Response) => { // Will only get here if HasAccessLevelMiddleware allows it
res.status(200).json({ "UserName": req.User.Name });});
HasAccessLevelMiddleware
The middleware decides if next()
is called, or not.
export function HasAccessLevelMiddleware(req: Express.Request, res: Express.Response, next: Express.NextFunction, iReqLevel: AccessLevel[] = []): void { let hasAccess = false;
if (req.User) // Has the middleware decoded the jwt properly?
hasAccess = iReqLevel.includes(req.User.AccessLevel);
else
hasAccess = iReqLevel.includes("Anonymous"); if (hasAccess)
next(); // Continue executing
else {
res.status(401).json({ Success: false, Error: "Denied" });
res.end();
}
}
Error handler
At last, add the error handler. The error handler takes four parameters, where the first one is err
. To get TypeScript to shine, it’s a good thing to define err as Error & { status: number, message: string }
. Error
is the built-in Error object and Express adds the status
and message
.
app.use(function (err: Error & { status: number, message: string }, req: Express.Request, res: Express.Response, next: Express.NextFunction) {console.error(err.status);
console.error(err.message);
console.error(err.stack);
res.status(500).json({ Error: "Internal error" });
res.end();});
curl
These commands were written on Windows, make necessary adjustments to run on other platforms:
# Login as user "Foo"curl -X POST -H "Content-Type: application/json" -d "{\"username\":\"Foo\",\"password\":\"Bar\"}" http://localhost:3001/login# Grab token from body or headers and do:set TOKEN=eyJhbGciOiJIUzI1NiIsIn.....# Use the tokencurl -H "Authorization: Bearer %TOKEN%" http://localhost:3001/me
Git repo
The repo is prepared to just run by pressing F5
in VSCode (or nodemon
, or by running node
). Read more on https://github.com/tomnil/expressplate
Enjoy :)
More content at plainenglish.io