Skip to content

Commit 4d97d86

Browse files
committed
fix jwks refresh for client stores
1 parent ed522ef commit 4d97d86

5 files changed

Lines changed: 267 additions & 75 deletions

File tree

lib/oidcc/plug/authorization_callback.ex

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,16 @@ defmodule Oidcc.Plug.AuthorizationCallback do
141141
| :state_not_verified
142142
| {:missing_request_param, param :: String.t()}
143143

144+
# what Oidcc.Plug.Authorize writes into the session. peer_ip and useragent are
145+
# read back from the client, so the checks tolerate them being absent.
146+
@typep session() :: %{
147+
nonce: String.t(),
148+
peer_ip: :inet.ip_address() | nil,
149+
useragent: String.t() | nil,
150+
pkce_verifier: String.t(),
151+
state_verifier: integer()
152+
}
153+
144154
@impl Plug
145155
def init(opts),
146156
do:
@@ -185,21 +195,15 @@ defmodule Oidcc.Plug.AuthorizationCallback do
185195
{:ok, code} <- fetch_request_param(params, "code"),
186196
scope = Map.get(params, "scope", "openid"),
187197
token_opts =
188-
prepare_retrieve_opts(
189-
opts,
190-
scope,
191-
session.nonce,
192-
redirect_uri,
193-
session.pkce_verifier
194-
),
198+
prepare_retrieve_opts(client_context, session, scope, redirect_uri, opts),
195199
{:ok, token} <-
196200
retrieve_token(
197201
code,
198202
client_context,
199203
retrieve_userinfo?,
200204
Map.merge(profile_opts, token_opts)
201205
),
202-
userinfo_opts = prepare_userinfo_opts(opts),
206+
userinfo_opts = prepare_userinfo_opts(client_context, opts),
203207
{:ok, userinfo} <- retrieve_userinfo(token, client_context, userinfo_opts, retrieve_userinfo?) do
204208
{:ok, {token, userinfo}}
205209
end
@@ -217,44 +221,37 @@ defmodule Oidcc.Plug.AuthorizationCallback do
217221
end
218222

219223
@spec prepare_retrieve_opts(
220-
opts :: opts(),
224+
client_context :: ClientContext.t(),
225+
session :: session(),
221226
scope :: String.t(),
222-
nonce :: String.t() | :any,
223227
redirect_uri :: String.t(),
224-
pkce_verifier :: String.t() | :none
228+
opts :: opts()
225229
) :: :oidcc_token.retrieve_opts()
226-
defp prepare_retrieve_opts(opts, scope, nonce, redirect_uri, pkce_verifier) do
230+
defp prepare_retrieve_opts(client_context, session, scope, redirect_uri, opts) do
227231
scopes = :oidcc_scope.parse(scope)
228232

229-
refresh_jwks = Utils.get_refresh_jwks_fun(opts)
230-
231233
opts
232234
|> Keyword.take([:request_opts, :preferred_auth_methods])
233235
|> Map.new()
234236
|> Map.merge(%{
235-
nonce: nonce,
237+
nonce: session.nonce,
236238
scope: scopes,
237239
redirect_uri: redirect_uri,
238-
pkce_verifier: pkce_verifier,
239-
refresh_jwks: refresh_jwks
240+
pkce_verifier: session.pkce_verifier
240241
})
241-
|> case do
242-
%{pkce_verifier: :none} = opts -> Map.delete(opts, :pkce_verifier)
243-
opts -> opts
244-
end
242+
|> Utils.put_refresh_jwks(client_context, opts)
245243
end
246244

247-
@spec prepare_userinfo_opts(opts :: opts()) :: :oidcc_userinfo.retrieve_opts()
248-
defp prepare_userinfo_opts(opts) do
249-
refresh_jwks = Utils.get_refresh_jwks_fun(opts)
250-
251-
%{refresh_jwks: refresh_jwks}
245+
@spec prepare_userinfo_opts(client_context :: ClientContext.t(), opts :: opts()) ::
246+
:oidcc_userinfo.retrieve_opts()
247+
defp prepare_userinfo_opts(client_context, opts) do
248+
Utils.put_refresh_jwks(%{}, client_context, opts)
252249
end
253250

254251
# The session is written by Oidcc.Plug.Authorize. Without it, there is nothing
255252
# to validate. Reject the request to prevent CSRF in that case.
256253
@spec fetch_authorize_session(conn :: Plug.Conn.t()) ::
257-
{:ok, map()} | {:error, error()}
254+
{:ok, session()} | {:error, error()}
258255
defp fetch_authorize_session(conn) do
259256
case get_session(conn, Authorize.get_session_name()) do
260257
%{

lib/oidcc/plug/utils.ex

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,52 @@ defmodule Oidcc.Plug.Utils do
2929
end
3030

3131
@doc """
32-
Returns a function to refresh the JWKS for a provider.
32+
Adds the JWKS refresh function to `map`, unless there is none.
33+
34+
The key is omitted rather than set to `nil` because `oidcc` only skips the
35+
refresh when the key is absent.
3336
"""
34-
@spec get_refresh_jwks_fun(Keyword.t()) ::
37+
@spec put_refresh_jwks(map(), ClientContext.t(), Keyword.t()) :: map()
38+
def put_refresh_jwks(map, client_context, opts) do
39+
case get_refresh_jwks_fun(client_context, opts) do
40+
nil -> map
41+
refresh_jwks -> Map.put(map, :refresh_jwks, refresh_jwks)
42+
end
43+
end
44+
45+
# oidcc calls the refresh function with (jwks, kid), while
46+
# c:Oidcc.Plug.ClientStore.refresh_jwks/1 takes the client context => the store
47+
# callback is wrapped rather than captured directly.
48+
@spec get_refresh_jwks_fun(ClientContext.t(), Keyword.t()) ::
3549
:oidcc_jwt_util.refresh_jwks_for_unknown_kid_fun() | nil
36-
def get_refresh_jwks_fun(opts) do
37-
if client_store = Keyword.get(opts, :client_store) do
38-
if function_exported?(client_store, :refresh_jwks, 1),
39-
do: &client_store.refresh_jwks/1
40-
else
41-
provider = Keyword.fetch!(opts, :provider)
42-
:oidcc_jwt_util.refresh_jwks_fun(provider)
50+
defp get_refresh_jwks_fun(client_context, opts) do
51+
case Keyword.get(opts, :client_store) do
52+
nil ->
53+
provider = Keyword.fetch!(opts, :provider)
54+
:oidcc_jwt_util.refresh_jwks_fun(provider)
55+
56+
client_store ->
57+
client_store_refresh_jwks_fun(client_store, client_context)
58+
end
59+
end
60+
61+
@spec client_store_refresh_jwks_fun(module(), ClientContext.t()) ::
62+
:oidcc_jwt_util.refresh_jwks_for_unknown_kid_fun() | nil
63+
defp client_store_refresh_jwks_fun(client_store, client_context) do
64+
Code.ensure_loaded!(client_store)
65+
66+
if function_exported?(client_store, :refresh_jwks, 1),
67+
do: fn _jwks, _kid -> refresh_jwks_from_store(client_store, client_context) end
68+
end
69+
70+
# The callback returns a JOSE.JWK struct, while oidcc puts the result straight
71+
# into the client context record => convert it.
72+
@spec refresh_jwks_from_store(module(), ClientContext.t()) ::
73+
{:ok, :jose_jwk.key()} | {:error, term()}
74+
defp refresh_jwks_from_store(client_store, client_context) do
75+
case client_store.refresh_jwks(client_context) do
76+
{:ok, %JOSE.JWK{} = jwks} -> {:ok, JOSE.JWK.to_record(jwks)}
77+
other -> other
4378
end
4479
end
4580

lib/oidcc/plug/validate_jwt_token.ex

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@ defmodule Oidcc.Plug.ValidateJwtToken do
4949
"""
5050
@typedoc since: "0.1.0"
5151
@type opts :: [
52-
provider: GenServer.name(),
53-
client_id: String.t() | (-> String.t()),
54-
client_secret: String.t() | (-> String.t()),
52+
provider: GenServer.name() | nil,
53+
client_store: module() | nil,
54+
client_id: String.t() | (-> String.t()) | nil,
55+
client_secret: String.t() | (-> String.t()) | nil,
5556
send_inactive_token_response: (conn :: Plug.Conn.t() -> Plug.Conn.t()),
5657
validate_opts: Oidcc.Token.retrieve_opts()
5758
]
@@ -76,6 +77,7 @@ defmodule Oidcc.Plug.ValidateJwtToken do
7677
opts
7778
|> Keyword.validate!([
7879
:provider,
80+
:client_store,
7981
:client_id,
8082
:client_secret,
8183
send_inactive_token_response: &__MODULE__.send_inactive_token_response/1,
@@ -89,16 +91,9 @@ defmodule Oidcc.Plug.ValidateJwtToken do
8991
def call(%Plug.Conn{private: %{ExtractAuthorization => access_token}} = conn, opts) do
9092
send_inactive_token_response = Keyword.fetch!(opts, :send_inactive_token_response)
9193

92-
refresh_jwks = Utils.get_refresh_jwks_fun(opts)
93-
94-
validate_opts =
95-
Map.merge(
96-
opts[:validate_opts],
97-
%{nonce: :any, refresh_jwks: refresh_jwks}
98-
)
99-
10094
with {:ok, client_context} <-
10195
Utils.get_client_context(conn, opts),
96+
validate_opts = prepare_validate_opts(client_context, opts),
10297
{:ok, claims} <-
10398
Oidcc.Token.validate_id_token(access_token, client_context, validate_opts) do
10499
put_private(conn, __MODULE__, claims)
@@ -119,6 +114,15 @@ defmodule Oidcc.Plug.ValidateJwtToken do
119114
"""
120115
end
121116

117+
@spec prepare_validate_opts(client_context :: Oidcc.ClientContext.t(), opts :: opts()) ::
118+
Oidcc.Token.retrieve_opts()
119+
defp prepare_validate_opts(client_context, opts) do
120+
opts
121+
|> Keyword.fetch!(:validate_opts)
122+
|> Map.put(:nonce, :any)
123+
|> Utils.put_refresh_jwks(client_context, opts)
124+
end
125+
122126
@doc false
123127
@spec send_inactive_token_response(conn :: Plug.Conn.t()) :: Plug.Conn.t()
124128
def send_inactive_token_response(conn) do

test/oidcc/plug/utils_test.exs

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,30 @@ defmodule Oidcc.Plug.UtilsTest do
128128
end
129129
end
130130

131-
describe "get_refresh_jwks_fun/1" do
131+
defmodule ClientStoreWithoutRefresh do
132+
@moduledoc false
133+
@behaviour Oidcc.Plug.ClientStore
134+
135+
alias Oidcc.Plug.ClientStore
136+
137+
@impl ClientStore
138+
def get_client_context(_conn), do: {:ok, %{}}
139+
end
140+
141+
defmodule ClientStoreWithRefresh do
142+
@moduledoc false
143+
@behaviour Oidcc.Plug.ClientStore
144+
145+
alias Oidcc.Plug.ClientStore
146+
147+
@impl ClientStore
148+
def get_client_context(_conn), do: {:ok, %{}}
149+
150+
@impl ClientStore
151+
def refresh_jwks(context), do: {:refreshed, context}
152+
end
153+
154+
describe "put_refresh_jwks/3" do
132155
test "uses oidcc_jwt_util for provider configuration" do
133156
refresh_fun = :test_refresh_fun
134157

@@ -138,45 +161,40 @@ defmodule Oidcc.Plug.UtilsTest do
138161
refresh_fun
139162
end do
140163
opts = [provider: :test_provider]
141-
assert Utils.get_refresh_jwks_fun(opts) == refresh_fun
164+
165+
assert Utils.put_refresh_jwks(%{}, :client_context, opts) == %{refresh_jwks: refresh_fun}
142166
end
143167
end
144168

145-
test "returns nil when client_store doesn't implement refresh_jwks" do
146-
defmodule ClientStoreWithoutRefresh do
147-
@moduledoc false
148-
@behaviour Oidcc.Plug.ClientStore
149-
150-
alias Oidcc.Plug.ClientStore
151-
152-
@impl ClientStore
153-
def get_client_context(_conn), do: {:ok, %{}}
154-
end
169+
test "adds a fun with the arity oidcc calls it with" do
170+
opts = [client_store: ClientStoreWithRefresh]
155171

156-
opts = [client_store: ClientStoreWithoutRefresh]
172+
assert %{refresh_jwks: refresh_jwks} = Utils.put_refresh_jwks(%{}, :client_context, opts)
157173

158-
assert Utils.get_refresh_jwks_fun(opts) == nil
174+
# oidcc invokes the refresh fun as fun(jwks, kid)
175+
assert is_function(refresh_jwks, 2)
159176
end
160177

161-
test "returns client_store.refresh_jwks function when implemented" do
162-
defmodule ClientStoreWithRefresh do
163-
@moduledoc false
164-
@behaviour Oidcc.Plug.ClientStore
178+
test "passes the client context to client_store.refresh_jwks/1" do
179+
opts = [client_store: ClientStoreWithRefresh]
165180

166-
alias Oidcc.Plug.ClientStore
181+
%{refresh_jwks: refresh_jwks} = Utils.put_refresh_jwks(%{}, :client_context, opts)
167182

168-
@impl ClientStore
169-
def get_client_context(_conn), do: {:ok, %{}}
183+
assert refresh_jwks.(:stale_jwks, "unknown_kid") == {:refreshed, :client_context}
184+
end
170185

171-
@impl ClientStore
172-
def refresh_jwks(arg), do: {:refreshed, arg}
173-
end
186+
test "omits the key for a client_store without the callback" do
187+
opts = [client_store: ClientStoreWithoutRefresh]
174188

175-
opts = [client_store: ClientStoreWithRefresh]
189+
# oidcc only skips the refresh when the key is absent, so it must not be
190+
# set to nil
191+
assert Utils.put_refresh_jwks(%{}, :client_context, opts) == %{}
192+
end
193+
194+
test "keeps existing keys" do
195+
opts = [client_store: ClientStoreWithoutRefresh]
176196

177-
refresh_fun = Utils.get_refresh_jwks_fun(opts)
178-
assert is_function(refresh_fun, 1)
179-
assert refresh_fun.(:test_arg) == {:refreshed, :test_arg}
197+
assert Utils.put_refresh_jwks(%{nonce: :any}, :client_context, opts) == %{nonce: :any}
180198
end
181199
end
182200
end

0 commit comments

Comments
 (0)