Skip to main content
important

This is a contributors guide and NOT a user guide. Please visit these docs if you are using or evaluating SuperTokens.

Overview

Below is a high level view of the backend SDKs. An SDK can have one or more recipes in them, and may have all or some of the parts that are mentioned below.

Init#

Used to initialise SuperTokens - providing app info, core connection info and a list of recipes to initalise as well.

Initialising means calling init in all the recipes and normalising of user configuration.

API Routing#

The user has to add our API handling logic in their API webserver so that we can handle all API requests with /auth/* (by default).

For example, in Node & express, this can be done by the user adding app.use(supertokens.middleware()) in their app. Here all requests will be intercepted by our middleware which will check for the request route, and if any of the recipes that have been initialised can handle them, then they do, else it gets passed on to the user's APIs.

Recipe Routing#

Each recipe can have a set of APIs that they can handle. For example, the session recipe has:

  • /session/refresh POST
  • /signout POST

When a request comes in the middlware, we first check the rid header in the request to see which recipe it is targetting (based on recipe ID). Then we find that recipe from the recipeList provide by the user, and see if the path and method matches any of the APIs they expose. If yes, we let the recipe handle that API request. Else we pass the request to the user's routes.

In the node SDK, this logic can be found here (the middleware function in that file).

Sometimes a recipe has sub recipes. In this case, the rid of the request would not be a sub recipe's rid, but would be the super recipe's rid. This way, the super recipe would get to handle this request, and can further check its child recipes for if they can handle it or not.

Error routing#

An error can be thrown during an API or during a function call (which can also be called in the user's API).

If it's an unexpected error (See section 1,2,b) then we let the user's error handler handle the error.

If it's an expected error (See section 1,2,a) then we need to handle it to send the right API output. This is where the error routing logic comes into picture.

In order to catch errors from ours and the user's APIs, we must ask them to insert our error handler in a way that even if their APIs throw an error, it goes through us first. For example in node, we ask the users to add app.use(supertokens.errorHandler()) after all their routes. In other frameworks / languages, this approach could be very different.

Then, in order to properly route errors, we need to recognise if an error was generated by the lib or by the user's APIs. In different languages we can approach this in different ways. In Java for example, we can use class names / inheritance to do this. In Javascript, we need to use something else like some random "magic" value as seen here - if an error contains this value, it's by us.

Once we have deduced that this error is by us, we ask each recipe if they generated it, and if yes, we let them handle it. Else we pass it to the user's error handler. This logic can be seen here (errorHandler function in that file).

Integrating with various web frameworks#

We need to expose all the FDI APIs via the SDK. This means, that we have to integrate with as many web frameworks within each langauge as possible.

For example, with NodeJS, we need to work with express, koajs, hapijs, aws lambda etc..

We do this by introducing abstract request / response classes which recipes and routing logic uses for getting the request path, sending a response, reading / setting headers etc.

Then we integrate these web frameworks by extending this abstract class and implementing the various functions as per that web framework.

Recipes#

Init#

Is called during SDK initialisation. Here we:

  • Normalise user config for this recipe
  • Create an instance of the recipe implementation and pass it to the override function (in case the user has given one).
  • Create an instance of the API implementation and pass it to the override function (in case the user has given one).
  • Create instances of any sub recipes that this recipe needs.

Recipe Interface#

A recipe interface is an interface that defines all the "core" functions that this recipe logic / APIs can depend on.

For example, for the emailpassword recipe, we would have at least these three functions:

  • signUp
  • signIn
  • doesEmailExist

You can find the full interface for the emailpassword recipe here.

This recipe would need to use other recipes like the emailverification or the session recipe, and those would have their own recipe interface.

API Interface#

An API interface is an interface that is one to one mapped with the different APIs being exposed by a recipe, here exposed means exposed to the frontend SDKs (FDI). So if a recipe has two APIs, there will be two functions in its API interface, and those APIs will do input validation / extraction and then call their respective API interface functions.

For example, for the emailpassword recipe, we would have at least these functions:

  • emailExistsGET
  • signInPOST
  • signUpPOST

You can find the full interface for the emailpassword recipe here.

This recipe would need to use other recipes like the emailverification or the session recipe, and those would have their own API interface.

Recipe implementation#

A recipe implementation is a class that implements the recipe interface for a recipe. This recipe implementation is the default behaviour of that recipe.

You can find the recipe implementation of the emailpassword recipe here.

Most functions in the recipe implementation usually just query the core service as per the CDI spec and return the result.

API implementation#

An API implementation is a class that implements the API interface for a recipe. This API implementation is the defaul behaviour of the APIs exposed by that recipe.

You can find the api implementation of the emailpassword recipe here.

As seen, these usually just use the underlying recipe implementation instance. The input to the functions are extracted and validated before calling the function.

All the functions also get an input using which they can access the actual request / response (abstract class) so that they can set and read headers / cookies if their logic requires that (For example, see the session recipe's API implementation)

Overriding the recipe implementation#

We allow users to override the default recipe implementation so that they can customise the behaviour of the SDK as per their auth requirements.

They can do so by providing the implementation of the following function during the init call for that recipe:

// an example of how they would override the 
// signUp function of the emailpassword recipe impl
supertokens.init({
recipeList: [
EmailPassword.init({
override {
functions: (originalImplementation: RecipeInterface): RecipeInterface => {
return {
...originalImplementation,
signUp: (input) => {
// some custom logic
// OR
return originalImplementation.signUp(input);
}
}
};
}
});
]
});

Here, the originalImplementation is an instance of the recipe implementation of that recipe. However, it is typed as the recipe interface. This allows composability of recipe interfaces - for example, one implementation of the recipe interface can add feature X, and another can add feature Y. The user can use both and get X and Y!

Overriding the API implementation#

We allow users to override the default API implementation so that they can customise the behaviour of the APIs as per their auth requirements.

They can do so by providing the implementation of the following function during the init call for that recipe:

// an example of how they would override the 
// signUp function of the emailpassword recipe impl
supertokens.init({
recipeList: [
EmailPassword.init({
override {
apis: (originalImplementation: APIInterface): APIInterface => {
return {
...originalImplementation,
signUpPOST: (input) => {
// some custom logic
// OR
return originalImplementation.signUpPOST(input);
}
}
};
}
});
]
});

Here, the originalImplementation is an instance of the API implementation of that recipe. However, it is typed as the API interface. This allows composability of API interfaces - for example, one implementation of the recipe interface can add feature X, and another can add feature Y. The user can use both and get X and Y!

Sometimes, one may want to override an API such that they want to declare the route themselves and handle the API completely. They can do this by setting a function to undefined (in NodeJS for example). So in the above example, they can do that by setting signUpPOST: undefined and then creating their own route to handle the API request.

User facing functions#

These are functions that are meant for users to use (in their API or elsewhere). They usually reside in the index.ts file of a recipe or the whole lib.

Examples of functions are:

  • Session.createNewSession(...)
  • EmailPassword.signIn(...)

These functions usually just call the recipe's underlying (one or several) recipe implementation's function. They can even use multiple recipes in them. For example, the EmailPassword.deleteUser(...) function would use the Session and the EmailPassword recipe implementations.

Some of them, like the can use a recipe's sub recipe. This is done because from the user's point of view, they are not directly aware of the subrecipe since they only did EmailPassword.init.

There are also some functions that are not tied to a recipe like:

  • supertokens.init
  • supertokens.middleware

Querying the core#

For our recipes to work, they need to query the core which exposes the CDI APIs. This querying happens in a round robin fashion and if a failure occurs, the querier automatically retries the next core location (if provided).

For us to know where the cores are located, the user passes the connectionUri and its apiKey when calling supertokens.init.

When querying the core, we need to know which CDI version to add to the header. This is done by querying the core (/apiversion GET), and finding the maximum value in the returned array which the backend SDK is also compatible with. This value is then stored in memory for all requests.

You can find an an implementation of the querier here.

Export paths#

The import statements we expose are very important.

For example, the place where all the ts files are built in the supertokens-node SDK is /lib/build/*. Therefore, by default, the user would have to use import ... from "supertokens-node/lib/build/recipe/..." which is ugly.

So we have extra files in the lib which allow users to import like import ... from "supertokens-node/recipe/...". As an example, you can see how this is achieved here. The files in this folder don't have any recipe logic - they simply import / export the actual recipe index.ts files.