%%%------------------------------------------------------------------- %% @doc Facilitate OpenID Code/Token Exchanges %% @end %%%------------------------------------------------------------------- -module(oidcc_token). -feature(maybe_expr, enable). -include("oidcc_client_context.hrl"). -include("oidcc_provider_configuration.hrl"). -include("oidcc_token.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_id_token/3]). -export_type([access/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([t/0]). -type id() :: #oidcc_token_id{token :: binary(), claims :: oidcc_jwt_util:claims()}. %% ID Token Wrapper %% %%

Fields

%% %% -type access() :: #oidcc_token_access{token :: binary(), expires :: pos_integer() | undefined}. %% Access Token Wrapper %% %%

Fields

%% %% -type refresh() :: #oidcc_token_refresh{token :: binary()}. %% Refresh Token Wrapper %% %%

Fields

%% %% -type t() :: #oidcc_token{ id :: oidcc_token:id() | none, access :: oidcc_token:access() | none, refresh :: oidcc_token:refresh() | none, scope :: oidcc_scope:scopes() }. %% Token Response Wrapper %% %%

Fields

%% %% -type pkce() :: #{verifier := binary()}. -type retrieve_opts() :: #{ pkce => pkce(), nonce => binary() | any, scope => oidcc_scope:scopes(), refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(), redirect_uri := uri_string:uri_string(), request_opts => oidcc_http_util:request_opts() }. %% Options for retrieving a token %% %% See [https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3] %% %%

Fields

%% %% -type refresh_opts_no_sub() :: #{ scope => oidcc_scope:scopes(), refresh_jwks => oidcc_jwt_util:refresh_jwks_for_unknown_kid_fun(), expected_subject := binary() }. %% See {@link refresh_opts_no_sub()} -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() }. %% Options for refreshing a token %% %% See [https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3] %% %%

Fields

%% %% -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() }. -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() }. -type error() :: {missing_claim, MissingClaim :: binary(), Claims :: oidcc_jwt_util:claims()} | bad_access_token_hash | sub_invalid | {none_alg_used, NoneClaims :: oidcc_jwt_util:claims()} | {grant_type_not_supported, authorization_code | refresh_token | jwt_bearer | client_credentials} | oidcc_jwt_util:error() | oidcc_http_util:error(). %% @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 {@link oidcc_provider_configuration_worker} %% see {@link oidcc:retrieve_token/5}. %% %%

Examples

%% %% ``` %% {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">>}). %% ''' %% @end -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 -> Pkce = maps:get(pkce, Opts, undefined), 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, Pkce, ClientContext, Opts, TelemetryOpts, true), extract_response(Token, ClientContext, Opts) end; false -> {error, {grant_type_not_supported, authorization_code}} end. %% @doc Refresh Token %% %% For a high level interface using {@link oidcc_provider_configuration_worker} %% see {@link oidcc:refresh_token/5}. %% %%

Examples

%% %% ``` %% {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>>}). %% ''' %% @end -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, undefined, 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

%% %% ``` %% {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)}). %% ''' %% @end -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, refresh_token], extra_meta => #{issuer => Issuer, client_id => ClientId}}, maybe {ok, Token} ?= retrieve_a_token(QueryString1, undefined, 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}. %% %%

Examples

%% %% ``` %% {ok, ClientContext} = %% oidcc_client_context:from_configuration_worker(provider_name, %% <<"client_id">>, %% <<"client_secret">>), %% %% {ok, #oidcc_token{}} = %% oidcc_token:client_credentials(ClientContext, %% #{scope => [<<"scope">>]}). %% ''' %% @end -spec client_credentials(ClientContext, Opts) -> {ok, t()} | {error, error()} when ClientContext :: oidcc_client_context: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, undefined, 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) -> RefreshJwksFun = maps:get(refresh_jwks, Opts, undefined), maybe {ok, Token} ?= int_extract_response(TokenResponseBody, ClientContext, Opts), {ok, Token} 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}, int_extract_response(TokenResponseBody, RefreshedClientContext, Opts) end; {error, Reason} -> {error, Reason} end. -spec int_extract_response(TokenMap, ClientContext, Opts) -> {ok, t()} | {error, error()} when TokenMap :: map(), ClientContext :: oidcc_client_context:t(), Opts :: retrieve_opts(). int_extract_response(TokenMap, ClientContext, Opts) -> Nonce = maps:get(nonce, Opts, any), Scopes = maps:get(scope, Opts, []), IdToken = maps:get(<<"id_token">>, TokenMap, none), AccessToken = maps:get(<<"access_token">>, TokenMap, none), AccessExpire = maps:get(<<"expires_in">>, TokenMap, undefined), RefreshToken = maps:get(<<"refresh_token">>, TokenMap, none), Scope = maps:get(<<"scope">>, TokenMap, oidcc_scope:scopes_to_bin(Scopes)), AccessTokenRecord = case AccessToken of none -> none; _ -> #oidcc_token_access{token = AccessToken, expires = AccessExpire} end, RefreshTokenRecord = case RefreshToken of none -> none; _ -> #oidcc_token_refresh{token = RefreshToken} end, case IdToken of none -> {ok, #oidcc_token{id = none, access = AccessTokenRecord, refresh = RefreshTokenRecord, scope = oidcc_scope:parse(Scope)}}; _ -> RescueNone = case validate_id_token(IdToken, ClientContext, Nonce) of {ok, OkClaims} -> {ok, {OkClaims, false}}; {error, {none_alg_used, NoneClaims}} -> {ok, {NoneClaims, true}}; {error, Reason} -> {error, Reason} end, maybe {ok, {Claims, NoneUsed}} ?= RescueNone, IdTokenRecord = #oidcc_token_id{token = IdToken, claims = Claims}, TokenRecord = #oidcc_token{id = IdTokenRecord, access = AccessTokenRecord, refresh = RefreshTokenRecord, scope = oidcc_scope:parse(Scope)}, 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 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} }) -> <> = 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 {@link retrieve/3}. %% If you gget the token passed from somewhere else, this function can validate it. %% %%

Examples

%% %% ``` %% {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). %% ''' %% @end -spec validate_id_token(IdToken, ClientContext, Nonce) -> {ok, Claims} | {error, error()} when IdToken :: binary(), ClientContext :: oidcc_client_context:t(), Nonce :: binary() | any, Claims :: oidcc_jwt_util:claims(). validate_id_token(IdToken, ClientContext, Nonce) -> #oidcc_client_context{provider_configuration = Configuration, jwks = #jose_jwk{} = Jwks, client_id = ClientId, client_secret = ClientSecret} = ClientContext, #oidcc_provider_configuration{id_token_signing_alg_values_supported = AllowAlgorithms, issuer = Issuer} = Configuration, maybe ExpClaims0 = [{<<"aud">>, ClientId}, {<<"iss">>, Issuer}], ExpClaims = case Nonce of any -> ExpClaims0; Bin when is_binary(Bin) -> [{<<"nonce">>, Nonce} | ExpClaims0] end, JwksInclOct = case oidcc_jwt_util:client_secret_oct_keys(AllowAlgorithms, ClientSecret) of none -> Jwks; OctJwk -> jose_jwk:merge(OctJwk, Jwks) end, {ok, {#jose_jwt{fields = Claims}, Jws}} ?= oidcc_jwt_util:verify_signature(IdToken, AllowAlgorithms, JwksInclOct), ok ?= oidcc_jwt_util:verify_claims(Claims, ExpClaims), ok ?= verify_missing_required_claims(Claims), case Jws of #jose_jws{alg = {jose_jws_alg_none, none}} -> {error, {none_alg_used, Claims}}; #jose_jws{} -> {ok, Claims} end end. -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, Pkce, ClientContext, Opts, TelemetryOpts, AuthenticateClient) -> {ok, map()} | {error, error()} when QsBodyIn :: oidcc_http_util:query_params(), Pkce :: pkce() | undefined, ClientContext :: oidcc_client_context:t(), Opts :: retrieve_opts() | refresh_opts(), TelemetryOpts :: oidcc_http_util:telemetry_opts(), AuthenticateClient :: boolean(). retrieve_a_token(QsBodyIn, Pkce, ClientContext, Opts, TelemetryOpts, AuthenticateClient) -> #oidcc_client_context{provider_configuration = Configuration, client_id = ClientId, client_secret = Secret} = ClientContext, #oidcc_provider_configuration{token_endpoint = TokenEndpoint, token_endpoint_auth_methods_supported = SupportedAuthMethods} = Configuration, AuthMethod = select_preferred_auth(SupportedAuthMethods), Header0 = [{"accept", "application/jwt, application/json"}], {Body, Header} = case AuthenticateClient of true -> add_authentication_code_verifier(QsBodyIn, Header0, AuthMethod, ClientId, Secret, Pkce); false -> {QsBodyIn, Header0} end, Request = {TokenEndpoint, Header, "application/x-www-form-urlencoded", uri_string:compose_query(Body)}, RequestOpts = maps:get(request_opts, Opts, #{}), maybe {ok, {{json, TokenResponse}, _Headers}} ?= oidcc_http_util:request(post, Request, TelemetryOpts, RequestOpts), {ok, TokenResponse} end. -spec select_preferred_auth(AuthMethodsSupported :: [binary(), ...]) -> post | basic | undefined. select_preferred_auth(AuthMethodsSupported) -> Selector = fun(Method, Current) -> case {Method, Current} of {_, post} -> post; {<<"client_secret_basic">>, _} -> basic; {<<"client_secret_post">>, _} -> post; {_, Current} -> Current end end, lists:foldl(Selector, undefined, AuthMethodsSupported). -spec add_authentication_code_verifier( QueryList, Header, AuthMethod, ClientId, ClientSecret, Pkce ) -> {oidcc_http_util:query_params(), [oidcc_http_util:http_header()]} when QueryList :: oidcc_http_util:query_params(), Header :: [oidcc_http_util:http_header()], AuthMethod :: basic | post | undefined, ClientId :: binary(), ClientSecret :: binary(), Pkce :: pkce() | undefined. add_authentication_code_verifier( QsBodyList, Header, basic, ClientId, Secret, undefined ) -> NewHeader = [oidcc_http_util:basic_auth_header(ClientId, Secret) | Header], {QsBodyList, NewHeader}; add_authentication_code_verifier( QsBodyList, Header, post, ClientId, ClientSecret, undefined ) -> NewBodyList = [{<<"client_id">>, ClientId}, {<<"client_secret">>, ClientSecret} | QsBodyList], {NewBodyList, Header}; add_authentication_code_verifier(B, H, undefined, CI, CS, undefined) -> add_authentication_code_verifier(B, H, basic, CI, CS, undefined); add_authentication_code_verifier(BodyQs, Header, AuthMethod, CI, CS, #{verifier := CV}) -> BodyQs1 = [{<<"code_verifier">>, CV} | BodyQs], add_authentication_code_verifier(BodyQs1, Header, AuthMethod, CI, CS, undefined).