Version: 2.0.0 (latest)

Session Tokens

You are reading the documentation for version 2 of FoalTS. Instructions for upgrading to this version are available here. The 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 that can be 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 (or starts visiting the website) 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.

The Basics#

Choosing a session store#

To begin, you must first specify where the session states 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.

To do so, the package name of the store must be provided with the configuration key settings.session.store.

settings:
session:
store: "@foal/typeorm"

TypeORMStore#

npm install typeorm @foal/typeorm

This store uses the default TypeORM connection whose configuration is usually specified in ormconfig.{json|yml|js}.

Session states are saved in the databasesession table of your SQL database. In order to create it, you need to add and run migrations. For this purpose, you can export the DatabaseSession entity in your user file and execute the following commands.

entities/user.entity.ts

import { DatabaseSession } from '@foal/typeorm';
import { BaseEntity, Entity } from 'typeorm';
@Entity()
export class User extends BaseEntity {
/* ... */
}
export { DatabaseSession }
npm run makemigrations
npm run migrations

Warning: If you use TypeORM store, then your entity IDs must be numbers (not strings).

RedisStore#

npm install @foal/redis

In order to use this store, you must provide the redis URI in the configuration.

settings:
session:
store: "@foal/redis"
redis:
uri: 'redis://localhost:6379'

MongoDBStore#

npm install @foal/mongodb

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

settings:
session:
store: "@foal/mongodb"
mongodb:
uri: 'mongodb://localhost:27017'

Usage with the Authorization header#

This section explains how to use sessions with a bearer token and the Authorization header. See the section below to see how to use them with cookies.

The mechanism is as follows:

  1. Upon login, create the session and assign it to ctx.session. Then return the session token in the response.
  2. On subsequent requests, send the token in the Authorization header with this scheme: Authorization: Bearer <token>.
import { Context, createSession, dependency, Get, HttpResponseOK, Post, Store, UseSessions } from '@foal/core';
@UseSessions()
export class ApiController {
@dependency
store: Store;
@Post('/login')
async login(ctx: Context) {
// Check the user credentials...
ctx.session = await createSession(this.store);
// See the "authentication" section below
// to see how to associate a user to the session.
return new HttpResponseOK({
token: ctx.session.getToken()
});
}
@Get('/products')
readProducts(ctx: Context) {
// If the request has an Authorization header with a valid token
// then ctx.session is defined.
return new HttpResponseOK([]);
}
}

If the Authorization header does not use the bearer scheme or if the token is invalid or expired, then the hook returns a 400 or 401 error.

If you want to make sure that ctx.session is set and get a 400 error if no Authorization header is provided, you can use the required option for this.

import { Context, createSession, dependency, Get, HttpResponseOK, Post, Store, UseSessions } from '@foal/core';
export class ApiController {
@dependency
store: Store;
@Post('/login')
@UseSessions()
async login(ctx: Context) {
// Check the user credentials...
ctx.session = await createSession(this.store);
// See the "authentication" section below
// to see how to associate a user to the session.
return new HttpResponseOK({
token: ctx.session.getToken()
});
}
@Get('/products')
@UseSessions({ required: true })
readProducts(ctx: Context) {
// ctx.session is defined.
return new HttpResponseOK([]);
}
}

Usage with cookies#

This section explains how to use sessions with cookies. See the section above to see how to use them with a bearer token and the Authorization header.

--

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

When using the @UseSessions hook with the cookie option, FoalTS makes sure that ctx.session is always set and takes care of managing the session token on the client (using a cookie).

import { Context, dependency, Get, HttpResponseOK, Post, Store, UseSessions } from '@foal/core';
@UseSessions({ cookie: true })
export class ApiController {
@dependency
store: Store;
@Post('/login')
login(ctx: Context) {
// Check the user credentials...
// See the "authentication" section below
// to see how to associate a user to the session.
return new HttpResponseOK();
}
@Get('/products')
readProducts(ctx: Context) {
// ctx.session is defined.
return new HttpResponseOK([]);
}
}

If the session has expired, the hook returns a 401 error. If you want to redirect the user to the login page, you can use the redirectTo option to do so.

@UseSessions({
cookie: true,
redirectTo: '/login'
})
export class ApiController {
@Get('/products')
readProducts(ctx: Context) {
// ctx.session is defined.
return new HttpResponseOK([]);
}
}

Adding authentication and access control#

This section explains how to associate a specific user to a session and how to use ctx.user.

Sessions can be used to authenticate users. To do this, you can use the Session.setUser method and the fetchUser function.

import { Context, createSession, dependency, Get, HttpResponseOK, Post, Store, UseSessions } from '@foal/core';
import { fetchUser } from '@foal/typeorm';
import { User } from '../entities';
@UseSessions({
// If the session is attached to a user,
// then use "fetchUser" to retrieve the user from the database
// and assign it to ctx.user
user: fetchUser(User)
})
export class ApiController {
@dependency
store: Store;
@Post('/login')
async login(ctx: Context) {
// Check the user credentials...
// const user = ...
ctx.session = await createSession(this.store);
// Attach the user to the session.
ctx.session.setUser(user);
return new HttpResponseOK({
token: ctx.session.getToken()
});
}
@Get('/products')
readProducts(ctx: Context) {
// If the ctx.session is defined and the session is attached to a user
// then ctx.user is an instance of User. Otherwise it is undefined.
return new HttpResponseOK([]);
}
}

If you want to restrict certain routes to authenticated users, you can use the @UserRequired hook for this.

import { Context, Get, HttpResponseOK, UserRequired, UseSessions } from '@foal/core';
import { fetchUser } from '@foal/typeorm';
import { User } from '../entities';
@UseSessions({
user: fetchUser(User)
})
export class ApiController {
@Get('/products')
@UserRequired()
readProducts(ctx: Context) {
// ctx.user is defined.
return new HttpResponseOK([]);
}
}

If the user is not authenticated, the hook returns a 401 error. If you want to redirect the user to the login page, you can use the redirectTo option to do so.

import { Context, Get, HttpResponseOK, UserRequired, UseSessions } from '@foal/core';
import { fetchUser } from '@foal/typeorm';
import { User } from '../entities';
@UseSessions({
redirectTo: '/login',
user: fetchUser(User)
})
export class ApiController {
@Get('/products')
@UserRequired({
redirectTo: '/login'
})
readProducts(ctx: Context) {
// ctx.user is defined.
return new HttpResponseOK([]);
}
}

Destroying the session#

Sessions can be destroyed (i.e users can be logged out) using their destroy method.

import { Context, HttpResponseNoContent, Post, UseSessions } from '@foal/core';
export class AuthController {
@Post('/logout')
@UseSessions()
async logout(ctx: Context) {
if (ctx.session) {
await ctx.session.destroy();
}
return new HttpResponseNoContent();
}
}

Save and Read Content#

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

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

Flash Content#

Sometimes we may wish to store items in the session only for the next request.

For example, when users enter incorrect credentials, they are redirected to the login page, and this time we may want to render the page with a specific message that says "Incorrect email or password". If the user refreshes the page, the message then disappears.

This can be done with flash content. The data will only be available on the next request.

ctx.session.set('error', 'Incorrect email or password', { flash: true });

Security#

Session Expiration Timeouts#

Session states has two expiration timeouts.

TimeoutDescriptionDefault value
Inactivity (or idle) timeoutPeriod of inactivity after which the session expires.15 minutes
Absolute timeoutPeriod after which the session expires, regardless of its activity.1 week

If needed, the default values can be override in the configuration. The timeouts must be provided in seconds.

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

Revoking Sessions#

Revoking One Session#

foal g script revoke-session

Open scripts/revoke-session.ts and update its content.

import { createService, readSession, Store } from '@foal/core';
import { createConnection } from 'typeorm';
export const schema = {
type: 'object',
properties: {
token: { type: 'string' },
},
required: [ 'token' ]
}
export async function main({ token }: { token: string }) {
await createConnection();
const store = createService(Store);
await store.boot();
const session = await readSession(store, token);
if (session) {
await session.destroy();
}
}

Build the script.

npm run build

Run the script.

foal run revoke-session token="lfdkszjanjiznr"

Revoking All Sessions#

foal g script revoke-all-sessions

Open scripts/revoke-all-sessions.ts and update its content.

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

Build the script.

npm run build

Run the script.

foal run revoke-all-sessions

Query All Sessions of a User#

This feature is only available with the TypeORM store.

const user = { id: 1 };
const ids = await store.getSessionIDsOf(user);

Query All Connected Users#

This feature is only available with the TypeORM store.

const ids = await store.getAuthenticatedUserIds();

Force the Disconnection of a User#

This feature is only available with the TypeORM store.

const user = { id: 1 };
await store.destroyAllSessionsOf(user);

Re-generate the Session ID#

When a user logs in or change their password, it is a good practice to regenerate the session ID. This can be done with the regenerateID method.

ctx.session.regenerateID();

Advanced#

Specify the Store Locally#

By default, the @UseSessions hook and the Store service retrieve the store to use from the configuration. This behavior can be override by importing the store directly into the code.

import { Context, createSession, dependency, Get, HttpResponseOK, Post, UseSessions } from '@foal/core';
import { RedisStore } from '@foal/redis';
@UseSessions({ store: RedisStore })
export class ApiController {
@dependency
store: RedisStore;
@Post('/login')
async login(ctx: Context) {
// Check the user credentials...
ctx.session = await createSession(this.store);
return new HttpResponseOK({
token: ctx.session.getToken()
});
}
@Get('/products')
readProducts(ctx: Context) {
return new HttpResponseOK([]);
}
}

Cleanup Expired Sessions#

By default, FoalTS removes expired sessions in TypeORMStore and MongoDBStore every 50 requests on average. This can be changed with this configuration key:

settings:
session:
garbageCollector:
periodicity: 25

Implement a Custom Store#

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

To use it, your can import it directly in your code (see the section Specify the Store Locally) or use a relative path in the configuration. In this case, the class must be exported with the name ConcreteSessionStore.

import { SessionState, SessionStore } from '@foal/core';
class CustomSessionStore extends SessionStore {
save(state: SessionState, maxInactivity: number): Promise<void> {
// ...
}
read(id: string): Promise<SessionState | null> {
// ...
}
update(state: SessionState, maxInactivity: number): Promise<void> {
// ...
}
destroy(id: string): Promise<void> {
// ...
}
clear(): Promise<void> {
// ...
}
cleanUpExpiredSessions(maxInactivity: number, maxLifeTime: number): Promise<void> {
// ...
}
}
MethodDescription
saveSaves the session for the first time. If a session already exists with the given ID, a SessionAlreadyExists error MUST be thrown.
readReads a session. If the session does not exist, the value null MUST be returned.
updateUpdates and extends the lifetime of a session. If the session no longer exists (i.e. has expired or been destroyed), the session MUST still be saved.
destroyDeletes a session. If the session does not exist, NO error MUST be thrown.
clearClears all sessions.
cleanUpExpiredSessionsSome session stores may need to run periodically background jobs to cleanup expired sessions. This method deletes all expired sessions. If the store manages a cache database, then this method can remain empty but it must NOT throw an error.

Session stores do not manipulate Session instances directly. Instead, they use SessionState objects.

interface SessionState {
// 44-characters long
id: string;
userId: string|number|null;
content: { [key: string]: any };
flash: { [key: string]: any };
// 4-bytes long (min: 0, max: 2147483647)
updatedAt: number;
// 4-bytes long (min: 0, max: 2147483647)
createdAt: number;
}

Create a fetchUser function#

The function fetchUser from the package @foal/typeorm takes an @Entity() class as parameter and returns a function with this signature:

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

If the ID matches a user, then an instance of the class is returned. Otherwise, the function returns undefined.

If needed you can implement your own fetchUser function with this exact signature.

Usage with Cookies#

Do not Auto-Create the Session#

By default, when the cookie option is set to true, the @UseSessions hook automatically creates a session if it does not already exist. This can be disabled with the create option.

import { Context, createSession, dependency, HttpResponseOK, Post, Store, UseSessions } from '@foal/core';
export class ApiController {
@dependency
store: Store;
@Post('/login')
@UseSessions({ cookie: true, create: false })
async login(ctx: Context) {
// Check the credentials...
// ctx.session is potentially undefined
if (!ctx.session) {
ctx.session = await createSession(this.store);
}
return new HttpResponseOK();
}
}

Override the Cookie Options#

The default session cookie directives can be overridden in the configuration as follows:

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

Require the Cookie#

In rare situations, you may want to return a 400 error or redirect the user if no session cookie already exists on the client. If so, you can use the required option to do so.

import { Get, HttpResponseOK, UseSessions } from '@foal/core';
export class ApiController {
@Get('/products')
@UseSessions({ cookie: true, required: true })
readProducts() {
return new HttpResponseOK([]);
}
}

Read a Session From a Token#

The @UseSessions hook automatically retrieves the session state on each request. If you need to manually read a session (for example in a shell script), you can do it with the readSession function.

import { readSession } from '@foal/core';
const session = await readSession(store, token);
if (!session) {
throw new Error('Session does not exist or has expired.')
}
const foo = session.get('foo');

Save Manually a Session#

The @UseSessions hook automatically saves the session state on each request. If you need to manually save a session (for example in a shell script), you can do it with the commit method.

await session.commit();