File Coverage

blib/lib/PAGI/Middleware/Session.pm
Criterion Covered Total %
statement 90 92 97.8
branch 20 22 90.9
condition 11 21 52.3
subroutine 15 15 100.0
pod 1 2 50.0
total 137 152 90.1


line stmt bran cond sub pod time code
1             package PAGI::Middleware::Session;
2              
3 2     2   221549 use strict;
  2         3  
  2         66  
4 2     2   8 use warnings;
  2         7  
  2         87  
5 2     2   353 use parent 'PAGI::Middleware';
  2         279  
  2         8  
6 2     2   99 use Future::AsyncAwait;
  2         4  
  2         20  
7 2     2   504 use Digest::SHA qw(sha256_hex);
  2         3275  
  2         189  
8 2     2   680 use PAGI::Utils::Random qw(secure_random_bytes);
  2         4  
  2         2929  
9              
10             =head1 NAME
11              
12             PAGI::Middleware::Session - Session management middleware with pluggable State/Store
13              
14             =head1 SYNOPSIS
15              
16             use PAGI::Middleware::Builder;
17              
18             # Default (cookie-based, in-memory store)
19             my $app = builder {
20             enable 'Session', secret => 'your-secret-key';
21             $my_app;
22             };
23              
24             # Explicit state and store
25             use PAGI::Middleware::Session::State::Header;
26             use PAGI::Middleware::Session::Store::Memory;
27              
28             my $app = builder {
29             enable 'Session',
30             secret => 'your-secret-key',
31             state => PAGI::Middleware::Session::State::Header->new(
32             header_name => 'X-Session-ID',
33             ),
34             store => PAGI::Middleware::Session::Store::Memory->new;
35             $my_app;
36             };
37              
38             # In your app:
39             async sub app {
40             my ($scope, $receive, $send) = @_;
41              
42             # Raw hashref access
43             my $session = $scope->{'pagi.session'};
44             $session->{user_id} = 123;
45              
46             # Or use the PAGI::Session helper
47             use PAGI::Session;
48             my $s = PAGI::Session->new($scope->{'pagi.session'});
49             $s->set('user_id', 123);
50             my $uid = $s->get('user_id'); # dies if key missing
51             }
52              
53             =head1 DESCRIPTION
54              
55             PAGI::Middleware::Session provides server-side session management with a
56             pluggable architecture for session ID transport (State) and session data
57             storage (Store).
58              
59             The B layer controls how the session ID travels between client and
60             server (cookies, headers, bearer tokens, or custom logic). The B
61             layer controls where session data is persisted (memory, Redis, database).
62              
63             By default, sessions use cookie-based IDs and in-memory storage.
64              
65             B The default in-memory store is suitable for development and
66             single-process deployments only. Sessions are not shared between workers
67             and are lost on restart. For production multi-worker deployments, provide
68             a C object backed by Redis, a database, or another shared storage.
69              
70             =head1 CONFIGURATION
71              
72             =over 4
73              
74             =item * secret (required)
75              
76             Secret key used for session ID generation.
77              
78             =item * expire (default: 3600)
79              
80             Session expiration time in seconds.
81              
82             =item * state (optional)
83              
84             A L object that implements C
85             and C. If not provided, a
86             L instance is created using
87             C, C, and C.
88              
89             =item * store (optional)
90              
91             A L object that implements async C,
92             C, and C. If not provided, a
93             L instance is created.
94              
95             =item * cookie_name (default: 'pagi_session')
96              
97             Name of the session cookie. Only used when C defaults to
98             L.
99              
100             =item * cookie_options (default: { httponly => 1, path => '/', samesite => 'Lax' })
101              
102             Options for the session cookie. Only used when C defaults to
103             L. For production HTTPS
104             deployments, add C<< secure => 1 >>.
105              
106             =back
107              
108             =head1 STATE CLASSES
109              
110             State classes control how the session ID is extracted from requests and
111             injected into responses. All implement the L
112             interface.
113              
114             =over 4
115              
116             =item L
117              
118             Default. Reads the session ID from a request cookie and sets it via
119             C on the response. Suitable for browser-based web applications.
120              
121             =item L
122              
123             Reads the session ID from a custom HTTP header. Requires C;
124             accepts an optional C regex with a capture group. Injection is a
125             no-op (the client manages header-based transport).
126              
127             PAGI::Middleware::Session::State::Header->new(
128             header_name => 'X-Session-ID',
129             );
130              
131             =item L
132              
133             Convenience subclass of State::Header that reads an opaque bearer token from
134             the CtokenE> header. Intended for opaque
135             session tokens, B JWTs. For JWT authentication, use
136             L instead.
137              
138             PAGI::Middleware::Session::State::Bearer->new();
139              
140             =item L
141              
142             Custom session ID transport using coderefs. Requires an C coderef;
143             accepts an optional C coderef (defaults to no-op).
144              
145             PAGI::Middleware::Session::State::Callback->new(
146             extract => sub { my ($scope) = @_; ... },
147             inject => sub { my ($headers, $id, $options) = @_; ... },
148             );
149              
150             =back
151              
152             =head1 STORE CLASSES
153              
154             Store classes control where session data is persisted. All implement the
155             L interface; methods return L
156             objects for async compatibility.
157              
158             =over 4
159              
160             =item L
161              
162             Default. In-memory hash storage. Not shared across workers or restarts.
163             Suitable for development and testing only.
164              
165             =item External stores
166              
167             Redis, database, and other shared stores are available as separate CPAN
168             distributions. Any object implementing C, C,
169             and C (returning Futures) can be used.
170              
171             =back
172              
173             =head1 PAGI::Session HELPER
174              
175             L is a standalone helper object that wraps the raw session
176             data hashref with a clean accessor interface.
177              
178             use PAGI::Session;
179             my $session = PAGI::Session->new($scope->{'pagi.session'});
180              
181             Key methods:
182              
183             =over 4
184              
185             =item * C - Dies if the key does not exist (catches typos).
186              
187             =item * C - Returns C<$default> for missing keys.
188              
189             =item * C - Sets a session value.
190              
191             =item * C - Checks key existence.
192              
193             =item * C - Removes a key.
194              
195             =item * C - Lists user keys (excludes internal C<_>-prefixed keys).
196              
197             =item * C - Returns the session ID.
198              
199             =item * C - Requests session ID regeneration on next response.
200              
201             =item * C - Marks the session for deletion.
202              
203             =back
204              
205             The helper stores a reference to the underlying hash, so mutations are
206             visible to the middleware.
207              
208             =head1 IDEMPOTENCY
209              
210             The middleware skips processing if C<< $scope-E{'pagi.session'} >>
211             already exists. This prevents double-initialization when the middleware
212             appears more than once in a stack.
213              
214             For mixed auth patterns (e.g. web cookies for browsers, bearer tokens for
215             APIs), use L with fallback
216             logic instead of stacking multiple Session middleware instances:
217              
218             use PAGI::Middleware::Session::State::Callback;
219             use PAGI::Middleware::Session::State::Cookie;
220             use PAGI::Middleware::Session::State::Bearer;
221              
222             my $cookie_state = PAGI::Middleware::Session::State::Cookie->new(
223             cookie_name => 'pagi_session',
224             expire => 3600,
225             );
226             my $bearer_state = PAGI::Middleware::Session::State::Bearer->new();
227              
228             enable 'Session',
229             secret => $ENV{SESSION_SECRET},
230             state => PAGI::Middleware::Session::State::Callback->new(
231             extract => sub {
232             my ($scope) = @_;
233             return $bearer_state->extract($scope)
234             // $cookie_state->extract($scope);
235             },
236             inject => sub {
237             my ($headers, $id, $options) = @_;
238             $cookie_state->inject($headers, $id, $options);
239             },
240             );
241              
242             =cut
243              
244             sub _init {
245 11     11   20 my ($self, $config) = @_;
246              
247             $self->{secret} = $config->{secret}
248 11   50     37 // die "Session middleware requires 'secret' option";
249 11   50     60 $self->{expire} = $config->{expire} // 3600;
250              
251             # State: pluggable session ID transport
252 11 100       22 if ($config->{state}) {
253 2         4 $self->{state} = $config->{state};
254             } else {
255 9         1073 require PAGI::Middleware::Session::State::Cookie;
256             $self->{state} = PAGI::Middleware::Session::State::Cookie->new(
257             cookie_name => $config->{cookie_name} // 'pagi_session',
258             cookie_options => $config->{cookie_options} // {
259             httponly => 1,
260             path => '/',
261             samesite => 'Lax',
262             },
263             expire => $self->{expire},
264 9   50     94 );
      100        
265             }
266              
267             # Store: pluggable async session storage
268 11 100       26 if ($config->{store}) {
269 2         4 $self->{store} = $config->{store};
270             } else {
271 9         23 require PAGI::Middleware::Session::Store::Memory;
272 9         40 $self->{store} = PAGI::Middleware::Session::Store::Memory->new();
273             }
274             }
275              
276             sub wrap {
277 18     18 1 3307 my ($self, $app) = @_;
278              
279 18     18   149 return async sub {
280 18         25 my ($scope, $receive, $send) = @_;
281 18 50       43 if ($scope->{type} ne 'http') {
282 0         0 await $app->($scope, $receive, $send);
283 0         0 return;
284             }
285              
286             # Idempotency: skip if session already exists in scope
287 18 100       32 if (exists $scope->{'pagi.session'}) {
288             warn "Session middleware: pagi.session already in scope, skipping\n"
289 1 50       3 if $ENV{PAGI_DEBUG};
290 1         3 await $app->($scope, $receive, $send);
291 1         116 return;
292             }
293              
294             # Extract session ID via state handler
295 17         44 my $session_id = $self->{state}->extract($scope);
296              
297             # Validate and load session
298 17         34 my ($session, $is_new) = await $self->_load_or_create_session($session_id);
299 17         612 $session_id = $session->{_id};
300              
301             # Add session to scope
302 17         83 my $new_scope = {
303             %$scope,
304             'pagi.session' => $session,
305             'pagi.session_id' => $session_id,
306             };
307              
308             # Wrap send to save session and inject state
309 34         778 my $wrapped_send = async sub {
310 34         51 my ($event) = @_;
311 34 100       59 if ($event->{type} eq 'http.response.start') {
312 17   50     13 my @headers = @{$event->{headers} // []};
  17         43  
313              
314 17 100       42 if ($session->{_destroyed}) {
    100          
315             # Destroy: delete from store, clear client state
316 1         3 await $self->{store}->delete($session_id);
317 1         21 $self->{state}->clear(\@headers);
318             }
319             elsif ($session->{_regenerated}) {
320             # Regenerate: new ID, delete old, save under new
321 1         2 my $old_id = $session_id;
322 1         3 $session_id = $self->_generate_session_id();
323 1         3 $session->{_id} = $session_id;
324 1         3 delete $session->{_regenerated};
325 1         4 await $self->{store}->delete($old_id);
326 1         40 my $transport = await $self->_save_session($session_id, $session);
327 1         44 $self->{state}->inject(\@headers, $transport, {});
328             }
329             else {
330             # Normal: save and inject if new
331 15         31 my $transport = await $self->_save_session($session_id, $session);
332 15 100       689 if ($is_new) {
333 12         32 $self->{state}->inject(\@headers, $transport, {});
334             }
335             }
336              
337 17         64 await $send->({ %$event, headers => \@headers });
338 17         472 return;
339             }
340 17         28 await $send->($event);
341 17         78 };
342              
343 17         35 await $app->($new_scope, $receive, $wrapped_send);
344 18         91 };
345             }
346              
347 17     17   18 async sub _load_or_create_session {
348 17         28 my ($self, $session_id) = @_;
349              
350             # Try to load existing session. The store handles validation —
351             # server-side stores return undef for unknown IDs, cookie stores
352             # return undef if decoding/verification fails.
353 17 100 66     41 if (defined $session_id && length $session_id) {
354 7         15 my $session = await $self->_get_session($session_id);
355 7 100 66     421 if ($session && !$self->_is_expired($session)) {
356 5         7 $session->{_last_access} = time();
357 5         17 return ($session, 0);
358             }
359             }
360              
361             # Create new session
362 12         43 $session_id = $self->_generate_session_id();
363 12         44 my $session = {
364             _id => $session_id,
365             _created => time(),
366             _last_access => time(),
367             };
368              
369 12         98 return ($session, 1);
370             }
371              
372             sub _generate_session_id {
373 13     13   18 my ($self) = @_;
374              
375             # Use cryptographically secure random bytes
376 13         30 my $random = unpack('H*', secure_random_bytes(16));
377 13         23 my $time = time();
378 13         116 return sha256_hex("$random-$time-$self->{secret}");
379             }
380              
381 7     7   7 async sub _get_session {
382 7         12 my ($self, $id) = @_;
383 7         17 return await $self->{store}->get($id);
384             }
385              
386 16     16   17 async sub _save_session {
387 16         24 my ($self, $id, $session) = @_;
388 16         42 return await $self->{store}->set($id, $session);
389             }
390              
391             sub _is_expired {
392 5     5   8 my ($self, $session) = @_;
393              
394 5   33     11 my $last_access = $session->{_last_access} // $session->{_created} // 0;
      0        
395 5         36 return (time() - $last_access) > $self->{expire};
396             }
397              
398             # Class method to clear all sessions (useful for testing)
399             sub clear_sessions {
400 7     7 0 13885 require PAGI::Middleware::Session::Store::Memory;
401 7         21 PAGI::Middleware::Session::Store::Memory::clear_all();
402             }
403              
404             1;
405              
406             __END__