diff --git a/src/nova_auth_oidc_jwt.erl b/src/nova_auth_oidc_jwt.erl index 3aeb403..f3fcc1b 100644 --- a/src/nova_auth_oidc_jwt.erl +++ b/src/nova_auth_oidc_jwt.erl @@ -18,6 +18,8 @@ end -include_lib("jose/include/jose_jwt.hrl"). -export([validate_bearer/2, validate_bearer/3, validate_token/3]). +%% Exported for tests: standard-claim validation (exp/iss/aud) in isolation. +-export([validate_claims/3]). -doc """ Validate a JWT bearer token from the request's Authorization header. @@ -97,7 +99,16 @@ validate_claims(AuthMod, Provider, Claims) -> Exp when Exp =< Now -> {error, token_expired}; _ -> - validate_audience(AuthMod, Provider, Claims) + validate_issuer(AuthMod, Provider, Claims) + end. + +%% The token's `iss` must match the provider's configured issuer. Without this, +%% a validly-signed token from any issuer the JWKS resolves could be accepted. +validate_issuer(AuthMod, Provider, Claims) -> + {ok, #{issuer := ExpectedIss}} = nova_auth_oidc:provider_config(AuthMod, Provider), + case maps:get(~"iss", Claims, undefined) of + ExpectedIss -> validate_audience(AuthMod, Provider, Claims); + _ -> {error, invalid_issuer} end. validate_audience(AuthMod, Provider, Claims) -> diff --git a/test/nova_auth_oidc_SUITE.erl b/test/nova_auth_oidc_SUITE.erl index 24736e7..4cf58c3 100644 --- a/test/nova_auth_oidc_SUITE.erl +++ b/test/nova_auth_oidc_SUITE.erl @@ -37,7 +37,12 @@ init_per_suite(Config) -> Config. end_per_suite(_Config) -> - catch persistent_term:erase({nova_auth_oidc, test_oidc_config}), + _ = + (try + persistent_term:erase({nova_auth_oidc, test_oidc_config}) + catch + _:_ -> ok + end), ok. config_returns_defaults(_Config) -> @@ -96,4 +101,9 @@ redirect_uri_builds_correctly(_Config) -> %%---------------------------------------------------------------------- clear_cache() -> - catch persistent_term:erase({nova_auth_oidc, test_oidc_config}). + _ = + (try + persistent_term:erase({nova_auth_oidc, test_oidc_config}) + catch + _:_ -> ok + end). diff --git a/test/nova_auth_oidc_jwt_tests.erl b/test/nova_auth_oidc_jwt_tests.erl new file mode 100644 index 0000000..93c3b43 --- /dev/null +++ b/test/nova_auth_oidc_jwt_tests.erl @@ -0,0 +1,41 @@ +-module(nova_auth_oidc_jwt_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% Standard-claim validation: exp -> iss -> aud. Provider config comes from +%% test_oidc_config (authentik: issuer https://auth.example.com/application/o/myapp, +%% client_id test-client-id). + +-define(ISS, ~"https://auth.example.com/application/o/myapp"). +-define(AUD, ~"test-client-id"). + +valid_claims_pass_test() -> + ?assertEqual(ok, validate(base())). + +expired_rejected_test() -> + ?assertEqual({error, token_expired}, validate((base())#{~"exp" => past()})). + +missing_exp_rejected_test() -> + ?assertEqual({error, missing_exp}, validate(maps:remove(~"exp", base()))). + +wrong_issuer_rejected_test() -> + ?assertEqual( + {error, invalid_issuer}, validate((base())#{~"iss" => ~"https://evil.example.com"}) + ). + +missing_issuer_rejected_test() -> + ?assertEqual({error, invalid_issuer}, validate(maps:remove(~"iss", base()))). + +wrong_audience_rejected_test() -> + ?assertEqual({error, invalid_audience}, validate((base())#{~"aud" => ~"someone-else"})). + +audience_list_pass_test() -> + ?assertEqual(ok, validate((base())#{~"aud" => [~"other", ?AUD]})). + +validate(Claims) -> + nova_auth_oidc_jwt:validate_claims(test_oidc_config, authentik, Claims). + +base() -> + #{~"exp" => future(), ~"iss" => ?ISS, ~"aud" => ?AUD, ~"sub" => ~"user-1"}. + +future() -> erlang:system_time(second) + 3600. +past() -> erlang:system_time(second) - 3600.