/src/mod_auth_openidc/src/metadata/jwks.c
Line | Count | Source |
1 | | /* |
2 | | * Licensed to the Apache Software Foundation (ASF) under one |
3 | | * or more contributor license agreements. See the NOTICE file |
4 | | * distributed with this work for additional information |
5 | | * regarding copyright ownership. The ASF licenses this file |
6 | | * to you under the Apache License, Version 2.0 (the |
7 | | * "License"); you may not use this file except in compliance |
8 | | * with the License. You may obtain a copy of the License at |
9 | | * |
10 | | * http://www.apache.org/licenses/LICENSE-2.0 |
11 | | * |
12 | | * Unless required by applicable law or agreed to in writing, |
13 | | * software distributed under the License is distributed on an |
14 | | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
15 | | * KIND, either express or implied. See the License for the |
16 | | * specific language governing permissions and limitations |
17 | | * under the License. |
18 | | */ |
19 | | |
20 | | /*************************************************************************** |
21 | | * Copyright (C) 2017-2026 ZmartZone Holding BV |
22 | | * Copyright (C) 2013-2017 Ping Identity Corporation |
23 | | * All rights reserved. |
24 | | * |
25 | | * JWKS retrieval, caching, and validation for OIDC metadata. |
26 | | * |
27 | | * @Author: Hans Zandbelt - hans.zandbelt@openidc.com |
28 | | */ |
29 | | |
30 | | #include "cfg/dir.h" |
31 | | #include "metadata/internal.h" |
32 | | |
33 | | #include "cache/cache.h" |
34 | | #include "http.h" |
35 | | #include "metrics.h" |
36 | | #include "proto/proto.h" |
37 | | #include "util/util.h" |
38 | | |
39 | | #include <apr_hash.h> |
40 | | #include <apr_strings.h> |
41 | | |
42 | | /* |
43 | | * get cache key for the JWKs file for a specified URI |
44 | | */ |
45 | 0 | static const char *oidc_metadata_jwks_cache_key(const oidc_jwks_uri_t *jwks_uri) { |
46 | 0 | return jwks_uri->signed_uri ? jwks_uri->signed_uri : jwks_uri->uri; |
47 | 0 | } |
48 | | |
49 | | /* rate-limit forced (i.e. cache-bypassing) refreshes of a jwks_uri to one per this many seconds */ |
50 | 0 | #define OIDC_METADATA_JWKS_FORCED_REFRESH_INTERVAL_DEFAULT 60 |
51 | | |
52 | | /* environment variable overriding that window, in seconds; 0 turns the rate limit off entirely */ |
53 | 0 | #define OIDC_METADATA_JWKS_FORCED_REFRESH_INTERVAL_ENVVAR "OIDC_JWKS_FORCED_REFRESH_INTERVAL" |
54 | | |
55 | | /* upper bound on the window: past an hour a genuine key rollover would go unnoticed for too long */ |
56 | 0 | #define OIDC_METADATA_JWKS_FORCED_REFRESH_INTERVAL_MAX 3600 |
57 | | |
58 | | /* postfix distinguishing the forced-refresh marker from the JWKs document under the same key */ |
59 | 0 | #define OIDC_METADATA_JWKS_FORCED_REFRESH_POSTFIX "#forced" |
60 | | |
61 | | /* |
62 | | * Forced-refresh rate-limit window. OIDC_JWKS_FORCED_REFRESH_INTERVAL accepts 0-3600 seconds; |
63 | | * invalid values retain the safe default. |
64 | | */ |
65 | 0 | static int oidc_metadata_jwks_forced_refresh_interval(request_rec *r) { |
66 | 0 | int interval = 0; |
67 | 0 | const char *s_interval = |
68 | 0 | (r->subprocess_env != NULL) |
69 | 0 | ? apr_table_get(r->subprocess_env, OIDC_METADATA_JWKS_FORCED_REFRESH_INTERVAL_ENVVAR) |
70 | 0 | : NULL; |
71 | |
|
72 | 0 | if (s_interval == NULL) |
73 | 0 | return OIDC_METADATA_JWKS_FORCED_REFRESH_INTERVAL_DEFAULT; |
74 | | |
75 | 0 | if ((_oidc_str_to_int_checked(s_interval, &interval) == FALSE) || (interval < 0) || |
76 | 0 | (interval > OIDC_METADATA_JWKS_FORCED_REFRESH_INTERVAL_MAX)) { |
77 | 0 | oidc_warn( |
78 | 0 | r, "ignoring %s value \"%s\": must be an integer number of seconds between 0 and %d; using %d", |
79 | 0 | OIDC_METADATA_JWKS_FORCED_REFRESH_INTERVAL_ENVVAR, s_interval, |
80 | 0 | OIDC_METADATA_JWKS_FORCED_REFRESH_INTERVAL_MAX, OIDC_METADATA_JWKS_FORCED_REFRESH_INTERVAL_DEFAULT); |
81 | 0 | return OIDC_METADATA_JWKS_FORCED_REFRESH_INTERVAL_DEFAULT; |
82 | 0 | } |
83 | | |
84 | 0 | return interval; |
85 | 0 | } |
86 | | |
87 | | /* the shared-cache key recording that a forced refresh of this jwks_uri was recently attempted */ |
88 | 0 | static const char *oidc_metadata_jwks_forced_refresh_key(request_rec *r, const oidc_jwks_uri_t *jwks_uri) { |
89 | 0 | return apr_pstrcat(r->pool, oidc_metadata_jwks_cache_key(jwks_uri), OIDC_METADATA_JWKS_FORCED_REFRESH_POSTFIX, |
90 | 0 | NULL); |
91 | 0 | } |
92 | | |
93 | | /* |
94 | | * Check whether this URI was forcibly refreshed within the rate-limit window. Unauthenticated |
95 | | * unknown-key requests must not each trigger an outbound fetch. This check has no side effects; |
96 | | * oidc_metadata_jwks_get() records the attempt. |
97 | | */ |
98 | 0 | apr_byte_t oidc_metadata_jwks_forced_refresh_throttled(request_rec *r, const oidc_jwks_uri_t *jwks_uri) { |
99 | 0 | char *value = NULL; |
100 | | /* a zero-length window disables the guard: never report a forced refresh as throttled, whatever |
101 | | * marker an earlier request may have left in the cache */ |
102 | 0 | if (oidc_metadata_jwks_forced_refresh_interval(r) == 0) |
103 | 0 | return FALSE; |
104 | 0 | if (oidc_cache_get_jwks(r, oidc_metadata_jwks_forced_refresh_key(r, jwks_uri), &value) == FALSE) { |
105 | | /* Fail the throttle closed on cache errors to prevent unknown-key fetches during an outage. */ |
106 | 0 | oidc_warn(r, "cache lookup for the JWKs forced-refresh throttle failed; treating the forced refresh " |
107 | 0 | "as throttled"); |
108 | 0 | return TRUE; |
109 | 0 | } |
110 | 0 | return (value != NULL); |
111 | 0 | } |
112 | | |
113 | | /* record a forced-refresh attempt so the next one within the window is rate-limited; stamped before |
114 | | * the fetch rather than after it, so a jwks_uri that is down or slow is not retried per request */ |
115 | 0 | static apr_byte_t oidc_metadata_jwks_forced_refresh_stamp(request_rec *r, const oidc_jwks_uri_t *jwks_uri) { |
116 | 0 | const int interval = oidc_metadata_jwks_forced_refresh_interval(r); |
117 | | /* with the guard disabled there is nothing to record: writing a marker that expires immediately |
118 | | * would still put an entry in the shared cache on every forced refresh */ |
119 | 0 | if (interval == 0) |
120 | 0 | return TRUE; |
121 | 0 | return oidc_cache_set_jwks(r, oidc_metadata_jwks_forced_refresh_key(r, jwks_uri), "1", |
122 | 0 | apr_time_now() + apr_time_from_sec(interval)); |
123 | 0 | } |
124 | | |
125 | | /* |
126 | | * checks if a parsed JWKs file is a valid one, cq. contains "keys" |
127 | | */ |
128 | 0 | static apr_byte_t oidc_metadata_jwks_is_valid(request_rec *r, const char *url, const oidc_json_t *j_jwks) { |
129 | |
|
130 | 0 | const oidc_json_t *keys = oidc_json_object_get(j_jwks, OIDC_METADATA_KEYS); |
131 | 0 | if ((keys == NULL) || (!oidc_json_is_array(keys))) { |
132 | 0 | oidc_error( |
133 | 0 | r, "JWKs JSON metadata obtained from URL \"%s\" did not contain a \"" OIDC_METADATA_KEYS "\" array", |
134 | 0 | url); |
135 | 0 | return FALSE; |
136 | 0 | } |
137 | 0 | return TRUE; |
138 | 0 | } |
139 | | |
140 | | /* |
141 | | * helper function to get the JWKs for the specified issuer |
142 | | */ |
143 | | static apr_byte_t oidc_metadata_jwks_retrieve_and_cache(request_rec *r, oidc_cfg_t *cfg, |
144 | | const oidc_jwks_uri_t *jwks_uri, int ssl_validate_server, |
145 | 0 | oidc_json_t **j_jwks) { |
146 | |
|
147 | 0 | char *response = NULL; |
148 | 0 | const char *url = (jwks_uri->signed_uri != NULL) ? jwks_uri->signed_uri : jwks_uri->uri; |
149 | |
|
150 | 0 | OIDC_METRICS_TIMING_START(r, cfg); |
151 | | |
152 | | /* get the JWKs from the specified URL with the specified parameters */ |
153 | 0 | if (oidc_http_get(r, url, NULL, NULL, NULL, NULL, ssl_validate_server, &response, NULL, NULL, |
154 | 0 | oidc_cfg_http_timeout_long_get(cfg), oidc_cfg_outgoing_proxy_get(cfg), |
155 | 0 | oidc_cfg_dir_pass_cookies_get(r), NULL, NULL, NULL) == FALSE) { |
156 | 0 | OIDC_METRICS_COUNTER_INC(r, cfg, OM_PROVIDER_JWKS_ERROR); |
157 | 0 | return FALSE; |
158 | 0 | } |
159 | | |
160 | 0 | OIDC_METRICS_TIMING_ADD(r, cfg, OM_PROVIDER_JWKS); |
161 | |
|
162 | 0 | if ((jwks_uri->signed_uri != NULL) && (jwks_uri->jwk_list != NULL)) { |
163 | |
|
164 | 0 | oidc_jwt_t *jwt = NULL; |
165 | 0 | oidc_jose_error_t err; |
166 | 0 | apr_hash_t *keys = apr_hash_make(r->pool); |
167 | |
|
168 | 0 | oidc_debug(r, "signed_jwks verifier keys count=%d", jwks_uri->jwk_list->nelts); |
169 | 0 | for (int i = 0; i < jwks_uri->jwk_list->nelts; i++) { |
170 | 0 | oidc_jwk_t *jwk = APR_ARRAY_IDX(jwks_uri->jwk_list, i, oidc_jwk_t *); |
171 | 0 | if (jwk->kid != NULL) { |
172 | 0 | oidc_debug(r, "signed_jwks verifier kid=%s", jwk->kid); |
173 | 0 | apr_hash_set(keys, jwk->kid, APR_HASH_KEY_STRING, jwk); |
174 | 0 | } else { |
175 | 0 | const char *kid = apr_psprintf(r->pool, "%d", apr_hash_count(keys)); |
176 | 0 | oidc_debug(r, "signed_jwks verifier kid=%s", kid); |
177 | 0 | apr_hash_set(keys, kid, APR_HASH_KEY_STRING, jwk); |
178 | 0 | } |
179 | 0 | } |
180 | |
|
181 | 0 | if (oidc_jwt_parse(r->pool, response, &jwt, keys, FALSE, &err) == FALSE) { |
182 | 0 | oidc_error(r, "parsing JWT failed: %s", oidc_jose_e2s(r->pool, err)); |
183 | 0 | return FALSE; |
184 | 0 | } |
185 | | |
186 | 0 | oidc_debug(r, "successfully parsed JWT returned from \"signed_jwks_uri\" endpoint"); |
187 | |
|
188 | 0 | if (oidc_jwt_verify(r->pool, jwt, keys, &err) == FALSE) { |
189 | 0 | oidc_error(r, "verifying JWT failed: %s", oidc_jose_e2s(r->pool, err)); |
190 | 0 | oidc_jwt_destroy(jwt); |
191 | 0 | return FALSE; |
192 | 0 | } |
193 | | |
194 | 0 | if (oidc_proto_jwt_validate(r, jwt, NULL, FALSE, FALSE, -1) == FALSE) { |
195 | 0 | oidc_jwt_destroy(jwt); |
196 | 0 | return FALSE; |
197 | 0 | } |
198 | | |
199 | 0 | oidc_debug(r, "successfully verified and validated JWKs JWT"); |
200 | |
|
201 | 0 | response = jwt->payload.value.str; |
202 | 0 | oidc_jwt_destroy(jwt); |
203 | 0 | } |
204 | | |
205 | | /* decode and see if it is not an error response somehow */ |
206 | 0 | if (oidc_json_decode_and_check_error(r, response, j_jwks) == FALSE) { |
207 | 0 | oidc_error(r, "JSON parsing of JWKs published at the jwks_uri failed"); |
208 | 0 | return FALSE; |
209 | 0 | } |
210 | | |
211 | | /* check to see if it is a set of valid JWKs */ |
212 | 0 | if (oidc_metadata_jwks_is_valid(r, url, *j_jwks) == FALSE) { |
213 | | /* the decoded object is ours now; release it before bailing */ |
214 | 0 | oidc_json_decref(*j_jwks); |
215 | 0 | *j_jwks = NULL; |
216 | 0 | return FALSE; |
217 | 0 | } |
218 | | |
219 | | /* store the JWKs in the cache */ |
220 | 0 | oidc_cache_set_jwks(r, oidc_metadata_jwks_cache_key(jwks_uri), response, |
221 | 0 | apr_time_now() + apr_time_from_sec(oidc_cfg_jwks_uri_refresh_interval_get(jwks_uri))); |
222 | |
|
223 | 0 | return TRUE; |
224 | 0 | } |
225 | | |
226 | | /* |
227 | | * return JWKs for the specified issuer |
228 | | */ |
229 | | apr_byte_t oidc_metadata_jwks_get(request_rec *r, oidc_cfg_t *cfg, const oidc_jwks_uri_t *jwks_uri, |
230 | 0 | int ssl_validate_server, oidc_json_t **j_jwks, apr_byte_t *refresh) { |
231 | 0 | char *value = NULL; |
232 | 0 | const char *url = jwks_uri->signed_uri ? jwks_uri->signed_uri : jwks_uri->uri; |
233 | | /* a forced refresh that was throttled, could not be stamped, or already fetched-and-failed: the |
234 | | * fallback path below must not turn round and fetch again, which would defeat the rate limit */ |
235 | 0 | apr_byte_t forced_refresh_attempted = FALSE; |
236 | |
|
237 | 0 | oidc_debug(r, "enter, %sjwks_uri=%s, refresh=%d", jwks_uri->signed_uri ? "signed_" : "", url, *refresh); |
238 | | |
239 | | /* see if we need to do a forced refresh */ |
240 | 0 | if (*refresh == TRUE) { |
241 | 0 | if (oidc_metadata_jwks_forced_refresh_throttled(r, jwks_uri) == TRUE) { |
242 | 0 | forced_refresh_attempted = TRUE; |
243 | 0 | oidc_debug(r, |
244 | 0 | "not refreshing the JWKs from URI \"%s\": a forced refresh was already " |
245 | 0 | "attempted less than %d seconds ago", |
246 | 0 | url, oidc_metadata_jwks_forced_refresh_interval(r)); |
247 | | // fall back to any cached JWKs |
248 | 0 | } else if (oidc_metadata_jwks_forced_refresh_stamp(r, jwks_uri) == FALSE) { |
249 | 0 | forced_refresh_attempted = TRUE; |
250 | 0 | oidc_warn(r, "not refreshing the JWKs from URI \"%s\": could not record the rate-limit marker", |
251 | 0 | url); |
252 | | // fall back to any cached JWKs |
253 | 0 | } else { |
254 | 0 | forced_refresh_attempted = TRUE; |
255 | 0 | oidc_debug(r, "doing a forced refresh of the JWKs from URI \"%s\"", url); |
256 | 0 | if (oidc_metadata_jwks_retrieve_and_cache(r, cfg, jwks_uri, ssl_validate_server, j_jwks) == |
257 | 0 | TRUE) |
258 | 0 | return TRUE; |
259 | | // else: fall back to any cached JWKs |
260 | 0 | } |
261 | 0 | } |
262 | | |
263 | | /* see if the JWKs is cached and decodes cleanly (a cached error response is treated as a miss) */ |
264 | 0 | if ((oidc_cache_get_jwks(r, oidc_metadata_jwks_cache_key(jwks_uri), &value) == TRUE) && (value != NULL) && |
265 | 0 | (oidc_json_decode_and_check_error(r, value, j_jwks) == FALSE)) { |
266 | 0 | oidc_warn(r, "JSON parsing of cached JWKs data failed"); |
267 | 0 | value = NULL; |
268 | 0 | } |
269 | |
|
270 | 0 | if (value == NULL) { |
271 | | /* a forced refresh that was throttled or already failed must not fall through into another |
272 | | * unguarded fetch. An ordinary initial cache miss (no forced refresh was requested) is not |
273 | | * throttled and still fetches normally. */ |
274 | 0 | if (forced_refresh_attempted == TRUE) |
275 | 0 | return FALSE; |
276 | | /* it is non-existing, invalid or expired: do a forced refresh */ |
277 | 0 | *refresh = TRUE; |
278 | 0 | return oidc_metadata_jwks_retrieve_and_cache(r, cfg, jwks_uri, ssl_validate_server, j_jwks); |
279 | 0 | } |
280 | | |
281 | 0 | return TRUE; |
282 | 0 | } |