Session Tokens (authentication)

This document describes changes and new features introduced in version 1.0.0. Instructions to upgrade to the new release can be found here. Old documentation can be found here.

Introduction

This document assumes that you have alread read the Quick Start page.

In FoalTS, web sessions are temporary states associated with a specific user. They are identified by a token and are mainly used to keep users authenticated between several HTTP requests (the client sends the token on each request to authenticate the user).

A session usually begins when the user logs in and ends after a period of inactivity or when the user logs out. By inactivity, we mean that the server no longer receives requests from the authenticated user for a certain period of time.

Get Started

Provide a Secret

In order to use sessions, you must provide a base64-encoded secret in either:

  • a configuration file

    Example with config/default.yml

    settings:
    session:
    secret: xxx
  • or in a .env file or in an environment variable:

    SETTINGS_SESSION_SECRET=xxx

You can generate such a secret with the CLI command:

foal createsecret

Choose a Session Store

Then you have to choose where the temporary session state will be stored. FoalTS provides several session stores for this. For example, you can use the TypeORMStore to save the sessions in your SQL database or the RedisStore to save them in a redis cache.

These session stores are services and can therefore be injected into your controllers and services as such:

export class AuthController {
@dependency
store: TypeORMStore;
@Post('/login')
// ...
login() {
// ...
const store = this.store;
}
}

Create the Session and Get the Token (Log In)

Sessions are created using the method createAndSaveSessionFromUser of the session store. It takes one parameter: an object that must have an id attribute (the user id). At login time, the user is usually retrieved upstream when checking credentials.

const session = await store.createAndSaveSessionFromUser(user);
// Alternatively, you can also call the `createAndSaveSession` method as follows:
const session = await store.createAndSaveSession({ userId: user.id });

The session token then can be read with the method getToken() to send it back to the client. This token identifies the session.

const token = session.getToken();

Use the Session Token to Retrieve the Session

On each subsequent request, the client must send this token in order to retrieve the session and authenticate the user. It must be included in the Authorization header using the bearer scheme (unless you use cookies, see section below).

Authorization: Bearer my-session-token

The hooks @TokenRequired and @TokenOptional will then check the token and retrieve the associated session and user.

import { Get, HttpResponseOK, TokenRequired } from '@foal/core';
import { TypeORMStore } from '@foal/typeorm';
@TokenRequired({ store: TypeORMStore })
class ApiController {
@Get('/products')
readProducts(ctx) {
// ctx.user and ctx.session are defined.
return new HttpResponseOK();
}
}

If the header Authorization is not found or does not use the bearer scheme, the hook @TokenRequired returns an error 400 - BAD REQUEST. The @TokenOptional hook does nothing.

If the token is present and not valid or if the associated session has expired, both hooks return an error 401 - UNAUTHORIZED.

In other cases, the hooks retrieve the session from the store and assign it to the Context.session property. As for the session user ID, it is assigned to Context.user.

If you want the ctx.user to be an object or an instance of the User class, you must pass to the hook user option a function whose signature is:

(id: string|number) => Promise<any|undefined>

The hooks will assign the value it returns to ctx.user.

For example, you can use the fetchUser function to retrieve the user from the database:

import { Get, HttpResponseOK, TokenRequired } from '@foal/core';
import { fetchUser, TypeORMStore } from '@foal/typeorm';
import { User } from '../entities';
@TokenRequired({
store: TypeORMStore,
user: fetchUser(User)
})
class ApiController {
@Get('/products')
readProducts(ctx) {
// ctx.user is an instance of User
return new HttpResponseOK();
}
}

Note: The hooks @TokenRequired and @TokenOptional are responsible for extending the session life each time a request is received.

Alternatively, you can also manually verify a session token and read its associated session. The code below shows how to do so.

const token = // ...
const sessionID = Session.verifyTokenAndGetId(token);
if (!sessionID) {
throw new Error('Invalid session token.');
}
const session = await store.read(sessionID);
if (!session) {
throw new Error('Session does not exist or has expired.')
}
const userId = session.get('userId');

Destroy the Session (Log Out)

Sessions are can be destroyed (i.e users can be logged out) using the destroy method of the session store.

import { Context, dependency, HttpResponseNoContent, TokenRequired, Session } from '@foal/core';
import { TypeORMStore } from '@foal/typeorm';
export class AuthController {
@dependency
store: TypeORMStore;
@Post('/logout')
@TokenRequired({ store: TypeORMStore, extendLifeTimeOrUpdate: false })
async logout(ctx: Context<any, Session>) {
await this.store.destroy(ctx.session.sessionID);
return new HttpResponseNoContent();
}
}

Usage with Cookies

Be aware that if you use cookies, your application must provide a CSRF defense.

FoalTS sessions can also be used with cookies. The hook cookie option and the setSessionCookie and removeSessionCookie funtions are dedicated to this use.

import { setSessionCookie, removeSessionCookie } from '@foal/core';
export class AuthController {
@Post('/login')
// ...
login() {
// ...
const response = new HttpResponseOK();
setSessionCookie(response, session.getToken());
return response;
}
@Post('/logout')
// ...
logout() {
// ...
const response = new HttpResponseOK();
removeSessionCookie(response);
return response;
}
}
@TokenRequired({ store: MyStore, cookie: true })
export class ApiController {
}

The cookie default directives can be override in the configuration.

Example with config/default.yml

settings:
session:
cookie:
name: xxx
domain: example.com
httpOnly: false # default: true
path: /foo # default: /
sameSite: lax
secure: true

Instead of having 400 and 401 HTTP errors, you can also define redirections.

@TokenRequired({
store: TypeORMStore,
cookie: true;
redirectTo: '/login'
})
export class PageController {
// ...
}

Update the Session Content

When receiving an HTTP request, the hooks @TokenRequired and @TokenOptional convert the session token (if it exists and is valid) into a Session instance retrieved from the session store. This object is assigned to the Context.session property and is accessible in the remaining hooks and in the controller method.

You can access and modify the session content with the set and get methods.

import { Context, HttpResponseNoContent, Post, Session, TokenRequired } from '@foal/core';
@TokenRequired(/* ... */)
export class ApiController {
@Post('/subscribe')
purchase(ctx: Context<any, Session>) {
const plan = ctx.session.get<string>('plan', 'free');
// ...
}
@Post('/choose-premium-plan')
addToCart(ctx: Context<any, Session>) {
ctx.session.set('plan', 'premium');
return new HttpResponseNoContent();
}
}

Session Expiration Timeouts

Session states are by definition temporary. They have two expiration timeouts.

The first one is the inactivity (or idle) timeout. If the session is not updated or its lifetime is not extended, the session is destroyed. The @TokenRequired and @TokenOptional take care of extending the session lifetime on each request. Its default value is 15 minutes.

The second is the absolute timeout. Whatever the activity is, the session will expire after a fixed period of time. The default value is one week.

These values can be override with the configuration keys settings.session.expirationTimeouts.inactivity and settings.session.expirationTimeouts.absolute. The time periods must be specified in seconds.

Example with config/default.yml

settings:
session:
secret: xxx
expirationTimeouts:
absolute: 2592000 # 30 days
inactivity: 1800 # 30 min

Example with .env

SETTINGS_SESSION_EXPIRATION_TIMEOUTS_ABSOLUTE=2592000
SETTINGS_SESSION_EXPIRATION_TIMEOUTS_INACTIVITY=1800

Revoking Sessions

Sessions can be revoked (i.e. destroyed) using the methods destroy and clear of the session stores. The examples below show how to use these methods in shell scripts.

Revoking One Session

Create a new file named src/scripts/revoke-session.ts.

import { createService, Session } from '@foal/core';
import { TypeORMStore } from '@foal/typeorm';
import { createConnection } from 'typeorm';
export const schema = {
type: 'object',
properties: {
sessionID: { type: 'string' },
token: { type: 'string' },
}
}
export async function main(args) {
if (!args.sessionID && !args.token) {
console.error('You must provide the session token or session ID.');
return;
}
await createConnection();
if (!args.sessionID) {
const sessionID = Session.verifyTokenAndGetId(args.token);
if (!sessionID) {
console.error('Invalid session token');
return;
}
args.sessionID = sessionID;
}
const store = createService(TypeORMStore); // OR MongoDBStore, RedisStore, etc
await store.destroy(args.sessionID);
}

Build the script.

npm run build:scripts

Run the script.

foal run revoke-session token="xxx.yyy"
foal run revoke-session sessionID="xxx"

Revoking All Sessions

Create a new file named src/scripts/revoke-all-sessions.ts.

import { createService } from '@foal/core';
import { TypeORMStore } from '@foal/typeorm';
import { createConnection } from 'typeorm';
export async function main() {
await createConnection();
const store = createService(TypeORMStore); // OR MongoDBStore, RedisStore, etc
await store.clear();
}

Build the script.

npm run build:scripts

Run the script.

foal run revoke-all-sessions

Session Stores

FoalTS currently offers three built-in session stores: TypeORMStore, MongoDBStore RedisStore. Others will come in the future. If you need a specific one, you can submit a Github issue or even create your own store (see section below).

TypeORMStore (SQL Databases: Postgres, MySQL, SQLite, etc)

npm install typeorm @foal/typeorm

This store uses the default TypeORM connection which is usually specified in ormconfig.{json|yml|js}. This means that session states are saved in your SQL database (using the table foal_session).

Due to the nature of SQL databases, not all expired sessions are deleted by default (we cannot define a time period after which a SQL row must be deleted). However, the session store provides us with a cleanUpExpiredSessions function to manually delete all expired sessions. It is recommended to periodically call this method using, for example, a shell script.

src/scripts/clean-up-expired-sessions.ts

import { createService } from '@foal/core';
import { TypeORMStore } from '@foal/typeorm';
import { createConnection } from 'typeorm';
export async function main() {
await createConnection();
const store = createService(TypeORMStore);
await store.cleanUpExpiredSessions();
}

Build the script.

npm run build:scripts

Run the script.

foal run clean-up-expired-sessions

RedisStore

npm install @foal/redis

In order to use this store, you must provide the redis URI in either:

  • a configuration file

    Example with config/default.yml

    redis:
    uri: 'redis://localhost:6379'
  • or in a .env file or in an environment variable:

    REDIS_URI=redis://localhost:6379

MongoDBStore

npm install @foal/mongodb

This store saves your session states in a MongoDB database (using the collection foalSessions). In order to use it, you must provide the MongoDB URI in either:

  • a configuration file

    Example with config/default.yml

    mongodb:
    uri: 'mongodb://localhost:27017'
  • or in a .env file or in an environment variable:

    MONGODB_URI=mongodb://localhost:27017

Due to the nature of MongoDB databases, not all expired sessions are deleted by default (we cannot define a time period after which a document must be deleted). However, the session store provides us with a cleanUpExpiredSessions function to manually delete all expired sessions. It is recommended to periodically call this method using, for example, a shell script.

src/scripts/clean-up-expired-sessions.ts

import { createService } from '@foal/core';
import { MongoDBStore } from '@foal/mongodb';
export async function main() {
const store = createService(MongoDBStore);
await store.cleanUpExpiredSessions();
}

Build the script.

npm run build:scripts

Run the script.

foal run clean-up-expired-sessions

Custom Store

If necessary, you can also create your own session store. This one must inherit the abstract class SessionStore.

class CustomSessionStore extends SessionStore {
createAndSaveSession(sessionContent: object, options?: SessionOptions | undefined): Promise<Session> {
throw new Error('Method not implemented.');
}
update(session: Session): Promise<void> {
throw new Error('Method not implemented.');
}
destroy(sessionID: string): Promise<void> {
throw new Error('Method not implemented.');
}
read(sessionID: string): Promise<Session | undefined> {
throw new Error('Method not implemented.');
}
extendLifeTime(sessionID: string): Promise<void> {
throw new Error('Method not implemented.');
}
clear(): Promise<void> {
throw new Error('Method not implemented.');
}
cleanUpExpiredSessions(): Promise<void> {
throw new Error('Method not implemented.');
}
}

Here is the description of each method:

  • createAndSaveSession: Create and save a new session.

    This method MUST call the generateSessionID method to generate the session ID.

    This method MUST call the applySessionOptions method to extend the sessionContent.

  • update: Update and extend the lifetime of a session.

    Depending on the implementation, the internal behavior can be similar to "update" or "upsert".

  • destroy: Delete a session, whether it exists or not.

  • read: Read a session from its ID.

    Return undefined if the session does not exist or has expired.

  • extendLifeTime: Extend the lifetime of a session from its ID. The duration is the inactivity timeout.

    If the session does not exist, the method does not throw an error.

  • clear: Clear all sessions.

  • cleanUpExpiredSessions: Some session stores may need to run periodically background jobs to cleanup expired sessions. This method deletes all expired sessions.