Skip to main content

src/auth/livery_auth_introspect.erl

-module(livery_auth_introspect).
-moduledoc """
OAuth 2.0 token introspection middleware (RFC 7662).

Verifies opaque bearer tokens that cannot be checked locally by
POSTing them to the authorization server's introspection endpoint
and trusting the `active` field of the JSON response. On success
the full introspection response (claims such as `scope`,
`client_id`, `username`, `sub`, `exp`) is stored as
`meta(user, _)` (read it back with `livery_ext:user/1`). On any
failure it short-circuits with `401 Unauthorized`.

Use this for reference/opaque tokens; for self-contained JWTs
prefer local verification with `livery_auth_bearer`.

State:

```erlang
{livery_auth_introspect, #{
    endpoint      => <<"https://issuer.example/oauth/introspect">>,
    client_id     => <<"my-api">>,
    client_secret => <<"s3cret">>,
    required      => true
}}
```

`client_id`/`client_secret` authenticate this resource server to
the introspection endpoint via HTTP Basic. The HTTP call is
pluggable via `fetch => fun((Url, Headers, Body) -> {ok, Status,
Body} | {error, _})`; the default uses `hackney`.
""".
-behaviour(livery_middleware).

-export([call/3, introspect/2, default_fetch/3]).

-export_type([opts/0, error_reason/0]).

-type opts() :: #{
    endpoint := binary(),
    client_id => binary(),
    client_secret => binary(),
    token_type_hint => binary(),
    required => boolean(),
    fetch => fetch_fun()
}.

-type fetch_fun() ::
    fun(
        (binary(), [{binary(), binary()}], binary()) ->
            {ok, non_neg_integer(), binary()} | {error, term()}
    ).

-type error_reason() ::
    inactive
    | invalid_response
    | invalid_json
    | {http_status, non_neg_integer()}
    | term().

%%====================================================================
%% Middleware
%%====================================================================

-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 introspect(Token, State) of
                {ok, Claims} ->
                    Next(livery_req:set_meta(user, Claims, Req));
                {error, _} ->
                    unauthorized(<<"invalid token">>)
            end
    end.

%%====================================================================
%% Introspection
%%====================================================================

-doc """
Introspect a token at the configured endpoint.

Returns the introspection response map when the token is `active`,
`{error, inactive}` when it is not, and other `{error, _}` reasons
on transport or decoding failure.
""".
-spec introspect(binary(), opts()) -> {ok, map()} | {error, error_reason()}.
introspect(Token, Opts) ->
    Endpoint = maps:get(endpoint, Opts),
    Fetch = maps:get(fetch, Opts, fun default_fetch/3),
    case Fetch(Endpoint, headers(Opts), form_body(Token, Opts)) of
        {ok, 200, Body} -> parse_response(Body);
        {ok, Code, _Body} -> {error, {http_status, Code}};
        {error, _} = Error -> Error
    end.

-spec parse_response(binary()) -> {ok, map()} | {error, error_reason()}.
parse_response(Body) ->
    try json:decode(Body) of
        #{<<"active">> := true} = Claims -> {ok, Claims};
        #{<<"active">> := false} -> {error, inactive};
        _ -> {error, invalid_response}
    catch
        _:_ -> {error, invalid_json}
    end.

-spec form_body(binary(), opts()) -> binary().
form_body(Token, Opts) ->
    Pairs =
        case maps:get(token_type_hint, Opts, undefined) of
            undefined -> [{<<"token">>, Token}];
            Hint -> [{<<"token">>, Token}, {<<"token_type_hint">>, Hint}]
        end,
    iolist_to_binary(uri_string:compose_query(Pairs)).

-spec headers(opts()) -> [{binary(), binary()}].
headers(Opts) ->
    Base = [
        {<<"content-type">>, <<"application/x-www-form-urlencoded">>},
        {<<"accept">>, <<"application/json">>}
    ],
    case {maps:get(client_id, Opts, undefined), maps:get(client_secret, Opts, undefined)} of
        {Id, Secret} when is_binary(Id), is_binary(Secret) ->
            Cred = base64:encode(<<Id/binary, ":", Secret/binary>>),
            [{<<"authorization">>, <<"Basic ", Cred/binary>>} | Base];
        _ ->
            Base
    end.

%%====================================================================
%% Default HTTP fetcher (hackney)
%%====================================================================

-doc "Default introspection POST using `hackney`, verifying the server's TLS cert.".
-spec default_fetch(binary(), [{binary(), binary()}], binary()) ->
    {ok, non_neg_integer(), binary()} | {error, term()}.
default_fetch(Url, Headers, Body) ->
    {ok, _} = application:ensure_all_started(hackney),
    Opts = [
        with_body,
        {recv_timeout, 5000},
        {connect_timeout, 5000},
        {ssl_options, livery_auth:tls_opts()}
    ],
    case hackney:request(post, Url, Headers, Body, Opts) of
        {ok, Code, _RespHeaders, RespBody} ->
            {ok, Code, RespBody};
        {error, Reason} ->
            {error, Reason}
    end.

%%====================================================================
%% Internals
%%====================================================================

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