From 7a8ead63edeb9ba49bbafc0773261132e72ff5d5 Mon Sep 17 00:00:00 2001 From: Paul Swartz Date: Tue, 9 Jan 2024 12:00:53 -0500 Subject: [PATCH] feat: function to locally validate a JWT Many access tokens are JWTs, which clients can validate without needing to use the `token_introspection` endpoint. This allows multiple clients using the same issuer to use access tokens as a means of validation between them. --- lib/oidcc/token.ex | 46 +++++++++++++++++++++++ src/oidcc_token.erl | 77 ++++++++++++++++++++++++++++++++++++++ test/oidcc_token_SUITE.erl | 41 +++++++++++++++++++- test/oidcc_token_test.erl | 67 +++++++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 1 deletion(-) diff --git a/lib/oidcc/token.ex b/lib/oidcc/token.ex index 1724754..757a67d 100644 --- a/lib/oidcc/token.ex +++ b/lib/oidcc/token.ex @@ -303,6 +303,52 @@ defmodule Oidcc.Token do nonce ) + @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. + + ## Examples + + iex> {:ok, pid} = + ...> Oidcc.ProviderConfiguration.Worker.start_link(%{ + ...> issuer: "https://api.login.yahoo.com" + ...> }) + ...> + ...> {:ok, client_context} = + ...> Oidcc.ClientContext.from_configuration_worker( + ...> pid, + ...> "client_id", + ...> "client_secret" + ...> ) + ...> + ...> #Get JWT from Authorization header + ...> jwt = "jwt" + ...> + ...> opts = %{ + ...> signing_algs: client_context.provider_configuration.id_token_signing_alg_values_supported + ...> } + ...> + ...> Oidcc.Token.validate_jwt(jwt, client_context, opts) + ...> # => {:ok, %{"sub" => "sub", ... }} + + """ + @doc since: "3.0.0" + @spec validate_jwt( + jwt :: String.t(), + client_context :: ClientContext.t(), + opts :: :oidcc_token.validate_jwt_opts() + ) :: {:ok, :oidcc_jwt_util.claims()} | {:error, :oidcc_token.error()} + def validate_jwt(jwt, client_context, opts), + do: + :oidcc_token.validate_jwt( + jwt, + ClientContext.struct_to_record(client_context), + opts + ) + @doc """ Retrieve JSON Web Token (JWT) Profile Token diff --git a/src/oidcc_token.erl b/src/oidcc_token.erl index e3b94d6..2d4e566 100644 --- a/src/oidcc_token.erl +++ b/src/oidcc_token.erl @@ -34,6 +34,7 @@ -export([retrieve/3]). -export([validate_jarm/3]). -export([validate_id_token/3]). +-export([validate_jwt/3]). -export([authorization_headers/4]). -export([authorization_headers/5]). @@ -48,6 +49,7 @@ -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]). -type id() :: #oidcc_token_id{token :: binary(), claims :: oidcc_jwt_util:claims()}. @@ -194,6 +196,13 @@ dpop_nonce => binary() }. +-type validate_jwt_opts() :: + #{ + signing_algs => [binary()] | undefined, + encryption_algs => [binary()] | undefined, + encryption_encs => [binary()] | undefined + }. + -type error() :: {missing_claim, MissingClaim :: binary(), Claims :: oidcc_jwt_util:claims()} | pkce_verifier_required @@ -926,6 +935,74 @@ validate_id_token(IdToken, ClientContext, Opts) when is_map(Opts) -> 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. +%% +%%

Examples

+%% +%% ``` +%% {ok, ClientContext} = +%% oidcc_client_context:from_configuration_worker(provider_name, +%% <<"client_id">>, +%% <<"client_secret">>), +%% +%% %% Get Jwt from Authorization header +%% +%% {ok, Claims} = +%% oidcc:validate_jwt(Jwt, ClientContext, Opts). +%% ''' +%% @end +%% @since 3.2.0 +-spec validate_jwt(Jwt, ClientContext, Opts) -> + {ok, Claims} | {error, error()} +when + Jwt :: binary(), + ClientContext :: oidcc_client_context:t(), + Opts :: validate_jwt_opts(), + Claims :: oidcc_jwt_util:claims(). +validate_jwt(Jwt, ClientContext, Opts) when is_map(Opts) -> + #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 + } = + Configuration, + + SigningAlgs = maps:get(signing_algs, Opts, []), + EncryptionAlgs = maps:get(encryption_algs, Opts, []), + EncryptionEncs = maps:get(encryption_encs, Opts, []), + ExpClaims = [{<<"iss">>, Issuer}], + 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), + TrustedAudience = maps:get(trusted_audience, Opts, any), + + maybe + {ok, {#jose_jwt{fields = Claims}, _}} ?= + oidcc_jwt_util:decrypt_and_verify( + Jwt, Jwks, SigningAlgs, EncryptionAlgs, EncryptionEncs + ), + ok ?= oidcc_jwt_util:verify_claims(Claims, ExpClaims), + ok ?= verify_missing_required_claims(Claims), + ok ?= verify_aud_claim(Claims, ClientId, TrustedAudience), + ok ?= verify_exp_claim(Claims), + ok ?= verify_nbf_claim(Claims), + {ok, Claims} + end. + %% @doc Authorization headers %% %% Generate a map of authorization headers to use when using the given diff --git a/test/oidcc_token_SUITE.erl b/test/oidcc_token_SUITE.erl index e683983..d370c9b 100644 --- a/test/oidcc_token_SUITE.erl +++ b/test/oidcc_token_SUITE.erl @@ -5,13 +5,14 @@ -export([init_per_suite/1]). -export([retrieves_client_credentials_token/1]). -export([retrieves_jwt_profile_token/1]). +-export([validates_access_token/1]). -include_lib("common_test/include/ct.hrl"). -include_lib("jose/include/jose_jwk.hrl"). -include_lib("oidcc/include/oidcc_token.hrl"). -include_lib("stdlib/include/assert.hrl"). -all() -> [retrieves_jwt_profile_token, retrieves_client_credentials_token]. +all() -> [retrieves_jwt_profile_token, retrieves_client_credentials_token, validates_access_token]. init_per_suite(_Config) -> {ok, _} = application:ensure_all_started(oidcc), @@ -112,3 +113,41 @@ retrieves_client_credentials_token(_Config) -> ), ok. + +validates_access_token(_Config) -> + PrivDir = code:priv_dir(oidcc), + Issuer = <<"https://erlef-test-w4a8z2.zitadel.cloud">>, + + {ok, ZitadelConfigurationPid} = + oidcc_provider_configuration_worker:start_link(#{ + issuer => Issuer + }), + + {ok, ZitadelClientCredentialsJson} = file:read_file( + PrivDir ++ "/test/fixtures/zitadel-client-credentials.json" + ), + #{ + <<"clientId">> := ZitadelClientCredentialsClientId, + <<"clientSecret">> := ZitadelClientCredentialsClientSecret + } = jose:decode(ZitadelClientCredentialsJson), + + {ok, ZitadelClientContext} = oidcc_client_context:from_configuration_worker( + ZitadelConfigurationPid, + ZitadelClientCredentialsClientId, + ZitadelClientCredentialsClientSecret + ), + + {ok, Token} = oidcc_token:client_credentials(ZitadelClientContext, #{ + scope => [<<"openid">>, <<"profile">>] + }), + + #oidcc_token{access = #oidcc_token_access{token = AccessToken}} = Token, + ?assertMatch( + {ok, #{ + <<"iss">> := Issuer, + <<"aud">> := [ZitadelClientCredentialsClientId] + }}, + oidcc_token:validate_jwt(AccessToken, ZitadelClientContext, #{signing_algs => [<<"RS256">>]}) + ), + + ok. diff --git a/test/oidcc_token_test.erl b/test/oidcc_token_test.erl index 5fa2c7d..60021a4 100644 --- a/test/oidcc_token_test.erl +++ b/test/oidcc_token_test.erl @@ -1947,6 +1947,73 @@ validate_id_token_encrypted_token_test() -> ok. +validate_jwt_test() -> + #oidcc_client_context{ + client_id = ClientId, + jwks = Jwk, + provider_configuration = #oidcc_provider_configuration{issuer = Issuer} + } = + ClientContext = client_context_fapi2_fixture(), + + GoodClaims = + #{ + <<"iss">> => Issuer, + <<"aud">> => ClientId, + <<"sub">> => <<"1234">>, + <<"iat">> => erlang:system_time(second), + <<"exp">> => erlang:system_time(second) + 10 + }, + Expired = GoodClaims#{<<"exp">> => erlang:system_time(second) - 1}, + NotYetValid = GoodClaims#{<<"nbf">> => erlang:system_time(second) + 5}, + WrongIssuer = GoodClaims#{<<"iss">> => <<"wrong">>}, + WrongAudience = GoodClaims#{<<"aud">> => <<"wrong">>}, + + JwtFun = fun(Claims) -> + Jwt = jose_jwt:from(Claims), + Jws = #{<<"alg">> => <<"RS256">>}, + {_Jws, Token} = + jose_jws:compact( + jose_jwt:sign(Jwk, Jws, Jwt) + ), + Token + end, + + Opts = #{ + signing_algs => [<<"RS256">>] + }, + + ?assertEqual( + {ok, GoodClaims}, + oidcc_token:validate_jwt(JwtFun(GoodClaims), ClientContext, Opts) + ), + + ?assertEqual( + {error, token_expired}, + oidcc_token:validate_jwt(JwtFun(Expired), ClientContext, Opts) + ), + + ?assertEqual( + {error, token_not_yet_valid}, + oidcc_token:validate_jwt(JwtFun(NotYetValid), ClientContext, Opts) + ), + + ?assertEqual( + {error, {missing_claim, {<<"iss">>, Issuer}, WrongIssuer}}, + oidcc_token:validate_jwt(JwtFun(WrongIssuer), ClientContext, Opts) + ), + + ?assertEqual( + {error, {missing_claim, {<<"aud">>, ClientId}, WrongAudience}}, + oidcc_token:validate_jwt(JwtFun(WrongAudience), ClientContext, Opts) + ), + + ?assertEqual( + {error, no_matching_key}, + oidcc_token:validate_jwt(JwtFun(WrongAudience), ClientContext, #{}) + ), + + ok. + client_context_fapi2_fixture() -> PrivDir = code:priv_dir(oidcc),