-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(<>), [{<<"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).