NAME

PAGI::Middleware::Session - Session management middleware with pluggable State/Store

SYNOPSIS

use PAGI::Middleware::Builder;

# Default (cookie-based, in-memory store)
my $app = builder {
    enable 'Session', secret => 'your-secret-key';
    $my_app;
};

# Explicit state and store
use PAGI::Middleware::Session::State::Header;
use PAGI::Middleware::Session::Store::Memory;

my $app = builder {
    enable 'Session',
        secret => 'your-secret-key',
        state  => PAGI::Middleware::Session::State::Header->new(
            header_name => 'X-Session-ID',
        ),
        store  => PAGI::Middleware::Session::Store::Memory->new;
    $my_app;
};

# In your app:
async sub app {
    my ($scope, $receive, $send) = @_;

    # Raw hashref access
    my $session = $scope->{'pagi.session'};
    $session->{user_id} = 123;

    # Or use the PAGI::Session helper
    use PAGI::Session;
    my $s = PAGI::Session->new($scope);
    $s->set('user_id', 123);
    my $uid = $s->get('user_id');  # dies if key missing
}

DESCRIPTION

PAGI::Middleware::Session provides server-side session management with a pluggable architecture for session ID transport (State) and session data storage (Store).

The State layer controls how the session ID travels between client and server (cookies, headers, bearer tokens, or custom logic). The Store layer controls where session data is persisted (memory, Redis, database).

By default, sessions use cookie-based IDs and in-memory storage.

Warning: The default in-memory store is suitable for development and single-process deployments only. Sessions are not shared between workers and are lost on restart. For production multi-worker deployments, provide a store object backed by Redis, a database, or another shared storage.

CONFIGURATION

STATE CLASSES

State classes control how the session ID is extracted from requests and injected into responses. All implement the PAGI::Middleware::Session::State interface.

PAGI::Middleware::Session::State::Cookie

Default. Reads the session ID from a request cookie and sets it via Set-Cookie on the response. Suitable for browser-based web applications.

PAGI::Middleware::Session::State::Header

Reads the session ID from a custom HTTP header. Requires header_name; accepts an optional pattern regex with a capture group. Injection is a no-op (the client manages header-based transport).

PAGI::Middleware::Session::State::Header->new(
    header_name => 'X-Session-ID',
);
PAGI::Middleware::Session::State::Bearer

Convenience subclass of State::Header that reads an opaque bearer token from the Authorization: Bearer <token> header. Intended for opaque session tokens, not JWTs. For JWT authentication, use PAGI::Middleware::Auth::Bearer instead.

PAGI::Middleware::Session::State::Bearer->new();
PAGI::Middleware::Session::State::Callback

Custom session ID transport using coderefs. Requires an extract coderef; accepts an optional inject coderef (defaults to no-op).

PAGI::Middleware::Session::State::Callback->new(
    extract => sub { my ($scope) = @_; ... },
    inject  => sub { my ($headers, $id, $options) = @_; ... },
);

STORE CLASSES

Store classes control where session data is persisted. All implement the PAGI::Middleware::Session::Store interface; methods return Future objects for async compatibility.

PAGI::Middleware::Session::Store::Memory

Default. In-memory hash storage. Not shared across workers or restarts. Suitable for development and testing only.

External stores

Redis, database, and other shared stores are available as separate CPAN distributions. Any object implementing get($id), set($id, $data), and delete($id) (returning Futures) can be used.

PAGI::Session HELPER

PAGI::Session is a standalone helper object that wraps the session data with a clean accessor interface.

use PAGI::Session;
my $session = PAGI::Session->new($scope);

Key methods:

  • get($key) - Dies if the key does not exist (catches typos).

  • get($key, $default) - Returns $default for missing keys.

  • set($key, $value) - Sets a session value.

  • exists($key) - Checks key existence.

  • delete($key) - Removes a key.

  • keys - Lists user keys (excludes internal _-prefixed keys).

  • id - Returns the session ID.

  • regenerate - Requests session ID regeneration on next response.

  • destroy - Marks the session for deletion.

The helper stores a reference to the underlying hash, so mutations are visible to the middleware.

IDEMPOTENCY

The middleware skips processing if $scope->{'pagi.session'} already exists. This prevents double-initialization when the middleware appears more than once in a stack.

For mixed auth patterns (e.g. web cookies for browsers, bearer tokens for APIs), use PAGI::Middleware::Session::State::Callback with fallback logic instead of stacking multiple Session middleware instances:

use PAGI::Middleware::Session::State::Callback;
use PAGI::Middleware::Session::State::Cookie;
use PAGI::Middleware::Session::State::Bearer;

my $cookie_state = PAGI::Middleware::Session::State::Cookie->new(
    cookie_name => 'pagi_session',
    expire      => 3600,
);
my $bearer_state = PAGI::Middleware::Session::State::Bearer->new();

enable 'Session',
    secret => $ENV{SESSION_SECRET},
    state  => PAGI::Middleware::Session::State::Callback->new(
        extract => sub {
            my ($scope) = @_;
            return $bearer_state->extract($scope)
                // $cookie_state->extract($scope);
        },
        inject => sub {
            my ($headers, $id, $options) = @_;
            $cookie_state->inject($headers, $id, $options);
        },
    );

NON-HTTP SCOPES (WebSocket, SSE, ...)

If there is a session, WebSocket and SSE connections see it too: a scope whose type is not 'http' but which still carries a headers key -- both a WebSocket upgrade request and an SSE request are typed this way (see PAGI::Context's is_websocket/is_sse; SSE is its own distinct scope type, not 'http' plus a flag) -- gets a read-only session: the same state->extract + store->get lookup the 'http' path uses, populating $scope->{'pagi.session'} (and $scope->{'pagi.session_id'}, when a real session was found) before calling the wrapped app. This is store-agnostic -- it works with whatever store/state pair the middleware was configured with, not just cookie-backed setups -- and scope-type-agnostic: any current or future scope type with a headers key gets the same treatment, WebSocket and SSE are simply the two named, tested cases today.

Nothing is ever saved. There is no http.response.start-shaped event on a protocol upgrade to hook a save onto, so store->set is never called and no transport (e.g. Set-Cookie) is ever injected for one of these scopes. Any mutation an application makes to the session hashref during a WebSocket/SSE connection is visible for the lifetime of that connection and then silently discarded -- it never reaches the store and is never seen again, by this connection or any other. Establish or change session state (login, logout, regeneration) over a regular 'http' request; treat a non-http scope's session as read-only.

A session id that resolves to nothing (absent, malformed, or expired) populates $scope->{'pagi.session'} with an explicit empty hashref {} rather than leaving the key missing -- every 'http' request is guaranteed a usable session (even a brand new, never-before-seen one falls through to a freshly created hashref), so application code written against that guarantee (e.g. anything that unconditionally builds a PAGI::Session from the scope) sees the same guarantee here instead of having to special-case "no session key at all" for non-http scopes. $scope->{'pagi.session_id'} is left unset in this case -- there is no real session id to report, and fabricating one for a hashref that was never loaded from (or will ever be saved to) the store would be misleading.

A scope with no headers key at all (a lifespan scope, which carries no request to extract a session id from) is passed through completely unmodified, exactly as every scope type other than 'http' always was before this feature existed.

SCOPE EXTENSIONS

This middleware adds the following to $scope:

  • pagi.session

    Hashref of session data. Modify this directly to update the session, or wrap it with PAGI::Session for strict accessor methods. Keys starting with _ are reserved for internal use.

  • pagi.session_id

    The session ID string. Absent (not merely undef) for a non-'http' scope whose session id didn't resolve to anything -- see "NON-HTTP SCOPES (WebSocket, SSE, ...)".

This applies to a non-'http' scope too, as long as it carries a headers key -- see "NON-HTTP SCOPES (WebSocket, SSE, ...)" for the read-only, never-saved semantics that applies there instead of the full load/mutate/save cycle described below.

SESSION DATA

Keys starting with _ are reserved for internal use by the session middleware. Do not use underscore-prefixed keys for application data.

Reserved keys (read-only metadata)

  • _id - Session ID

  • _created - Unix timestamp when session was created

  • _last_access - Unix timestamp of last access (updated each request)

Reserved keys (middleware-consumed flags)

These flags are set by the application and consumed by the middleware during response handling. They are deleted after processing.

  • _regenerated - Set to 1 to regenerate the session ID on this response. The old session is deleted from the store and a new ID is issued. Always do this after authentication to prevent session fixation attacks.

  • _destroyed - Set to 1 to destroy the session entirely. The session data is deleted from the store and the client-side state (cookie) is cleared. Use this for logout.

WHEN THE SESSION TRANSPORT IS EMITTED

On each response, the middleware saves the session unconditionally, but only emits the transport (e.g. Set-Cookie) via $state->inject when the session is new, regenerated, or its data has changed since it was loaded at the start of the request. A pure-read request against an existing, unmodified session emits no transport.

"Changed" is determined by comparing the session's data at load time against its data immediately before saving, regardless of how the mutation happened: $session->set(...), $session->data->{...} = ..., or direct hashref mutation via $scope->{'pagi.session'} all count. This matters most for transport-is-data stores (e.g. PAGI::Middleware::Session::Store::Cookie), where a discarded transport after a mutation would leave the client holding stale, incorrect data.

This entire section describes the 'http' scope path only. A non-http scope (see "NON-HTTP SCOPES (WebSocket, SSE, ...)") never saves and never emits a transport, regardless of whether its session was mutated.

SEE ALSO

PAGI::Session - Standalone session helper object

PAGI::Middleware::Session::State - Base class for session ID transport

PAGI::Middleware::Session::Store - Base class for session storage

PAGI::Middleware - Base class for middleware

PAGI::Middleware::Cookie - Cookie parsing