-module(livery_auth_session).
-moduledoc """
Signed session-cookie middleware.
Reads a cookie, verifies its HMAC-SHA256 signature against a shared
`secret`, decodes the JSON payload, and stores it on the request as
`meta(session, Data)` (read it back with `livery_ext:session/1`).
The cookie is stateless: the signed payload travels with the
client, so no server-side session store is needed.
State:
```erlang
{livery_auth_session, #{
secret => <<"a long random secret">>, %% required
name => <<"session">>, %% cookie name, default
meta_key => session, %% meta key, default
required => false %% default false
}}
```
A missing cookie is allowed through when `required => false` (the
default); the handler sees no `session` meta. A present but
tampered or expired cookie is always rejected with `401`.
Issue and clear cookies from a handler with `sign/2` plus
`set_cookie_header/2` and `clear_cookie_header/1`:
```erlang
login(Req) ->
Value = livery_auth_session:sign(#{<<"uid">> => 42},
#{secret => Secret, max_age => 3600}),
Hdr = livery_auth_session:set_cookie_header(Value,
#{max_age => 3600}),
R = livery_resp:redirect(303, <<"/">>),
livery_resp:with_header(element(1, Hdr), element(2, Hdr), R).
```
""".
-behaviour(livery_middleware).
-export([call/3]).
-export([sign/2, verify/2, set_cookie_header/2, clear_cookie_header/1]).
-export_type([error_reason/0]).
-type error_reason() :: malformed | bad_signature | expired.
%%====================================================================
%% Middleware
%%====================================================================
-spec call(livery_req:req(), livery_middleware:next(), map()) ->
livery_resp:resp().
call(Req, Next, State) ->
case livery_ext:cookie(name(State), Req) of
undefined ->
case maps:get(required, State, false) of
true -> unauthorized();
false -> Next(Req)
end;
Value ->
case verify(Value, State) of
{ok, Data} ->
Next(livery_req:set_meta(meta_key(State), Data, Req));
{error, _} ->
unauthorized()
end
end.
%%====================================================================
%% Cookie value sign / verify
%%====================================================================
-doc """
Sign a session payload into a cookie value.
`Data` is any JSON-encodable map. With `max_age` (seconds) an `exp`
claim is embedded and enforced by `verify/2`. Returns
`base64url(payload) "." base64url(hmac)`.
""".
-spec sign(map(), map()) -> binary().
sign(Data, Opts) when is_map(Data) ->
Json = iolist_to_binary(json:encode(add_exp(Data, Opts))),
P = b64(Json),
Sig = b64(mac(secret(Opts), Json)),
<<P/binary, ".", Sig/binary>>.
-doc """
Verify a cookie value and return its payload map.
Checks the HMAC signature in constant time and, if present, the
`exp` claim. Errors: `malformed`, `bad_signature`, `expired`.
""".
-spec verify(binary(), map()) -> {ok, map()} | {error, error_reason()}.
verify(Cookie, Opts) ->
case binary:split(Cookie, <<".">>) of
[P, Sig] ->
case {unb64(P), unb64(Sig)} of
{{ok, Json}, {ok, Actual}} ->
Expected = mac(secret(Opts), Json),
case
byte_size(Expected) =:= byte_size(Actual) andalso
crypto:hash_equals(Expected, Actual)
of
true -> decode_payload(Json);
false -> {error, bad_signature}
end;
_ ->
{error, malformed}
end;
_ ->
{error, malformed}
end.
%%====================================================================
%% Set-Cookie builders
%%====================================================================
-doc """
Build a `Set-Cookie` header carrying a signed value.
Attributes come from `Opts`: `path` (default `<<"/">>`),
`http_only` (default `true`), `secure` (default `true`),
`same_site` (default `<<"Lax">>`), `max_age`, `domain`.
""".
-spec set_cookie_header(binary(), map()) -> {binary(), binary()}.
set_cookie_header(Value, Opts) ->
{<<"set-cookie">>, iolist_to_binary([name(Opts), <<"=">>, Value, cookie_attrs(Opts)])}.
-doc "Build a `Set-Cookie` header that expires the session cookie.".
-spec clear_cookie_header(map()) -> {binary(), binary()}.
clear_cookie_header(Opts) ->
{<<"set-cookie">>,
iolist_to_binary([
name(Opts),
<<"=; Path=">>,
path(Opts),
<<"; Max-Age=0">>
])}.
%%====================================================================
%% Internals
%%====================================================================
-spec decode_payload(binary()) -> {ok, map()} | {error, error_reason()}.
decode_payload(Json) ->
try json:decode(Json) of
Map when is_map(Map) -> check_exp(Map);
_ -> {error, malformed}
catch
_:_ -> {error, malformed}
end.
-spec check_exp(map()) -> {ok, map()} | {error, expired}.
check_exp(#{<<"exp">> := Exp} = Map) when is_integer(Exp) ->
case erlang:system_time(second) =< Exp of
true -> {ok, Map};
false -> {error, expired}
end;
check_exp(Map) ->
{ok, Map}.
-spec add_exp(map(), map()) -> map().
add_exp(Data, #{max_age := MaxAge}) when is_integer(MaxAge) ->
Data#{<<"exp">> => erlang:system_time(second) + MaxAge};
add_exp(Data, _Opts) ->
Data.
-spec cookie_attrs(map()) -> iodata().
cookie_attrs(Opts) ->
[
<<"; Path=">>,
path(Opts),
domain_attr(Opts),
max_age_attr(Opts),
same_site_attr(Opts),
bool_attr(<<"; Secure">>, maps:get(secure, Opts, true)),
bool_attr(<<"; HttpOnly">>, maps:get(http_only, Opts, true))
].
-spec domain_attr(map()) -> iodata().
domain_attr(#{domain := D}) -> [<<"; Domain=">>, D];
domain_attr(_) -> [].
-spec max_age_attr(map()) -> iodata().
max_age_attr(#{max_age := M}) when is_integer(M) ->
[<<"; Max-Age=">>, integer_to_binary(M)];
max_age_attr(_) ->
[].
-spec same_site_attr(map()) -> iodata().
same_site_attr(Opts) ->
[<<"; SameSite=">>, maps:get(same_site, Opts, <<"Lax">>)].
-spec bool_attr(binary(), boolean()) -> iodata().
bool_attr(Attr, true) -> Attr;
bool_attr(_Attr, false) -> [].
-spec name(map()) -> binary().
name(Opts) -> maps:get(name, Opts, <<"session">>).
-spec path(map()) -> binary().
path(Opts) -> maps:get(path, Opts, <<"/">>).
-spec meta_key(map()) -> term().
meta_key(Opts) -> maps:get(meta_key, Opts, session).
-spec secret(map()) -> binary().
secret(#{secret := S}) -> S.
-spec mac(binary(), binary()) -> binary().
mac(Secret, Data) ->
crypto:mac(hmac, sha256, Secret, Data).
-spec b64(binary()) -> binary().
b64(Bin) ->
base64:encode(Bin, #{mode => urlsafe, padding => false}).
-spec unb64(binary()) -> {ok, binary()} | error.
unb64(Bin) ->
try
{ok, base64:decode(Bin, #{mode => urlsafe, padding => false})}
catch
_:_ -> error
end.
-spec unauthorized() -> livery_resp:resp().
unauthorized() ->
livery_resp:text(401, <<"invalid session">>).