Packages
oidcc
3.8.0-beta.1
3.8.0
3.8.0-beta.1
3.7.2
3.7.1
3.7.0
3.6.0
3.5.2
3.5.1
3.5.0
retired
3.4.0
3.3.0
3.2.6
3.2.5
3.2.4
3.2.3
3.2.2
3.2.1
3.2.0
3.2.0-beta.3
3.2.0-beta.2
retired
3.2.0-beta.1
retired
3.1.2
3.1.2-beta.1
retired
3.1.1
retired
3.1.0
retired
3.1.0-beta.2
retired
3.1.0-beta.1
retired
3.0.2
3.0.1
retired
3.0.0
retired
3.0.0-rc.6
retired
3.0.0-rc.5
retired
3.0.0-rc.4
retired
3.0.0-rc.3
retired
3.0.0-rc.2
retired
3.0.0-rc.1
retired
3.0.0-alpha.5
retired
3.0.0-alpha.4
retired
3.0.0-alpha.3
retired
3.0.0-alpha.2
retired
3.0.0-alpha.1
retired
2.0.0-alpha.2
retired
2.0.0-alpha.1
retired
1.8.1
retired
1.8.0
retired
1.7.0
retired
1.6.0
retired
1.5.1
retired
1.5.0
retired
1.4.0
retired
1.3.0
retired
1.2.1
retired
1.2.0
retired
1.1.0
retired
1.0.1
retired
1.0.0
retired
OpenID Connect client library for the BEAM.
Current section
Files
Jump to
Current section
Files
src/oidcc_http_util.erl
%% SPDX-FileCopyrightText: 2023 Erlang Ecosystem Foundation
%% SPDX-License-Identifier: Apache-2.0
-module(oidcc_http_util).
-feature(maybe_expr, enable).
-moduledoc "HTTP Client Utilities".
-export([basic_auth_header/2]).
-export([bearer_auth_header/1]).
-export([headers_to_cache_deadline/2]).
-export([request/4]).
-export_type([
http_header/0, error/0, httpc_error/0, query_params/0, telemetry_opts/0, request_opts/0
]).
-doc "See `uri_string:compose_query/1`.".
-doc #{since => <<"3.0.0">>}.
-type query_params() :: [{unicode:chardata(), unicode:chardata() | true}].
-doc "HTTP header representation used by OIDCC.".
-doc #{since => <<"3.0.0">>}.
-type http_header() :: {Field :: [byte()] | binary(), Value :: iodata()}.
-doc #{since => <<"3.0.0">>}.
-type error() ::
{http_error, StatusCode :: non_neg_integer(), HttpBodyResult :: binary() | map()}
| {use_dpop_nonce, Nonce :: binary(), HttpBodyResult :: binary() | map()}
| invalid_content_type
| httpc_error().
-doc "Transport error. The default adapter returns errors documented by `httpc:request/5`.".
-doc #{since => <<"3.0.0">>}.
-type httpc_error() :: term().
-doc """
See `httpc:request/5`.
## Parameters
* `timeout` - timeout for request
* `ssl` - TLS config
* `httpc_profile` - `httpc` profile used by the default adapter
* `http_adapter` - `{AdapterModule, AdapterConfigMap}` transport adapter
""".
-doc #{since => <<"3.0.0">>}.
-type request_opts() :: #{
timeout => timeout(),
ssl => [ssl:tls_option()],
httpc_profile => atom() | pid(),
http_adapter => oidcc_http_adapter:config()
}.
-doc #{since => <<"3.0.0">>}.
-type telemetry_opts() :: #{
topic := [atom()],
extra_meta => map()
}.
-doc false.
-spec basic_auth_header(User, Secret) -> http_header() when
User :: binary(),
Secret :: binary().
basic_auth_header(User, Secret) ->
UserEnc = uri_string:compose_query([{User, true}]),
SecretEnc = uri_string:compose_query([{Secret, true}]),
RawAuth = <<UserEnc/binary, <<":">>/binary, SecretEnc/binary>>,
AuthData = base64:encode(RawAuth),
{"authorization", [<<"Basic ">>, AuthData]}.
-doc false.
-spec bearer_auth_header(Token) -> http_header() when Token :: binary().
bearer_auth_header(Token) ->
{"authorization", [<<"Bearer ">>, Token]}.
-doc false.
-spec request(Method, Request, TelemetryOpts, RequestOpts) ->
{ok, {{json, term()} | {jwt, binary()}, [oidcc_http_adapter:header()]}}
| {error, error()}
when
Method :: oidcc_http_adapter:method(),
Request :: oidcc_http_adapter:request(),
TelemetryOpts :: telemetry_opts(),
RequestOpts :: request_opts().
request(Method, Request, TelemetryOpts, RequestOpts) ->
TelemetryTopic = maps:get(topic, TelemetryOpts),
TelemetryExtraMeta = maps:get(extra_meta, TelemetryOpts, #{}),
Timeout = maps:get(timeout, RequestOpts, timer:minutes(1)),
SslOpts = maps:get(ssl, RequestOpts, undefined),
HttpProfile = maps:get(httpc_profile, RequestOpts, default),
{Adapter, AdapterConfig} = maps:get(
http_adapter,
RequestOpts,
{oidcc_http_adapter_httpc, #{profile => HttpProfile}}
),
HttpOpts0 = [{timeout, Timeout}],
HttpOpts =
case SslOpts of
undefined -> HttpOpts0;
_Opts -> [{ssl, SslOpts} | HttpOpts0]
end,
telemetry:span(
TelemetryTopic,
TelemetryExtraMeta,
fun() ->
maybe
{ok, {_StatusLine, Headers, _Result} = Response} ?=
erlang:apply(
Adapter,
request,
[
Method,
Request,
HttpOpts,
[{body_format, binary}],
AdapterConfig
]
),
{ok, BodyAndFormat} ?= extract_successful_response(Response),
{{ok, {BodyAndFormat, Headers}}, TelemetryExtraMeta}
else
{error, Reason} ->
{{error, Reason}, maps:put(error, Reason, TelemetryExtraMeta)}
end
end
).
-spec extract_successful_response({StatusLine, [HttpHeader], HttpBodyResult}) ->
{ok, {json, term()} | {jwt, binary()}} | {error, error()}
when
StatusLine :: {HttpVersion, StatusCode, string()},
HttpVersion :: string(),
StatusCode :: non_neg_integer(),
HttpHeader :: oidcc_http_adapter:header(),
HttpBodyResult :: binary().
extract_successful_response({{_HttpVersion, Status, _HttpStatusName}, Headers, HttpBodyResult}) when
Status == 200 orelse Status == 201
->
case fetch_content_type(Headers) of
json ->
{ok, {json, json:decode(HttpBodyResult)}};
jwt ->
{ok, {jwt, HttpBodyResult}};
unknown ->
{error, invalid_content_type}
end;
extract_successful_response({{_HttpVersion, StatusCode, _HttpStatusName}, Headers, HttpBodyResult}) ->
Body =
case fetch_content_type(Headers) of
json ->
json:decode(HttpBodyResult);
jwt ->
HttpBodyResult;
unknown ->
HttpBodyResult
end,
case proplists:lookup("dpop-nonce", Headers) of
{"dpop-nonce", DpopNonce} ->
{error, {use_dpop_nonce, iolist_to_binary(DpopNonce), Body}};
_ ->
{error, {http_error, StatusCode, Body}}
end.
-spec fetch_content_type(Headers) -> json | jwt | unknown when
Headers :: [oidcc_http_adapter:header()].
fetch_content_type(Headers) ->
case proplists:lookup("content-type", Headers) of
{"content-type", ContentType} ->
case binary:split(iolist_to_binary(ContentType), <<";">>) of
[<<"application/jwt">> | _Rest] ->
jwt;
[MediaType | _Rest] ->
case is_json_content_type(MediaType) of
true ->
json;
false ->
unknown
end
end;
_Other ->
unknown
end.
%% RFC 6838 §4.2.8 structured-suffix syntax: any `application/<subtype>+json'
%% is a JSON document with extra contract on top. Both `application/json' and
%% `application/jwk-set+json' fit, plus less common variants like
%% `application/<vendor>+json'. Match the generic pattern so providers using
%% any `+json' subtype on discovery / JWKS responses are accepted.
-spec is_json_content_type(ContentType :: binary()) -> boolean().
is_json_content_type(ContentType) ->
MediaType = string:lowercase(string:trim(ContentType)),
case MediaType of
<<"application/json">> ->
true;
<<"application/", Subtype/binary>> ->
Size = byte_size(Subtype),
Size >= 5 andalso binary:part(Subtype, Size - 5, 5) =:= <<"+json">>;
_ ->
false
end.
-spec headers_to_cache_deadline(Headers, DefaultExpiry) -> pos_integer() when
Headers :: [oidcc_http_adapter:header()], DefaultExpiry :: non_neg_integer().
headers_to_cache_deadline(Headers, DefaultExpiry) ->
case proplists:lookup("cache-control", Headers) of
{"cache-control", Cache} ->
try
cache_deadline(Cache, DefaultExpiry)
catch
_:_ ->
DefaultExpiry
end;
none ->
DefaultExpiry
end.
-spec cache_deadline(Cache :: iodata(), Fallback :: pos_integer()) -> pos_integer().
cache_deadline(Cache, Fallback) ->
%% RFC 7234 §5.2: cache-control directive names are case-insensitive
%% (`Max-Age', `MAX-AGE', and `max-age' are all valid). Lowercase the
%% whole header before splitting so the `<<"max-age">>' match below
%% catches every spelling.
Lower = string:lowercase(iolist_to_binary(Cache)),
Entries = binary:split(Lower, [<<",">>, <<"=">>, <<" ">>], [global, trim_all]),
clamp_expiry(extract_max_age(Entries, Fallback), Fallback).
%% Walk the cache-control tokens looking for `max-age=<N>' and return N as
%% milliseconds. If the value is missing, zero, or non-numeric, return the
%% caller's fallback.
-spec extract_max_age([binary()], pos_integer()) -> pos_integer().
extract_max_age([<<"max-age">>, Value | _Rest], Fallback) ->
try binary_to_integer(Value) of
N when N > 0 ->
erlang:convert_time_unit(N, second, millisecond);
_ ->
Fallback
catch
_:_ ->
Fallback
end;
extract_max_age([_ | Rest], Fallback) ->
extract_max_age(Rest, Fallback);
extract_max_age([], Fallback) ->
Fallback.
%% `erlang:send_after/3' (used by `oidcc_provider_configuration_worker') and
%% `timer:send_after/2' both accept at most 16#FFFFFFFF ms (~49.7 days).
%% Clamp the cache-derived expiry so an over-eager provider that advertises
%% a longer max-age can never trigger badarg in the caller.
-spec clamp_expiry(term(), pos_integer()) -> pos_integer().
clamp_expiry(Value, _Fallback) when is_integer(Value), Value > 0, Value =< 16#FFFFFFFF ->
Value;
clamp_expiry(Value, _Fallback) when is_integer(Value), Value > 16#FFFFFFFF ->
16#FFFFFFFF;
clamp_expiry(_Value, Fallback) ->
Fallback.