Session handling
Since Compas v0.0.172 Compas provides a session store based on JSON Web Tokens and provides session transport support via authorizatoin headers.
JSON Web Token based
TIP
Requires @compas/store
to be installed
Let's see what Compas brings to the table for sessions. Session in Compas allow you to persist a data object in PostgreSQL so you can for example keep track if a user is logged in, and add the user id to that object so you can use it when necessary. Although JWT's are often used in combination with claim's, for example which permissions a user has and what it's ID is, Compas doesn't support it this way. The tokens only contain an identifier and expiration time, which then can be used in the backend to find the underlying session object. This way you have all the controls on the backend and can create specific routes to expose things like which permissions the user has.
The tokens are always created in pairs: an access token and refresh token. The access token is often short-lived, like 10 - 15 minutes stored in memory, and is used to authenticate with api calls. The refresh token on the other hand has a longer time to live, often limited based on the expected attack vectors like if the application is used in public libraries for example, and can be used to fetch a new token pair.
Let's take a look at the provided utilities by @compas/store
. Most functions will return a Either<X, AppError>
, meaning that callers should handle returnValue.error
and handle these appropriately.
Session store
Let's look at the primitives first. These are for creating session tokens, storing information with that session and invalidating it.
sessionStoreSettings
Session store settings is an object expected by various functions, containing the following properties:
accessTokenMaxAgeInSeconds
(number): How long access tokens should be valid. As mentioned, this should be short like 5 to 15 minutes.refreshTokenMaxAgeInSeconds
(number): How long refresh tokens should be validsigningKey
(string,>20 chars): The key used for signing JWT's. By changing this value, all sessions will be invalidated. The value should be handled as a secret and should be generated in a secure random way.
sessionStoreCreate
Creates a new session with the provided sessionData
and returns a token pair.
Example:
import { sessionStoreCreate } from "@compas/store";
const settings = {
accessTokenMaxAgeInSeconds: 5 * 60, // 5 minutes
refreshTokenMaxAgeInSeconds: 24 * 60 * 60, // 24 hours
signingKey: "secure key loaded from secure place",
};
const { error, value: tokenPair } = await sessionStoreCreate(
newEventFromEvent(event),
sql,
settings,
{
user: ".user.id",
...otherProps,
},
);
if (error) {
// handle error
} else {
// send token pair to client
}
Errors:
validator.error
-> thrown whensessionSettings
has validator errorserror.server.internal
-> if for some reason the JWT signing library can't create the access or refresh token.
sessionStoreGet
Get the session object from the accessTokenString
. Note that this returns the full database backed object. The id
is necessary for updating the session object and data
contains the saved object for this session.
Example:
import { sessionStoreGet } from "@compas/store";
const settings = {
accessTokenMaxAgeInSeconds: 5 * 60, // 5 minutes
refreshTokenMaxAgeInSeconds: 24 * 60 * 60, // 24 hours
signingKey: "secure key loaded from secure place",
};
// Get Authorization header, showing the Koa way.
const header = ctx.get("authorization");
if (!header.startsWith("Bearer")) {
// throw invalid header
}
const accessTokenString = header.substring("Bearer ".length);
const { error, value } = await sessionStoreGet(
newEventFromEvent(event),
sql,
settings,
accessTokenString,
);
if (error) {
// handle error
} else {
const { id, data } = value.session;
// id is used for updating the session,
// data contains your saved session data
}
Errors:
validator.error
-> thrown whensessionSettings
has validator errorserror.server.internal
-> if for some reason the JWT validating library can't decode or validate the provided access token.sessionStore.verifyAndDecodeJWT.invalidToken
-> when the token is not issued by this backend, or if it is edited by an unauthorized entity.sessionStore.verifyAndDecodeJWT.expiredToken
-> if theexp
of the token before the current time.sessionStore.get.invalidAcessToken
-> when the decoded access token is not conforming the expected structure.sessionStore.get.invalidSession
-> when no session can be found by the provided access token.sessionStore.get.revokedToken
-> when the token is revoked, via for examplesessionStoreInvalidate
or when a new token pair is issued viasessionStoreRefreshTokens
.
sessionStoreUpdate
Update the data stored for the provided session. This function will check if the object is changed and only do a database call when that is the case.
Example:
import { sessionStoreUpdate } from "@compas/store";
const { error } = await sessionStoreUpdate(newEventFromEvent(event), sql, {
id: "my-session-id",
data: {
new: {
data: "object",
},
},
});
if (error) {
// handle error
}
// Session is saved or wasn't changed
Errors:
sessionStore.update.invalidSession
-> when the provided session has an invalid id or if the data is not a plain JS object.
sessionStoreInvalidate
Revoke all tokens for the provided session.
Example:
import { sessionStoreInvalidate } from "@compas/store";
const { error } = await sessionStoreInvalidate(newEventFromEvent(event), sql, {
id: "my-session-id",
});
if (error) {
// handle error
}
// Session is completely invalidated
Errors:
sessionStore.invalidate.invalidSession
-> the provided session does not contain a valid identifier.
sessionStoreRefreshTokens
Get a new token pair via the provided refresh token. This invalidates the token pair that is used. To prevent refresh token stealing, it detects if the refresh token is already used, and if so does the following two things:
- Invalidates the session
- Creates a
compas.sessionStore.potentialLeakedSession
job, with the digest of session duration and tokens used as the job 'data'.
Example:
import { sessionStoreRefreshTokens } from "@compas/store";
const settings = {
accessTokenMaxAgeInSeconds: 5 * 60, // 5 minutes
refreshTokenMaxAgeInSeconds: 24 * 60 * 60, // 24 hours
signingKey: "secure key loaded from secure place",
};
const { error, value } = await sessionStoreRefreshTokens(
newEventFromEvent(event),
sql,
settings,
ctx.validatedBody.refreshToken,
);
if (error) {
// handle error
} else {
// return token pair to the caller
}
Errors:
validator.error
-> thrown whensessionSettings
has validator errorserror.server.internal
-> if for some reason the JWT validating and signing library can't decode the refresh token, validate the provided refresh token or can't sign the new tokens.sessionStore.verifyAndDecodeJWT.invalidToken
-> when the token is not issued by this backend, or if it is edited by an unauthorized entity.sessionStore.verifyAndDecodeJWT.expiredToken
-> if theexp
of the token before the current time.sessionStore.refreshTokens.invalidRefreshToken
-> when the decoded refresh token is not conforming the expected structure.sessionStore.refreshTokens.invalidSession
-> when no session can be found by the provided refresh token.sessionStore.refreshTokens.revokedToken
-> when the token is revoked, via for examplesessionStoreInvalidate
or when a new token pair is issued viasessionStoreRefreshTokens
.
sessionStoreCleanupExpiredSessions
Cleanup tokens that are expired / revoked longer than 'maxRevokedAgeInDays' days ago. Also removes the session if no tokens exist anymore. Note that when tokens are removed, Compas can't detect refresh token reuse, which hints on session stealing. A good default may be 45 days.
Session transport
Compas also supports ways of transporting the JWT's. For now limited to reading from the HTTP Authorization header.
SessionTransportSettings
An object that is expected by some session transport functions. Most of the properties have sensible defaults and are thus optional, but allow overriding.
sessionStoreSettings
(SessionStoreSettings
): Specify the session store settings, this is passed tosessionStoreGet
and helps decoding and verifying tokens.
The above properties are all required.
enableHeaderTransport
(boolean): Enable or disable using the 'Authorization' header for access token transport. Defaults totrue
.
Transports are checked in the above order, and at least one transport needs to be enabled.
sessionTransportLoadFromContext
Tries to load the session based on the provided context and options.
If enableHeaderTransport
is set, the Authorization
header is read first. The value is expected to be in the Bearer $accessToken
format. When a token is found in the header, it is passed to sessionStoreGet
and the result is returned. If no access token is found, the following steps are taken.
Example:
import { sessionTransportLoadFromContext } from "@compas/store";
import { getApp } from "@compas/server";
const sessionStoreSettings = {
accessTokenMaxAgeInSeconds: 5 * 60, // 5 minutes
refreshTokenMaxAgeInSeconds: 24 * 60 * 60, // 24 hours
signingKey: "secure key loaded from secure place",
};
const sessionTransportSettings = {
sessionStoreSettings, // use defaults
};
const app = getApp();
app.use(async (ctx, next) => {
const { error, value: session } = await sessionTransportLoadFromContext(
newEventFromEvent(ctx.event),
sql,
ctx,
sessionTransportSettings,
);
if (error) {
// handle error
} else {
// return token pair to the caller
}
});
Errors:
- Infers all errors from
sessionStoreGet
error.server.internal
-> if the providedSessionTransportSettings
are invalid.