-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. """). ?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 }. ?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 }. ?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}], TrustedAudience = 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, TrustedAudience), 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 {@link oidcc_provider_configuration_worker} %% see {@link oidcc:client_credentials_token/4}. %% %%