Skip to main content

src/auth/livery_auth_bearer.erl

-module(livery_auth_bearer).
-moduledoc """
Bearer-token authentication middleware.

Extracts the bearer token from the `Authorization` header,
verifies it with `livery_auth:verify/2`, and stores the validated
claims on the request as `meta(user, Claims)` (read it back with
`livery_ext:user/1`). On any failure it short-circuits with
`401 Unauthorized` and a `WWW-Authenticate: Bearer` header.

State is the `livery_auth:verify_opts()` map plus an optional
`required => boolean()` (default `true`):

```erlang
{livery_auth_bearer, #{
    keys     => Jwks,
    issuer   => <<"https://issuer.example">>,
    audience => <<"my-api">>
}}
```

When `required => false`, a missing token is allowed through (the
handler sees no `user` meta), but a present-but-invalid token is
still rejected.
""".
-behaviour(livery_middleware).

-export([call/3]).

-spec call(
    livery_req:req(),
    livery_middleware:next(),
    map()
) -> livery_resp:resp().
call(Req, Next, State) ->
    case livery_ext:bearer_token(Req) of
        undefined ->
            case maps:get(required, State, true) of
                true -> unauthorized(<<"missing token">>);
                false -> Next(Req)
            end;
        Token ->
            case resolve_keys(State) of
                {ok, VerifyOpts} ->
                    verify_with_rotation(Token, VerifyOpts, State, Req, Next);
                {error, _} ->
                    unauthorized(<<"key resolution failed">>)
            end
    end.

%% Verify; on a no_matching_key failure with a jwks_uri, refresh the
%% JWKS once (rotation) and retry before giving up.
verify_with_rotation(Token, VerifyOpts, State, Req, Next) ->
    case livery_auth:verify(Token, VerifyOpts) of
        {ok, Claims} ->
            Next(livery_req:set_meta(user, Claims, Req));
        {error, no_matching_key} when is_map_key(jwks_uri, State) ->
            case refresh_keys(State) of
                {ok, VerifyOpts1} ->
                    case livery_auth:verify(Token, VerifyOpts1) of
                        {ok, Claims} ->
                            Next(livery_req:set_meta(user, Claims, Req));
                        {error, Reason} ->
                            unauthorized(reason_text(Reason))
                    end;
                {error, _} ->
                    unauthorized(<<"no matching key">>)
            end;
        {error, Reason} ->
            unauthorized(reason_text(Reason))
    end.

-spec resolve_keys(map()) -> {ok, livery_auth:verify_opts()} | {error, term()}.
resolve_keys(#{jwks_uri := Uri} = State) ->
    case livery_auth_jwks:keys(Uri, jwks_opts(State)) of
        {ok, Keys} -> {ok, verify_opts(State#{keys => Keys})};
        {error, _} = E -> E
    end;
resolve_keys(State) ->
    {ok, verify_opts(State)}.

refresh_keys(#{jwks_uri := Uri} = State) ->
    case livery_auth_jwks:refresh(Uri, jwks_opts(State)) of
        {ok, Keys} -> {ok, verify_opts(State#{keys => Keys})};
        {error, _} = E -> E
    end.

jwks_opts(State) ->
    maps:with([fetch, ttl], State).

-spec verify_opts(map()) -> livery_auth:verify_opts().
verify_opts(State) ->
    maps:without([required, jwks_uri, fetch, ttl], State).

-spec unauthorized(binary()) -> livery_resp:resp().
unauthorized(Detail) ->
    Resp = livery_resp:text(401, Detail),
    livery_resp:with_header(<<"www-authenticate">>, <<"Bearer">>, Resp).

-spec reason_text(livery_auth:error_reason()) -> binary().
reason_text(expired) -> <<"token expired">>;
reason_text(not_yet_valid) -> <<"token not yet valid">>;
reason_text(bad_signature) -> <<"bad token signature">>;
reason_text(no_matching_key) -> <<"no matching key">>;
reason_text(malformed) -> <<"malformed token">>;
reason_text(invalid_json) -> <<"malformed token">>;
reason_text(audience_mismatch) -> <<"audience mismatch">>;
reason_text({issuer_mismatch, _}) -> <<"issuer mismatch">>;
reason_text({unsupported_alg, _}) -> <<"unsupported algorithm">>.