dillonkearns / elm-pages / Server.Session

You can manage server state with HTTP cookies using this Server.Session API. Server-rendered routes have a Server.Request.Request argument that lets you inspect the incoming HTTP request, and return a response using the Server.Response.Response type.

This API provides a higher-level abstraction for extracting data from the HTTP request, and setting data in the HTTP response. It manages the session through key-value data stored in cookies, and lets you insert, update, and remove values from the Session. It also provides an abstraction for flash session values through withFlash.

Using Sessions

Using these functions, you can store and read session data in cookies to maintain state between requests.

import Server.Session as Session

secrets : BackendTask FatalError (List String)
secrets =
    Env.expect "SESSION_SECRET"
        |> BackendTask.allowFatal
        |> BackendTask.map List.singleton

type alias Data =
    { darkMode : Bool }

data : RouteParams -> Request -> BackendTask FatalError (Response Data ErrorPage)
data routeParams request =
    request
        |> Session.withSession
            { name = "mysession"
            , secrets = secrets
            , options = Nothing
            }
            (\session ->
                let
                    darkMode : Bool
                    darkMode =
                        (session |> Session.get "mode" |> Maybe.withDefault "light")
                            == "dark"
                in
                BackendTask.succeed
                    ( session
                    , Response.render
                        { darkMode = darkMode
                        }
                    )
            )

The elm-pages framework will manage signing these cookies using the secrets : BackendTask FatalError (List String) you pass in. That means that the values you set in your session will be directly visible to anyone who has access to the cookie (so don't directly store sensitive data in your session). Since the session cookie is signed using the secret you provide, the cookie will be invalidated if it is tampered with because it won't match when elm-pages verifies that it has been signed with your secrets. Of course you need to provide secure secrets and treat your secrets with care.

Rotating Secrets

The first String in secrets : BackendTask FatalError (List String) will be used to sign sessions, while the remaining String's will still be used to attempt to "unsign" the cookies. So if you have a single secret:

Session.withSession
    { name = "mysession"
    , secrets =
        BackendTask.map List.singleton
            (Env.expect "SESSION_SECRET2022-09-01")
    , options = Nothing
    }

Then you add a second secret

Session.withSession
    { name = "mysession"
    , secrets =
        BackendTask.map2
            (\newSecret oldSecret -> [ newSecret, oldSecret ])
            (Env.expect "SESSION_SECRET2022-12-01")
            (Env.expect "SESSION_SECRET2022-09-01")
    , options = Nothing
    }

The new secret (2022-12-01) will be used to sign all requests. This API always re-signs using the newest secret in the list whenever a new request comes in (even if the Session key-value pairs are unchanged), so these cookies get "refreshed" with the latest signing secret when a new request comes in.

However, incoming requests with a cookie signed using the old secret (2022-09-01) will still successfully be unsigned because they are still in the rotation (and then subsequently "refreshed" and signed using the new secret).

This allows you to rotate your session secrets (for security purposes). When a secret goes out of the rotation, it will invalidate all cookies signed with that. For example, if we remove our old secret from the rotation:

Session.withSession
    { name = "mysession"
    , secrets =
        BackendTask.map List.singleton
            (Env.expect "SESSION_SECRET2022-12-01")
    , options = Nothing
    }

And then a user makes a request but had a session signed with our old secret (2022-09-01), the session will be invalid (so withSession would parse the session for that request as Nothing). It's standard for cookies to have an expiration date, so there's nothing wrong with an old session expiring (and the browser will eventually delete old cookies), just be aware of that when rotating secrets.

withSession : { name : String, secrets : BackendTask error (List String), options : Maybe Server.SetCookie.Options } -> (Session -> BackendTask error ( Session, Server.Response.Response data errorPage )) -> Server.Request.Request -> BackendTask error (Server.Response.Response data errorPage)

The main function for using sessions. If you need more fine-grained control over cases where a session can't be loaded, see withSessionResult.

withSessionResult : { name : String, secrets : BackendTask error (List String), options : Maybe Server.SetCookie.Options } -> (Result NotLoadedReason Session -> BackendTask error ( Session, Server.Response.Response data errorPage )) -> Server.Request.Request -> BackendTask error (Server.Response.Response data errorPage)

Same as withSession, but gives you an Err with the reason why the Session couldn't be loaded instead of using Session.empty as a default in the cases where there is an error loading the session.

A session won't load if there is no session, or if it cannot be unsigned with your secrets. This could be because the cookie was tampered with or otherwise corrupted, or because the cookie was signed with a secret that is no longer in the rotation.


type NotLoadedReason
    = NoSessionCookie
    | InvalidSessionCookie

withSessionResult will return a Result with this type if it can't load a session.

Creating and Updating Sessions


type Session

Represents a Session with key-value Strings.

Use with withSession to read in the Session, and encode any changes you make to the Session back through cookie storage via the outgoing HTTP response.

empty : Session

An empty Session with no key-value pairs.

get : String -> Session -> Maybe String

Retrieve a String value from the session for the given key (or Nothing if the key is not present).

(session
    |> Session.get "mode"
    |> Maybe.withDefault "light"
)
    == "dark"

insert : String -> String -> Session -> Session

Insert a value under the given key in the Session.

session
    |> Session.insert "mode" "dark"

remove : String -> Session -> Session

Remove a key from the Session.

update : String -> (Maybe String -> Maybe String) -> Session -> Session

Update the Session, given a Maybe String of the current value for the given key, and returning a Maybe String.

If you return Nothing, the key-value pair will be removed from the Session (or left out if it didn't exist in the first place).

session
    |> Session.update "mode"
        (\mode ->
            case mode of
                Just "dark" ->
                    Just "light"

                Just "light" ->
                    Just "dark"

                Nothing ->
                    Just "dark"
        )

withFlash : String -> String -> Session -> Session

Flash session values are values that are only available for the next request.

session
    |> Session.withFlash "message" "Your payment was successful!"