Faroe

Set up a Faroe server

A Faroe server is a server that exposes an action invocation endpoint for Faroe server actions.

This may take up to an hour depending on how comfortable you are with the language and architecture. If you want to evaluate the server first, consider using the prebuilt local server by following the Quick-start guide.

All implementations of interfaces must be thread-safe unless specified otherwise.

Storage

The first step is to define you storage.

It is a key-value store that implements StorageInterface. It should be strongly consistent. Any writes/updates should be immediately visible to all subsequent reads. Beyond this consistency guarantee, there are no other requirements. You may use a traditional persistent database with backups or use a temporary storage like an in-memory store.

The entry value, counter, and optionally the expiration timestamp should be stored under each key. The counter is used to prevent race conditions. The expiration is just a hint. You may delete entries past its expiration to free up storage.

type StorageInterface interface {
	Get(key string) ([]byte, int32, error)
	Add(key string, value []byte, expiresAt time.Time) error
	Update(key string, value []byte, expiresAt time.Time, counter int32) error
	Delete(key string) error
}

Example implementations are available:

User store

Create a user store that implements an UserStoreInterface. This allows Faroe to interact with your existing user data.

type UserStoreInterface interface {
	CreateUser(emailAddress string, passwordHash []byte, passwordHashAlgorithmId string, passwordSalt []byte) (UserStruct, error)
	GetUser(userId string) (UserStruct, error)
	GetUserByEmailAddress(emailAddress string) (UserStruct, error)
	UpdateUserEmailAddress(userId string, emailAddress string, userEmailAddressCounter int32) error
	UpdateUserPasswordHash(userId string, passwordHash []byte, passwordHashAlgorithmId string, passwordSalt []byte, userPasswordHashCounter int32) error
	IncrementUserSessionsCounter(userId string, userSessionsCounter int32) error
	DeleteUser(userId string) error
}

Example implementations are available:

Alternatively, you may create a separate user server to handle database operations for your users in a dedicated server. To connect to your user server, use NewUserServerClient() to create UserServerStruct, which implements UserStoreInterface. NewUserServerClient() takes an ActionInvocationEndpointClientInterface, which is used to send action invocation requests to the user server's action invocation endpoint.

userStore := faroe.NewUserServerClient(actionInvocationEndpointClient)
type ActionInvocationEndpointClientInterface interface {
	SendActionInvocationEndpointRequest(body string) (string, error)
}

Because Faroe doesn't prescribe the authentication method used for action invocation endpoints, this is where you implement whatever authentication mechanism your user action endpoint uses.

User password hash algorithm

The user password hash algorithm implements PasswordHashAlgorithmInterface. The ID is a unique identifier for the algorithm. We recommend including the parameters alongside the algorithm name like argon2id.65536.3.1.32.

We recommend using Argon2id with 64MiB of memory, 3 iterations, and 1 degree of parallelism. An example implementation for Argon2id is available.

type PasswordHashAlgorithmInterface interface {
	Id() string
	SaltSize() int
	Hash(secret string, salt []byte) ([]byte, error)
}

User password reset temporary password hashing

The user password reset temporary password hash algorithm also implements PasswordHashAlgorithmInterface.

Temporary passwords used in user password resets are generated by the server. Because they are much stronger than user-defined passwords, you may use a weaker hashing algorithm than for user passwords. That said, you should still treat them as passwords and use hashing algorithms intended for passwords. We recommend Argon2id with 16MiB of memory, 3 iterations, and 1 degree of parallelism.

Action error logger

The LogActionError() method of ActionErrorLoggerInterface will be used to log all internal errors.

type ActionErrorLoggerInterface interface {
	LogActionError(timestamp time.Time, message string, actionInvocationId string, action string)
}

Email sender

The email sender is an implementation of EmailSenderInterface.

type EmailSenderInterface interface {
	SendSignupEmailAddressVerificationCode(emailAddress string, emailAddressVerificationCode string) error
	SendUserEmailAddressUpdateEmailVerificationCode(emailAddress string, displayName string, emailAddressVerificationCode string) error
	SendUserPasswordResetTemporaryPassword(emailAddress string, displayName string, temporaryPassword string) error
	SendUserSignedInNotification(emailAddress string, displayName string, timestamp time.Time) error
	SendUserPasswordUpdatedNotification(emailAddress string, displayName string, timestamp time.Time) error
	SendUserEmailAddressUpdatedNotification(emailAddress string, displayName string, newEmailAddress string, timestamp time.Time) error
}

Storage namespace

Storage entry keys are not globally namespaced. For example, an entry in the rate limit storage can have a same key as an entry in the cache. If you use a shared storage, make sure to prefix the key with NAME. (note the period) like main. and rate_limit..

Create server

Create a new ServerStruct with NewServer().

You can provide multiple user password hashing algorithms if your users use different algorithms. The first one will be used for hashing new passwords.

maxConcurrentPasswordHashingProcesses is the number of maximum CPU threads you want to dedicate to hashing passwords (user passwords and temporary passwords). Password hashing is expensive and will block the thread for the duration of the process.

server := faroe.NewServer(
	storage,
	userStore,
	errorLogger,
	[]faroe.PasswordHashAlgorithmInterface{userPasswordHashAlgorithm},
	temporaryPasswordHashAlgorithm,
	maxConcurrentPasswordHashingProcesses,
	faroe.RealClock,
	faroe.AllowAllEmailAddresses,
	emailSender,
	faroe.SessionConfigStruct{
		InactivityTimeout:     30 * 24 * time.Hour,
		ActivityCheckInterval: time.Minute,
		CacheExpiration:       time.Minute,
	},
)

Handle action invocation endpoint requests with ServerStruct.ResolveActionInvocationEndpointRequestWithBlocklist(). It takes an action invocation endpoint request body and returns the response body.

import (
    "io"
	"net/http"

    "github.com/faroedev/faroe"
)

func handleRequest(w http.ResponseWriter, r *http.Request) {
    bodyBytes, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	resultJSON, err := server.ResolveActionInvocationEndpointRequestWithBlocklist(string(bodyBytes), nil)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.Write([]byte(resultJSON))
	return
}