-module(oidcc_token).
-feature(maybe_expr, enable).
-include("internal/doc.hrl").
?MODULEDOC("""
Facilitate OpenID Code/Token Exchanges.
## Records
To use the records, import the definition:
```erlang
-include_lib(["oidcc/include/oidcc_token.hrl"]).
```
## Telemetry
See [`Oidcc.Token`](`m:'Elixir.Oidcc.Token'`).
""").
?MODULEDOC(#{since => <<"3.0.0">>}).
-include("oidcc_client_context.hrl").
-include("oidcc_provider_configuration.hrl").
-include("oidcc_token.hrl").
-include_lib("jose/include/jose_jwe.hrl").
-include_lib("jose/include/jose_jwk.hrl").
-include_lib("jose/include/jose_jws.hrl").
-include_lib("jose/include/jose_jwt.hrl").
-export([client_credentials/2]).
-export([jwt_profile/4]).
-export([refresh/3]).
-export([retrieve/3]).
-export([validate_jarm/3]).
-export([validate_id_token/3]).
-export([validate_jwt/3]).
-export([authorization_headers/4]).
-export([authorization_headers/5]).
-export_type([access/0]).
-export_type([authorization_headers_opts/0]).
-export_type([client_credentials_opts/0]).
-export_type([error/0]).
-export_type([id/0]).
-export_type([jwt_profile_opts/0]).
-export_type([refresh/0]).
-export_type([refresh_opts/0]).
-export_type([refresh_opts_no_sub/0]).
-export_type([retrieve_opts/0]).
-export_type([validate_jarm_opts/0]).
-export_type([validate_jwt_opts/0]).
-export_type([t/0]).
?DOC("""
ID Token Wrapper.
## Fields
* `token` - The retrieved token.
* `claims` - Unpacked claims of the verified token.
""").
?DOC(#{since => <<"3.0.0">>}).
-type id() :: #oidcc_token_id{token :: binary(), claims :: oidcc_jwt_util:claims()}.
?DOC("""
Access Token Wrapper.
## Fields
* `token` - The retrieved token.
* `expires` - Number of seconds the token is valid.
""").
?DOC(#{since => <<"3.0.0">>}).
-type access() ::
#oidcc_token_access{token :: binary(), expires :: pos_integer() | undefined, type :: binary()}.
?DOC("""
Refresh Token Wrapper.
## Fields
* `token` - The retrieved token.
""").
?DOC(#{since => <<"3.0.0">>}).
-type refresh() :: #oidcc_token_refresh{token :: binary()}.
?DOC("""
Token Response Wrapper.
## Fields
* `id` - `t:id/0`.
* `access` - `t:access/0`.
* `refresh` - `t:refresh/0`.
* `scope` - `t:oidcc_scope:scopes/0`.
""").
?DOC(#{since => <<"3.0.0">>}).
-type t() ::
#oidcc_token{
id :: oidcc_token:id() | none,
access :: oidcc_token:access() | none,
refresh :: oidcc_token:refresh() | none,
scope :: oidcc_scope:scopes()
}.
?DOC("""
Options for retrieving a token.
See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3.
## Fields
* `pkce_verifier` - PKCE verifier (random string previously given to
`m:oidcc_authorization`), see
https://datatracker.ietf.org/doc/html/rfc7636#section-4.1.
* `require_pkce` - whether to require PKCE when getting the token.
* `nonce` - Nonce to check.
* `scope` - Scope to store with the token.
* `refresh_jwks` - How to handle tokens with an unknown `kid`.
See `t:oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun/0`.
* `redirect_uri` - Redirect URI given to `oidcc_authorization:create_redirect_url/2`.
* `dpop_nonce` - if using DPoP, the `nonce` value to use in the proof claim.
* `trusted_audiences` - if present, a list of additional audience values to
accept. Defaults to `any` which allows any additional values.
* `validate_azp` - if `client_id`, validate that the `azp` claim matches the
client id. If `any`, skip the validation. Defaults to `client_id`. If a
binary or a list of binaries is given, validate that the `azp` claim matches
one of those.
* `token_request_claims` - Additional claims to use with the token request.
""").
?DOC(#{since => <<"3.0.0">>}).
-type retrieve_opts() ::
#{
pkce_verifier => binary(),
require_pkce => boolean(),
nonce => binary() | any,
scope => oidcc_scope:scopes(),
preferred_auth_methods => [oidcc_auth_util:auth_method(), ...],
refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
redirect_uri => uri_string:uri_string(),
request_opts => oidcc_http_util:request_opts(),
url_extension => oidcc_http_util:query_params(),
body_extension => oidcc_http_util:query_params(),
dpop_nonce => binary(),
trusted_audiences => [binary()] | any,
validate_azp => binary() | [binary()] | client_id | any,
token_request_claims => #{binary() => binary() | integer()}
}.
?DOC("See `t:refresh_opts_no_sub/0`.").
?DOC(#{since => <<"3.0.0">>}).
-type refresh_opts_no_sub() ::
#{
scope => oidcc_scope:scopes(),
refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
request_opts => oidcc_http_util:request_opts(),
url_extension => oidcc_http_util:query_params(),
body_extension => oidcc_http_util:query_params()
}.
?DOC(#{since => <<"3.0.0">>}).
-type refresh_opts() ::
#{
scope => oidcc_scope:scopes(),
refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
expected_subject := binary(),
request_opts => oidcc_http_util:request_opts(),
url_extension => oidcc_http_util:query_params(),
body_extension => oidcc_http_util:query_params()
}.
?DOC("""
Options for refreshing a token.
See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3.
## Fields
* `scope` - Scope to store with the token.
* `refresh_jwks` - How to handle tokens with an unknown `kid`.
See `t:oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun/0`.
* `expected_subject` - `sub` of the original token.
""").
?DOC(#{since => <<"3.2.0">>}).
-type validate_jarm_opts() ::
#{
trusted_audiences => [binary()] | any
}.
?DOC(#{since => <<"3.0.0">>}).
-type jwt_profile_opts() :: #{
scope => oidcc_scope:scopes(),
refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
request_opts => oidcc_http_util:request_opts(),
kid => binary(),
url_extension => oidcc_http_util:query_params(),
body_extension => oidcc_http_util:query_params()
}.
?DOC(#{since => <<"3.0.0">>}).
-type client_credentials_opts() :: #{
scope => oidcc_scope:scopes(),
refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
request_opts => oidcc_http_util:request_opts(),
url_extension => oidcc_http_util:query_params(),
body_extension => oidcc_http_util:query_params()
}.
?DOC(#{since => <<"3.0.0">>}).
-type authorization_headers_opts() :: #{
dpop_nonce => binary()
}.
?DOC(#{since => <<"3.2.0">>}).
-type validate_jwt_opts() ::
#{
signing_algs => [binary()] | undefined,
encryption_algs => [binary()] | undefined,
encryption_encs => [binary()] | undefined,
trusted_audiences => [binary()] | any,
refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun()
}.
?DOC(#{since => <<"3.0.0">>}).
-type error() ::
{missing_claim, MissingClaim :: binary(), Claims :: oidcc_jwt_util:claims()}
| pkce_verifier_required
| no_supported_auth_method
| bad_access_token_hash
| sub_invalid
| token_expired
| token_not_yet_valid
| {none_alg_used, Token :: t()}
| {missing_claim, ExpClaim :: {binary(), term()}, Claims :: oidcc_jwt_util:claims()}
| {grant_type_not_supported,
authorization_code | refresh_token | jwt_bearer | client_credentials}
| {invalid_property, {
Field :: id_token | refresh_token | access_token | expires_in | scopes, GivenValue :: term()
}}
| no_supported_code_challenge
| oidcc_jwt_util:error()
| oidcc_http_util:error().
-telemetry_event(#{
event => [oidcc, request_token, start],
description => <<"Emitted at the start of requesting a code token">>,
measurements => <<"#{system_time => non_neg_integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, request_token, stop],
description => <<"Emitted at the end of requesting a code token">>,
measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, request_token, exception],
description => <<"Emitted at the end of requesting a code token">>,
measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, refresh_token, start],
description => <<"Emitted at the start of refreshing a token">>,
measurements => <<"#{system_time => non_neg_integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, refresh_token, stop],
description => <<"Emitted at the end of refreshing a token">>,
measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, refresh_token, exception],
description => <<"Emitted at the end of refreshing a token">>,
measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, jwt_profile_token, start],
description => <<"Emitted at the start of exchanging a JWT profile token">>,
measurements => <<"#{system_time => non_neg_integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, jwt_profile_token, stop],
description => <<"Emitted at the end of exchanging a JWT profile token">>,
measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, jwt_profile_token, exception],
description => <<"Emitted at the end of exchanging a JWT profile token">>,
measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, client_credentials, start],
description => <<"Emitted at the start of exchanging a client credentials token">>,
measurements => <<"#{system_time => non_neg_integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, client_credentials, stop],
description => <<"Emitted at the end of exchanging a client credentials token">>,
measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
-telemetry_event(#{
event => [oidcc, client_credentials, exception],
description => <<"Emitted at the end of exchanging a client credentials token">>,
measurements => <<"#{duration => integer(), monotonic_time => integer()}">>,
metadata => <<"#{issuer => uri_string:uri_string(), client_id => binary()}">>
}).
?DOC("""
Retrieve the token using the authcode received before and directly validate
the result.
The authcode was sent to the local endpoint by the OpenId Connect provider,
using redirects.
For a high level interface using `m:oidcc_provider_configuration_worker`
see `oidcc:retrieve_token/5`.
## Examples
```erlang
{ok, ClientContext} =
oidcc_client_context:from_configuration_worker(provider_name,
<<"client_id">>,
<<"client_secret">>),
%% Get AuthCode from Redirect
{ok, #oidcc_token{}} =
oidcc:retrieve(AuthCode, ClientContext, #{
redirect_uri => <<"https://example.com/callback">>}).
```
""").
?DOC(#{since => <<"3.0.0">>}).
-spec retrieve(AuthCode, ClientContext, Opts) ->
{ok, t()} | {error, error()}
when
AuthCode :: binary(),
ClientContext :: oidcc_client_context:t(),
Opts :: retrieve_opts().
retrieve(AuthCode, ClientContext, Opts) ->
#oidcc_client_context{
provider_configuration = Configuration,
client_id = ClientId
} = ClientContext,
#oidcc_provider_configuration{issuer = Issuer, grant_types_supported = GrantTypesSupported} =
Configuration,
case lists:member(<<"authorization_code">>, GrantTypesSupported) of
true ->
QsBody =
[
{<<"grant_type">>, <<"authorization_code">>},
{<<"code">>, AuthCode},
{<<"redirect_uri">>, maps:get(redirect_uri, Opts)}
],
TelemetryOpts = #{
topic => [oidcc, request_token],
extra_meta => #{issuer => Issuer, client_id => ClientId}
},
maybe
{ok, Token} ?=
retrieve_a_token(
QsBody, ClientContext, Opts, TelemetryOpts, true
),
extract_response(Token, ClientContext, Opts)
end;
false ->
{error, {grant_type_not_supported, authorization_code}}
end.
?DOC("""
Validate the JARM response, returning the valid claims as a map.
The response was sent to the local endpoint by the OpenId Connect provider,
using redirects.
## Examples
```erlang
{ok, ClientContext} =
oidcc_client_context:from_configuration_worker(provider_name,
<<"client_id">>,
<<"client_secret">>),
%% Get Response from Redirect
{ok, #{<<"code">> := AuthCode}} =
oidcc:validate_jarm(Response, ClientContext, #{}),
{ok, #oidcc_token{}} = oidcc:retrieve(AuthCode, ClientContext,
#{redirect_uri => <<"https://redirect.example/">>}).
```
""").
?DOC(#{since => <<"3.2.0">>}).
-spec validate_jarm(Response, ClientContext, Opts) ->
{ok, oidcc_jwt_util:claims()} | {error, error()}
when
Response :: binary(),
ClientContext :: oidcc_client_context:t(),
Opts :: validate_jarm_opts().
validate_jarm(Response, ClientContext, Opts) ->
#oidcc_client_context{
provider_configuration = Configuration,
client_id = ClientId,
client_secret = ClientSecret,
client_jwks = ClientJwks,
jwks = Jwks0
} = ClientContext,
#oidcc_provider_configuration{
issuer = Issuer,
authorization_signing_alg_values_supported = SigningAlgSupported,
authorization_encryption_alg_values_supported = EncryptionAlgSupported,
authorization_encryption_enc_values_supported = EncryptionEncSupported
} =
Configuration,
Jwks1 =
case ClientJwks of
none -> Jwks0;
#jose_jwk{} -> oidcc_jwt_util:merge_jwks(Jwks0, ClientJwks)
end,
Jwks2 = oidcc_jwt_util:merge_client_secret_oct_keys(Jwks1, SigningAlgSupported, ClientSecret),
Jwks = oidcc_jwt_util:merge_client_secret_oct_keys(
Jwks2, EncryptionAlgSupported, ClientSecret
),
ExpClaims = [{<<"iss">>, Issuer}],
TrustedAudiences = maps:get(trusted_audiences, Opts, any),
%% https://openid.net/specs/oauth-v2-jarm-final.html#name-processing-rules
%% 1. decrypt if necessary
%% 2. validate <<"iss">> claim
%% 3. validate <<"aud">> claim
%% 4. validate <<"exp">> claim
%% 5. validate signature (valid, not <<"none">> alg)
%% 6. continue processing
maybe
{ok, {#jose_jwt{fields = Claims}, Jws}} ?=
oidcc_jwt_util:decrypt_and_verify(
Response, Jwks, SigningAlgSupported, EncryptionAlgSupported, EncryptionEncSupported
),
ok ?= oidcc_jwt_util:verify_claims(Claims, ExpClaims),
ok ?= verify_aud_claim(Claims, ClientId, TrustedAudiences),
ok ?= verify_exp_claim(Claims),
ok ?= verify_nbf_claim(Claims),
ok ?= oidcc_jwt_util:verify_not_none_alg(Jws),
{ok, Claims}
end.
?DOC("""
Refresh Token
For a high level interface using `m:oidcc_provider_configuration_worker`
see `oidcc:refresh_token/5`.
## Examples
```erlang
{ok, ClientContext} =
oidcc_client_context:from_configuration_worker(provider_name,
<<"client_id">>,
<<"client_secret">>),
%% Get AuthCode from Redirect
{ok, Token} =
oidcc_token:retrieve(AuthCode, ClientContext, #{
redirect_uri => <<"https://example.com/callback">>}).
%% Later
{ok, #oidcc_token{}} =
oidcc_token:refresh(Token,
ClientContext,
#{expected_subject => <<"sub_from_initial_id_token">>}).
```
""").
?DOC(#{since => <<"3.0.0">>}).
-spec refresh
(RefreshToken, ClientContext, Opts) ->
{ok, t()} | {error, error()}
when
RefreshToken :: binary(),
ClientContext :: oidcc_client_context:t(),
Opts :: refresh_opts();
(Token, ClientContext, Opts) ->
{ok, t()} | {error, error()}
when
Token :: oidcc_token:t(),
ClientContext :: oidcc_client_context:t(),
Opts :: refresh_opts_no_sub().
refresh(
#oidcc_token{
refresh = #oidcc_token_refresh{token = RefreshToken},
id = #oidcc_token_id{claims = #{<<"sub">> := ExpectedSubject}}
},
ClientContext,
Opts
) ->
refresh(RefreshToken, ClientContext, maps:put(expected_subject, ExpectedSubject, Opts));
refresh(RefreshToken, ClientContext, Opts) ->
#oidcc_client_context{
provider_configuration = Configuration,
client_id = ClientId
} = ClientContext,
#oidcc_provider_configuration{issuer = Issuer, grant_types_supported = GrantTypesSupported} =
Configuration,
case lists:member(<<"refresh_token">>, GrantTypesSupported) of
true ->
ExpectedSub = maps:get(expected_subject, Opts),
Scope = maps:get(scope, Opts, []),
QueryString =
[{<<"refresh_token">>, RefreshToken}, {<<"grant_type">>, <<"refresh_token">>}],
QueryString1 = oidcc_scope:query_append_scope(Scope, QueryString),
TelemetryOpts = #{
topic => [oidcc, refresh_token],
extra_meta => #{issuer => Issuer, client_id => ClientId}
},
maybe
{ok, Token} ?=
retrieve_a_token(QueryString1, ClientContext, Opts, TelemetryOpts, true),
{ok, TokenRecord} ?=
extract_response(Token, ClientContext, maps:put(nonce, any, Opts)),
case TokenRecord of
#oidcc_token{id = #oidcc_token_id{claims = #{<<"sub">> := ExpectedSub}}} ->
{ok, TokenRecord};
#oidcc_token{} ->
{error, sub_invalid}
end
end;
false ->
{error, {grant_type_not_supported, refresh_token}}
end.
?DOC("""
Retrieve JSON Web Token (JWT) Profile Token
See [https://datatracker.ietf.org/doc/html/rfc7523#section-4]
For a high level interface using {@link oidcc_provider_configuration_worker}
see {@link oidcc:jwt_profile_token/6}.
## Examples
```erlang
{ok, ClientContext} =
oidcc_client_context:from_configuration_worker(provider_name,
<<"client_id">>,
<<"client_secret">>),
{ok, KeyJson} = file:read_file("jwt-profile.json"),
KeyMap = jose:decode(KeyJson),
Key = jose_jwk:from_pem(maps:get(<<"key">>, KeyMap)),
{ok, #oidcc_token{}} =
oidcc_token:jwt_profile(<<"subject">>,
ClientContext,
Key,
#{scope => [<<"scope">>],
kid => maps:get(<<"keyId">>, KeyMap)}).
```
""").
?DOC(#{since => <<"3.0.0">>}).
-spec jwt_profile(Subject, ClientContext, Jwk, Opts) -> {ok, t()} | {error, error()} when
Subject :: binary(),
ClientContext :: oidcc_client_context:t(),
Jwk :: jose_jwk:key(),
Opts :: jwt_profile_opts().
jwt_profile(Subject, ClientContext, Jwk, Opts) ->
#oidcc_client_context{provider_configuration = Configuration, client_id = ClientId} =
ClientContext,
#oidcc_provider_configuration{issuer = Issuer, grant_types_supported = GrantTypesSupported} =
Configuration,
case lists:member(<<"urn:ietf:params:oauth:grant-type:jwt-bearer">>, GrantTypesSupported) of
true ->
Iat = os:system_time(seconds),
Exp = Iat + 60,
AssertionClaims = #{
<<"iss">> => Subject,
<<"sub">> => Subject,
<<"aud">> => [Issuer],
<<"exp">> => Exp,
<<"iat">> => Iat,
<<"nbf">> => Iat
},
AssertionJwt = jose_jwt:from(AssertionClaims),
AssertionJws0 = #{
<<"alg">> => <<"RS256">>,
<<"typ">> => <<"JWT">>
},
AssertionJws =
case maps:get(kid, Opts, none) of
none -> AssertionJws0;
Kid -> maps:put(<<"kid">>, Kid, AssertionJws0)
end,
{_Jws, Assertion} = jose_jws:compact(jose_jwt:sign(Jwk, AssertionJws, AssertionJwt)),
Scope = maps:get(scope, Opts, []),
QueryString =
[
{<<"assertion">>, Assertion},
{<<"grant_type">>, <<"urn:ietf:params:oauth:grant-type:jwt-bearer">>}
],
QueryString1 = oidcc_scope:query_append_scope(Scope, QueryString),
TelemetryOpts = #{
topic => [oidcc, jwt_profile_token],
extra_meta => #{issuer => Issuer, client_id => ClientId}
},
maybe
{ok, Token} ?=
retrieve_a_token(QueryString1, ClientContext, Opts, TelemetryOpts, false),
{ok, TokenRecord} ?=
extract_response(Token, ClientContext, maps:put(nonce, any, Opts)),
case TokenRecord of
#oidcc_token{id = none} ->
{ok, TokenRecord};
#oidcc_token{id = #oidcc_token_id{claims = #{<<"sub">> := Subject}}} ->
{ok, TokenRecord};
#oidcc_token{} ->
{error, sub_invalid}
end
end;
false ->
{error, {grant_type_not_supported, jwt_bearer}}
end.
?DOC("""
Retrieve Client Credential Token
See https://datatracker.ietf.org/doc/html/rfc6749#section-1.3.4
For a high level interface using `m:oidcc_provider_configuration_worker`
see `oidcc:client_credentials_token/4`.
## Examples
```erlang
{ok, ClientContext} =
oidcc_client_context:from_configuration_worker(provider_name,
<<"client_id">>,
<<"client_secret">>),
{ok, #oidcc_token{}} =
oidcc_token:client_credentials(ClientContext,
#{scope => [<<"scope">>]}).
```
""").
?DOC(#{since => <<"3.0.0">>}).
-spec client_credentials(ClientContext, Opts) -> {ok, t()} | {error, error()} when
ClientContext :: oidcc_client_context:authenticated_t(),
Opts :: client_credentials_opts().
client_credentials(ClientContext, Opts) ->
#oidcc_client_context{
provider_configuration = Configuration,
client_id = ClientId
} = ClientContext,
#oidcc_provider_configuration{issuer = Issuer, grant_types_supported = GrantTypesSupported} =
Configuration,
case lists:member(<<"client_credentials">>, GrantTypesSupported) of
true ->
Scope = maps:get(scope, Opts, []),
QueryString = [{<<"grant_type">>, <<"client_credentials">>}],
QueryString1 = oidcc_scope:query_append_scope(Scope, QueryString),
TelemetryOpts = #{
topic => [oidcc, client_credentials],
extra_meta => #{issuer => Issuer, client_id => ClientId}
},
maybe
{ok, Token} ?=
retrieve_a_token(QueryString1, ClientContext, Opts, TelemetryOpts, true),
extract_response(Token, ClientContext, maps:put(nonce, any, Opts))
end;
false ->
{error, {grant_type_not_supported, client_credentials}}
end.
-spec extract_response(TokenResponseBody, ClientContext, Opts) ->
{ok, t()} | {error, error()}
when
TokenResponseBody :: map(),
ClientContext :: oidcc_client_context:t(),
Opts :: retrieve_opts().
extract_response(TokenResponseBody, ClientContext, Opts) ->
maybe
{ok, Scopes} ?= extract_scope(TokenResponseBody, Opts),
{ok, AccessExpire} ?= extract_expiry(TokenResponseBody),
{ok, AccessTokenRecord} ?= extract_access_token(TokenResponseBody, AccessExpire),
{ok, RefreshTokenRecord} ?= extract_refresh_token(TokenResponseBody),
{ok, {IdTokenRecord, NoneUsed}} ?= extract_id_token(TokenResponseBody, ClientContext, Opts),
TokenRecord = #oidcc_token{
id = IdTokenRecord,
access = AccessTokenRecord,
refresh = RefreshTokenRecord,
scope = Scopes
},
ok ?= verify_access_token_map_hash(TokenRecord),
%% If none alg was used, continue with checks to allow the user to decide
%% if he wants to use the result
case NoneUsed of
true ->
{error, {none_alg_used, TokenRecord}};
false ->
{ok, TokenRecord}
end
end.
-spec extract_scope(TokenMap, Opts) -> {ok, oidcc_scope:scopes()} | {error, error()} when
TokenMap :: map(), Opts :: retrieve_opts().
extract_scope(TokenMap, Opts) ->
Scopes = maps:get(scope, Opts, []),
case maps:get(<<"scope">>, TokenMap, oidcc_scope:scopes_to_bin(Scopes)) of
ScopeBinary when is_binary(ScopeBinary) ->
{ok, oidcc_scope:parse(ScopeBinary)};
%% Some providers (e.g. Apple and Twitch) are setting the scope value
%% as list of string. This extends compatibility for those.
ScopeList when is_list(ScopeList) ->
{ok, ScopeList};
ScopeOther ->
{error, {invalid_property, {scope, ScopeOther}}}
end.
-spec extract_expiry(TokenMap) -> {ok, undefined | integer()} | {error, error()} when
TokenMap :: map().
extract_expiry(TokenMap) ->
case maps:get(<<"expires_in">>, TokenMap, undefined) of
undefined ->
{ok, undefined};
ExpiresInNum when is_integer(ExpiresInNum) ->
{ok, ExpiresInNum};
ExpiresInBinary when is_binary(ExpiresInBinary) ->
try
{ok, binary_to_integer(ExpiresInBinary)}
catch
error:badarg ->
{error, {invalid_property, {expires_in, ExpiresInBinary}}}
end;
ExpiresInOther ->
{error, {invalid_property, {expires_in, ExpiresInOther}}}
end.
-spec extract_access_token(TokenMap, Expiry) -> {ok, access()} | {error, error()} when
TokenMap :: map(),
Expiry :: integer().
extract_access_token(TokenMap, Expiry) ->
case maps:get(<<"access_token">>, TokenMap, none) of
none ->
{ok, none};
Token when is_binary(Token) ->
TokenType = maps:get(<<"token_type">>, TokenMap, <<"Bearer">>),
{ok, #oidcc_token_access{token = Token, expires = Expiry, type = TokenType}};
Other ->
{error, {invalid_property, {access_token, Other}}}
end.
-spec extract_refresh_token(TokenMap) -> {ok, refresh()} | {error, error()} when
TokenMap :: map().
extract_refresh_token(TokenMap) ->
case maps:get(<<"refresh_token">>, TokenMap, none) of
none ->
{ok, none};
Token when is_binary(Token) ->
{ok, #oidcc_token_refresh{token = Token}};
Other ->
{error, {invalid_property, {refresh_token, Other}}}
end.
-spec extract_id_token(TokenMap, ClientContext, Opts) ->
{ok, {TokenRecord, NoneUsed}} | {error, error()}
when
TokenMap :: map(),
ClientContext :: oidcc_client_context:t(),
Opts :: retrieve_opts(),
TokenRecord :: id(),
NoneUsed :: boolean().
extract_id_token(TokenMap, ClientContext, Opts) ->
case maps:get(<<"id_token">>, TokenMap, none) of
none ->
{ok, {none, false}};
Token when is_binary(Token) ->
case validate_id_token(Token, ClientContext, Opts) of
{ok, OkClaims} ->
{ok, {#oidcc_token_id{token = Token, claims = OkClaims}, false}};
{error, {none_alg_used, NoneClaims}} ->
{ok, {#oidcc_token_id{token = Token, claims = NoneClaims}, true}};
{error, Reason} ->
{error, Reason}
end;
Other ->
{error, {invalid_property, {id_token, Other}}}
end.
-spec verify_access_token_map_hash(TokenRecord :: t()) ->
ok | {error, error()}.
verify_access_token_map_hash(#oidcc_token{
id =
#oidcc_token_id{
claims =
#{<<"at_hash">> := ExpectedHash}
},
access = #oidcc_token_access{token = AccessToken}
}) ->
<<BinHash:16/binary, _Rest/binary>> = crypto:hash(sha256, AccessToken),
case base64:encode(BinHash, #{mode => urlsafe, padding => false}) of
ExpectedHash ->
ok;
_Other ->
{error, bad_access_token_hash}
end;
verify_access_token_map_hash(#oidcc_token{}) ->
ok.
?DOC("""
Validate ID Token
Usually the id token is validated using `retrieve/3`.
If you get the token passed from somewhere else, this function can validate it.
## Validations
* `iss` claim must match the issuer of the provider, or match the regex pattern if `issuer_regex` is configured in quirks.
* `nonce` claim must match the `nonce` option.
* `aud` claim must match the `trusted_audiences` option.
* `exp` claim must be in the future.
* `nbf` claim must be in the past.
* `azp` claim must match the client id by default. This can be disabled by setting the `validate_azp` option to `any`.
If the `validate_azp` option is set to a `binary()` or a list of binaries, the `azp` claim must match one of those values.
## Examples
```erlang
{ok, ClientContext} =
oidcc_client_context:from_configuration_worker(provider_name,
<<"client_id">>,
<<"client_secret">>),
%% Get IdToken from somewhere
{ok, Claims} =
oidcc:validate_id_token(IdToken, ClientContext, ExpectedNonce).
```
## Regex Issuer Validation
You can use a regex pattern to validate the issuer claim by adding an `issuer_regex`
to the quirks map when creating the provider configuration. See the documentation for `validate_jwt/3`
for more details.
""").
?DOC(#{since => <<"3.0.0">>}).
-spec validate_id_token(IdToken, ClientContext, NonceOrOpts) ->
{ok, Claims} | {error, error()}
when
IdToken :: binary(),
ClientContext :: oidcc_client_context:t(),
NonceOrOpts :: Nonce | retrieve_opts(),
Nonce :: binary() | any,
Claims :: oidcc_jwt_util:claims().
validate_id_token(IdToken, ClientContext, Nonce) when is_binary(Nonce) ->
validate_id_token(IdToken, ClientContext, #{nonce => Nonce});
validate_id_token(IdToken, ClientContext, any) ->
validate_id_token(IdToken, ClientContext, #{nonce => any});
validate_id_token(IdToken, ClientContext, Opts) when is_map(Opts) ->
#oidcc_client_context{
provider_configuration = Configuration,
client_id = ClientId
} =
ClientContext,
#oidcc_provider_configuration{
id_token_signing_alg_values_supported = AllowAlgorithms,
id_token_encryption_alg_values_supported = EncryptionAlgs,
id_token_encryption_enc_values_supported = EncryptionEncs
} =
Configuration,
ValidateOpts = maps:merge(Opts, #{
signing_algs => AllowAlgorithms,
encryption_algs => EncryptionAlgs,
encryption_encs => EncryptionEncs
}),
Nonce = maps:get(nonce, Opts, any),
ExpClaims =
case Nonce of
any -> [];
Bin when is_binary(Bin) -> [{<<"nonce">>, Nonce}]
end,
ValidateAzp =
case maps:get(validate_azp, Opts, client_id) of
client_id -> [ClientId];
any -> any;
Binary when is_binary(Binary) -> [Binary];
List when is_list(List) -> List
end,
validate_jwt(IdToken, ClientContext, ValidateOpts, fun(Claims) ->
maybe
ok ?= oidcc_jwt_util:verify_claims(Claims, ExpClaims),
ok ?= verify_missing_required_claims(Claims),
ok ?= verify_azp_claim(Claims, ValidateAzp),
ok
end
end).
?DOC("""
Validate JWT
Validates a generic JWT (such as an access token) from the given provider.
Useful if the issuer is shared between multiple applications, and the access token
generated for a user at one client is used to validate their access at another client.
Validating an arbitrary JWT token (not an ID token) is not covered by the OpenID
Connect specification. Therefore the signing / encryption algorithms are not
derieved from the provider configuration, but must be provided by the caller.
## Validations
* `iss` claim must match the issuer of the provider, or match the regex pattern if `issuer_regex` is configured in quirks.
* `aud` claim must match the `trusted_audiences` option.
* `exp` claim must be in the future.
* `nbf` claim must be in the past.
## Examples
```erlang
{ok, ClientContext} =
oidcc_client_context:from_configuration_worker(provider_name,
<<"client_id">>,
<<"client_secret">>),
%% Get Jwt from Authorization header
Jwt = <<"jwt">>,
Opts = #{
signing_algs => [<<"RS256">>]
},
{ok, Claims} =
oidcc:validate_jwt(Jwt, ClientContext, Opts).
```
## Regex Issuer Validation
You can use a regex pattern to validate the issuer claim by adding an `issuer_regex`
to the quirks map when creating the provider configuration:
```erlang
{ok, {ProviderConfig, _}} =
oidcc_provider_configuration:load_configuration(Issuer, #{
quirks => #{
issuer_regex => <<"^https://accounts\\.example\\.com/[a-z0-9]+">>
}
}),
```
This will allow tokens with issuer claims that match the regex pattern to validate successfully.
""").
?DOC(#{since => <<"3.2.0">>}).
-spec validate_jwt(Token, ClientContext, Opts) ->
{ok, Claims} | {error, error()}
when
Token :: binary(),
ClientContext :: oidcc_client_context:t(),
Opts :: validate_jwt_opts(),
Claims :: oidcc_jwt_util:claims().
validate_jwt(Token, ClientContext, Opts) when is_map(Opts) ->
validate_jwt(Token, ClientContext, Opts, fun(_Claims) -> ok end).
-spec validate_jwt(Token, ClientContext, Opts, AdditionalClaimValidation) ->
{ok, Claims} | {error, error()}
when
Token :: binary(),
ClientContext :: oidcc_client_context:t(),
Opts :: validate_jwt_opts(),
Claims :: oidcc_jwt_util:claims(),
AdditionalClaimValidation :: fun((Claims) -> ok | {error, error()}).
validate_jwt(Token, ClientContext, Opts, AdditionalClaimValidation) ->
RefreshJwksFun = maps:get(refresh_jwks, Opts, undefined),
unknown_kid_retry(
fun(RefreshedClientContext) ->
int_validate_jwt(
Token, RefreshedClientContext, Opts, AdditionalClaimValidation
)
end,
ClientContext,
RefreshJwksFun
).
-spec int_validate_jwt(Token, ClientContext, Opts, AdditionalClaimValidation) ->
{ok, Claims} | {error, error()}
when
Token :: binary(),
ClientContext :: oidcc_client_context:t(),
Opts :: validate_jwt_opts(),
Claims :: oidcc_jwt_util:claims(),
AdditionalClaimValidation :: fun((Claims) -> ok | {error, error()}).
int_validate_jwt(Token, ClientContext, Opts, AdditionalClaimValidation) ->
#oidcc_client_context{
provider_configuration = Configuration,
jwks = #jose_jwk{} = Jwks0,
client_id = ClientId,
client_secret = ClientSecret,
client_jwks = ClientJwks
} =
ClientContext,
#oidcc_provider_configuration{
issuer = Issuer,
issuer_regex = IssuerRegex
} =
Configuration,
SigningAlgs = maps:get(signing_algs, Opts, []),
EncryptionAlgs = maps:get(encryption_algs, Opts, []),
EncryptionEncs = maps:get(encryption_encs, Opts, []),
case {SigningAlgs, EncryptionAlgs} of
{[], []} ->
error(badarg, [Token, ClientContext, Opts], []);
_ ->
ok
end,
Jwks1 =
case ClientJwks of
none -> Jwks0;
#jose_jwk{} -> oidcc_jwt_util:merge_jwks(Jwks0, ClientJwks)
end,
Jwks2 = oidcc_jwt_util:merge_client_secret_oct_keys(Jwks1, SigningAlgs, ClientSecret),
Jwks = oidcc_jwt_util:merge_client_secret_oct_keys(Jwks2, EncryptionAlgs, ClientSecret),
TrustedAudiences = maps:get(trusted_audiences, Opts, any),
maybe
{ok, {#jose_jwt{fields = Claims}, Jws}} ?=
rescue_none_validated_jwt(
oidcc_jwt_util:decrypt_and_verify(
Token, Jwks, SigningAlgs, EncryptionAlgs, EncryptionEncs
)
),
ExpectedClaims =
case IssuerRegex of
undefined ->
[{<<"iss">>, Issuer}];
Pattern ->
[{<<"iss">>, {regex, Pattern}}]
end,
ok ?= oidcc_jwt_util:verify_claims(Claims, ExpectedClaims),
ok ?= verify_missing_required_claims(Claims),
ok ?= verify_aud_claim(Claims, ClientId, TrustedAudiences),
ok ?= verify_exp_claim(Claims),
ok ?= verify_nbf_claim(Claims),
ok ?= AdditionalClaimValidation(Claims),
case Jws of
#jose_jws{alg = {jose_jws_alg_none, none}} ->
{error, {none_alg_used, Claims}};
#jose_jws{} ->
{ok, Claims};
#jose_jwe{} ->
{ok, Claims}
end
end.
?DOC("""
Authorization headers
Generate a map of authorization headers to use when using the given
access token to access an API endpoint.
## Examples
```erlang
{ok, ClientContext} =
oidcc_client_context:from_configuration_worker(provider_name,
<<"client_id">>,
<<"client_secret">>),
%% Get Access Token record from somewhere
Headers =
oidcc:authorization_headers(AccessTokenRecord, :get, Url, ClientContext).
```
""").
?DOC(#{since => "3.2.0"}).
-spec authorization_headers(AccessTokenRecord, Method, Endpoint, ClientContext) -> HeaderMap when
AccessTokenRecord :: access(),
Method :: post | get,
Endpoint :: uri_string:uri_string(),
ClientContext :: oidcc_client_context:t(),
HeaderMap :: #{binary() => binary()}.
-spec authorization_headers(AccessTokenRecord, Method, Endpoint, ClientContext, Opts) ->
HeaderMap
when
AccessTokenRecord :: access(),
Method :: post | get,
Endpoint :: uri_string:uri_string(),
ClientContext :: oidcc_client_context:t(),
Opts :: authorization_headers_opts(),
HeaderMap :: #{binary() => binary()}.
authorization_headers(AccessTokenRecord, Method, Endpoint, ClientContext) ->
authorization_headers(AccessTokenRecord, Method, Endpoint, ClientContext, #{}).
authorization_headers(
#oidcc_token_access{} = AccessTokenRecord,
Method,
Endpoint,
#oidcc_client_context{} = ClientContext,
Opts
) ->
#oidcc_token_access{token = AccessToken, type = AccessTokenType} = AccessTokenRecord,
Header = oidcc_auth_util:add_authorization_header(
AccessToken, AccessTokenType, Method, Endpoint, Opts, ClientContext
),
maps:from_list([{list_to_binary(Key), list_to_binary([Value])} || {Key, Value} <- Header]).
-spec verify_aud_claim(Claims, ClientId, TrustedAudiences) -> ok | {error, error()} when
Claims :: oidcc_jwt_util:claims(), ClientId :: binary(), TrustedAudiences :: [binary()] | any.
verify_aud_claim(#{<<"aud">> := ClientId}, ClientId, _TrustedAudiences) ->
ok;
verify_aud_claim(#{<<"aud">> := Audience} = Claims, ClientId, any) when is_list(Audience) ->
case lists:member(ClientId, Audience) of
true -> ok;
false -> {error, {missing_claim, {<<"aud">>, ClientId}, Claims}}
end;
verify_aud_claim(#{<<"aud">> := Audience} = Claims, ClientId, TrustedAudiences0) when
is_list(Audience)
->
TrustedAudiences = [ClientId | TrustedAudiences0],
maybe
true ?= lists:member(ClientId, Audience),
[] ?= [A || A <- Audience, not lists:member(A, TrustedAudiences)],
ok
else
_ -> {error, {missing_claim, {<<"aud">>, ClientId}, Claims}}
end;
verify_aud_claim(Claims, ClientId, _TrustedAudiences) ->
{error, {missing_claim, {<<"aud">>, ClientId}, Claims}}.
-spec verify_azp_claim(Claims, Mode) -> ok | {error, error()} when
Claims :: oidcc_jwt_util:claims(), Mode :: [binary()] | any.
verify_azp_claim(_Claims, any) ->
ok;
verify_azp_claim(#{<<"azp">> := Azp}, AllowedAzp) when is_list(AllowedAzp) ->
case lists:member(Azp, AllowedAzp) of
true -> ok;
false -> {error, {missing_claim, {<<"azp">>, AllowedAzp}, #{<<"azp">> => Azp}}}
end;
verify_azp_claim(_, _Mode) ->
ok.
-spec verify_exp_claim(Claims) -> ok | {error, error()} when Claims :: oidcc_jwt_util:claims().
verify_exp_claim(#{<<"exp">> := Expiry}) ->
MaxClockSkew =
case application:get_env(oidcc, max_clock_skew) of
undefined -> 0;
{ok, ClockSkew} -> ClockSkew
end,
case erlang:system_time(second) > Expiry + MaxClockSkew of
true -> {error, token_expired};
false -> ok
end;
verify_exp_claim(Claims) ->
{error, {missing_claim, <<"exp">>, Claims}}.
-spec verify_nbf_claim(Claims) -> ok | {error, error()} when Claims :: oidcc_jwt_util:claims().
verify_nbf_claim(#{<<"nbf">> := Expiry}) ->
MaxClockSkew =
case application:get_env(oidcc, max_clock_skew) of
undefined -> 0;
{ok, ClockSkew} -> ClockSkew
end,
case erlang:system_time(second) < Expiry - MaxClockSkew of
true -> {error, token_not_yet_valid};
false -> ok
end;
verify_nbf_claim(_Claims) ->
ok.
-spec verify_missing_required_claims(Claims) -> ok | {error, error()} when
Claims :: oidcc_jwt_util:claims().
verify_missing_required_claims(Claims) ->
Required = [<<"iss">>, <<"sub">>, <<"aud">>, <<"exp">>, <<"iat">>],
CheckKeys = fun(Key, _Val, Acc) -> lists:delete(Key, Acc) end,
case maps:fold(CheckKeys, Required, Claims) of
[] ->
ok;
[MissingClaim | _Rest] ->
{error, {missing_claim, MissingClaim, Claims}}
end.
-spec retrieve_a_token(
QsBodyIn, ClientContext, Opts, TelemetryOpts, AuthenticateClient
) ->
{ok, map()} | {error, error()}
when
QsBodyIn :: oidcc_http_util:query_params(),
ClientContext :: oidcc_client_context:t(),
Opts :: retrieve_opts() | refresh_opts(),
TelemetryOpts :: oidcc_http_util:telemetry_opts(),
AuthenticateClient :: boolean().
retrieve_a_token(QsBodyIn, ClientContext, Opts, TelemetryOpts, AuthenticateClient) ->
#oidcc_client_context{provider_configuration = Configuration} =
ClientContext,
#oidcc_provider_configuration{
token_endpoint = TokenEndpoint0,
token_endpoint_auth_methods_supported = SupportedAuthMethods0,
token_endpoint_auth_signing_alg_values_supported = SigningAlgs
} =
Configuration,
QueryParams = maps:get(url_extension, Opts, []),
Header0 = [{"accept", "application/jwt, application/json"}],
QsBody0 = QsBodyIn ++ maps:get(body_extension, Opts, []),
SupportedAuthMethods =
case AuthenticateClient of
true -> SupportedAuthMethods0;
false -> [<<"none">>]
end,
DpopOpts =
case Opts of
#{dpop_nonce := DpopNonce} ->
#{nonce => DpopNonce};
_ ->
#{}
end,
maybe
{ok, QsBody} ?= add_pkce_verifier(QsBody0, Opts, ClientContext),
{ok, {Body, Header1}, AuthMethod} ?=
oidcc_auth_util:add_client_authentication(
QsBody, Header0, SupportedAuthMethods, SigningAlgs, Opts, ClientContext
),
TokenEndpoint = oidcc_auth_util:maybe_mtls_endpoint(
TokenEndpoint0, AuthMethod, <<"token_endpoint">>, ClientContext
),
Endpoint =
case QueryParams of
[] -> TokenEndpoint;
_ -> [TokenEndpoint, <<"?">>, uri_string:compose_query(QueryParams)]
end,
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)},
RequestOpts = maps:get(request_opts, Opts, #{}),
{ok, {{json, TokenResponse}, _Headers}} ?=
oidcc_http_util:request(post, Request, TelemetryOpts, RequestOpts),
{ok, TokenResponse}
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)
retrieve_a_token(
QsBodyIn,
ClientContext,
Opts#{dpop_nonce => NewDpopNonce},
TelemetryOpts,
AuthenticateClient
);
{error, Reason} ->
{error, Reason}
end.
-spec add_pkce_verifier(QueryList, Opts, ClientContext) ->
{ok, oidcc_http_util:query_params()} | {error, error()}
when
QueryList :: oidcc_http_util:query_params(),
Opts :: retrieve_opts() | refresh_opts(),
ClientContext :: oidcc_client_context:t().
add_pkce_verifier(BodyQs, #{pkce_verifier := PkceVerifier} = Opts, ClientContext) ->
#oidcc_client_context{provider_configuration = ProviderConfiguration} = ClientContext,
#oidcc_provider_configuration{code_challenge_methods_supported = CodeChallengeMethodsSupported} =
ProviderConfiguration,
RequirePkce = maps:get(require_pkce, Opts, false),
case CodeChallengeMethodsSupported of
undefined when RequirePkce ->
{error, no_supported_code_challenge};
undefined ->
{ok, BodyQs};
Methods when is_list(Methods) ->
case
lists:member(<<"S256">>, CodeChallengeMethodsSupported) or
lists:member(<<"plain">>, CodeChallengeMethodsSupported)
of
true ->
{ok, [{<<"code_verifier">>, PkceVerifier} | BodyQs]};
false when RequirePkce ->
{error, no_supported_code_challenge};
false ->
{ok, BodyQs}
end
end;
add_pkce_verifier(_BodyQs, #{require_pkce := true}, _ClientContext) ->
{error, pkce_verifier_required};
add_pkce_verifier(BodyQs, _Opts, _ClientContext) ->
{ok, BodyQs}.
-spec rescue_none_validated_jwt(Result) -> Response when
Response :: {ok, {#jose_jwt{}, #jose_jwe{} | #jose_jws{}}} | {error, oidcc_jwt_util:error()},
Result :: {ok, {#jose_jwt{}, #jose_jwe{} | #jose_jws{}}} | {error, oidcc_jwt_util:error()}.
rescue_none_validated_jwt({ok, Valid}) ->
{ok, Valid};
rescue_none_validated_jwt({error, {none_alg_used, Jwt0, Jws0}}) ->
{ok, {Jwt0, Jws0}};
rescue_none_validated_jwt(Other) ->
Other.
-spec unknown_kid_retry(Function, ClientContext, RefreshJwksFun) ->
{ok, Result} | {error, Error}
when
Function :: fun((ClientContext) -> {ok, Result} | {error, Error}),
ClientContext :: oidcc_client_context:t(),
RefreshJwksFun :: undefined | oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(),
Result :: term(),
Error :: term().
unknown_kid_retry(Function, ClientContext, RefreshJwksFun) ->
maybe
{ok, Result} ?= Function(ClientContext),
{ok, Result}
else
{error, {no_matching_key_with_kid, Kid}} when RefreshJwksFun =/= undefined ->
#oidcc_client_context{jwks = OldJwks} = ClientContext,
maybe
{ok, RefreshedJwks} ?= RefreshJwksFun(OldJwks, Kid),
RefreshedClientContext = ClientContext#oidcc_client_context{jwks = RefreshedJwks},
Function(RefreshedClientContext)
end;
{error, Reason} ->
{error, Reason}
end.