-module(oidcc_token_introspection).
-feature(maybe_expr, enable).
-include("internal/doc.hrl").
?MODULEDOC("""
OAuth Token Introspection.
See https://datatracker.ietf.org/doc/html/rfc7662.
## Records
To use the records, import the definition:
```erlang
-include_lib(["oidcc/include/oidcc_token_introspection.hrl"]).
```
## Telemetry
See [`Oidcc.TokenIntrospection`](`m:'Elixir.Oidcc.TokenIntrospection'`).
""").
?MODULEDOC(#{since => <<"3.0.0">>}).
-include("oidcc_client_context.hrl").
-include("oidcc_provider_configuration.hrl").
-include("oidcc_token.hrl").
-include("oidcc_token_introspection.hrl").
-export([introspect/3]).
-export_type([error/0]).
-export_type([opts/0]).
-export_type([t/0]).
?DOC("""
Introspection Result.
See https://datatracker.ietf.org/doc/html/rfc7662#section-2.2.
""").
?DOC(#{since => <<"3.0.0">>}).
-type t() :: #oidcc_token_introspection{
active :: boolean(),
client_id :: binary(),
exp :: pos_integer(),
scope :: oidcc_scope:scopes(),
username :: binary(),
iss :: binary()
}.
?DOC(#{since => <<"3.0.0">>}).
-type opts() :: #{
preferred_auth_methods => [oidcc_auth_util:auth_method(), ...],
request_opts => oidcc_http_util:request_opts(),
dpop_nonce => binary(),
client_self_only => boolean()
}.
?DOC(#{since => <<"3.0.0">>}).
-type error() :: client_id_mismatch | introspection_not_supported | oidcc_http_util:error().
-telemetry_event(#{
event => [oidcc, load_configuration, start],
description => <<"Emitted at the start of introspecting the token">>,
measurements => <<"#{system_time => non_neg_integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, load_configuration, stop],
description => <<"Emitted at the end of introspecting the token">>,
measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, load_configuration, exception],
description => <<"Emitted at the end of introspecting the token">>,
measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
?DOC("""
Introspect the given access token.
For a high level interface using `m:oidcc_provider_configuration_worker`
see `oidcc:introspect_token/5`.
## Examples
```erlang
{ok, ClientContext} =
oidcc_client_context:from_configuration_worker(provider_name,
<<"client_id">>,
<<"client_secret">>),
%% Get AccessToken
{ok, #oidcc_token_introspection{active = True}} =
oidcc_token_introspection:introspect(AccessToken, ClientContext, #{}).
```
""").
?DOC(#{since => <<"3.0.0">>}).
-spec introspect(Token, ClientContext, Opts) ->
{ok, t()}
| {error, error()}
when
Token :: oidcc_token:t() | binary(),
ClientContext :: oidcc_client_context:authenticated_t(),
Opts :: opts().
introspect(
#oidcc_token{access = #oidcc_token_access{token = AccessToken}},
ClientContext,
Opts
) ->
introspect(AccessToken, ClientContext, Opts);
introspect(AccessToken, ClientContext, Opts) ->
#oidcc_client_context{
provider_configuration = Configuration,
client_id = ClientId,
client_secret = ClientSecret
} = ClientContext,
#oidcc_provider_configuration{
introspection_endpoint = Endpoint0,
issuer = Issuer,
introspection_endpoint_auth_methods_supported = SupportedAuthMethods,
introspection_endpoint_auth_signing_alg_values_supported = AllowAlgorithms
} = Configuration,
case Endpoint0 of
undefined ->
{error, introspection_not_supported};
_ ->
Header0 = [{"accept", "application/json"}],
Body0 = [{<<"token">>, AccessToken}],
RequestOpts = maps:get(request_opts, Opts, #{}),
TelemetryOpts = #{
topic => [oidcc, introspect_token],
extra_meta => #{issuer => Issuer, client_id => ClientId}
},
DpopOpts =
case Opts of
#{dpop_nonce := DpopNonce} ->
#{nonce => DpopNonce};
_ ->
#{}
end,
maybe
{ok, {Body, Header1}, AuthMethod} ?=
oidcc_auth_util:add_client_authentication(
Body0, Header0, SupportedAuthMethods, AllowAlgorithms, Opts, ClientContext
),
Endpoint = oidcc_auth_util:maybe_mtls_endpoint(
Endpoint0,
AuthMethod,
<<"introspection_endpoint">>,
ClientContext
),
Header = oidcc_auth_util:add_dpop_proof_header(
Header1, post, Endpoint, DpopOpts, ClientContext
),
Request =
{Endpoint, Header, "application/x-www-form-urlencoded",
uri_string:compose_query(Body)},
{ok, {{json, Token}, _Headers}} ?=
oidcc_http_util:request(post, Request, TelemetryOpts, RequestOpts),
{ok, TokenMap} ?= extract_response(Token),
client_match(TokenMap, ClientContext, maps:get(client_self_only, Opts, true))
else
{error, {use_dpop_nonce, NewDpopNonce, _}} when
DpopOpts =:= #{}
->
%% only retry automatically if we didn't use a nonce the first time
%% (to avoid infinite loops)
introspect(
AccessToken,
ClientContext,
Opts#{dpop_nonce => NewDpopNonce}
);
{error, Reason} ->
{error, Reason}
end
end.
-spec client_match(Introspection, ClientContext, ClientSelfOnly) ->
{ok, t()} | {error, error()}
when
Introspection :: t(),
ClientContext :: oidcc_client_context:t(),
ClientSelfOnly :: boolean().
client_match(Introspection, _, false) ->
{ok, Introspection};
client_match(
#oidcc_token_introspection{client_id = ClientId} = Introspection,
#oidcc_client_context{client_id = ClientId},
true
) ->
{ok, Introspection};
client_match(_Introspection, _ClientContext, true) ->
{error, client_id_mismatch}.
-spec extract_response(TokenMap) ->
{ok, t()}
when
TokenMap :: map().
extract_response(TokenMap) ->
Active =
case maps:get(<<"active">>, TokenMap, undefined) of
true ->
true;
_ ->
false
end,
Scope = maps:get(<<"scope">>, TokenMap, <<"">>),
Username = maps:get(<<"username">>, TokenMap, undefined),
TokenType = maps:get(<<"token_type">>, TokenMap, undefined),
Exp = maps:get(<<"exp">>, TokenMap, undefined),
Iat = maps:get(<<"iat">>, TokenMap, undefined),
Nbf = maps:get(<<"nbf">>, TokenMap, undefined),
Sub = maps:get(<<"sub">>, TokenMap, undefined),
Aud = maps:get(<<"aud">>, TokenMap, undefined),
Iss = maps:get(<<"iss">>, TokenMap, undefined),
Jti = maps:get(<<"jti">>, TokenMap, undefined),
ClientId = maps:get(<<"client_id">>, TokenMap, undefined),
{ok, #oidcc_token_introspection{
active = Active,
scope = oidcc_scope:parse(Scope),
client_id = ClientId,
username = Username,
exp = Exp,
token_type = TokenType,
iat = Iat,
nbf = Nbf,
sub = Sub,
aud = Aud,
iss = Iss,
jti = Jti,
extra = maps:without(
[
<<"scope">>,
<<"active">>,
<<"username">>,
<<"exp">>,
<<"client_id">>,
<<"token_type">>,
<<"iat">>,
<<"nbf">>,
<<"sub">>,
<<"aud">>,
<<"iss">>,
<<"jti">>
],
TokenMap
)
}}.