DeterministicESPAsyncWebServer v6.28.0
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
oidc.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file oidc.cpp
6 * @brief OIDC ID-token (RS256) verification - implementation.
7 *
8 * The shared base64url decoder (base64 module), small bounded JSON field scanners
9 * (no full parser, no heap), and the RS256 signature check delegated to
10 * ssh_rsa_verify() (real RSA modexp; mbedTLS on ESP32). Claims are read only
11 * after the signature verifies.
12 */
13
14#include "services/oidc/oidc.h"
15
16#if DETWS_ENABLE_OIDC
17
18#include "network_drivers/presentation/base64/base64.h" // shared base64url_decode
20#include "network_drivers/session/scratch.h" // per-dispatch arena (keeps the decode buffers off the worker stack)
21
22#include <stdio.h>
23#include <string.h>
24
25namespace
26{
27// base64url decoding is shared with JWT in the base64 module (base64url_decode).
28
29// Bounded substring search of [hs, he) for the NUL-terminated needle.
30const char *mem_find(const char *hs, const char *he, const char *needle)
31{
32 size_t nl = strnlen(needle, (size_t)(he - hs) + 1);
33 if (nl == 0 || (size_t)(he - hs) < nl)
34 return nullptr;
35 for (const char *p = hs; p + nl <= he; p++)
36 if (memcmp(p, needle, nl) == 0)
37 return p;
38 return nullptr;
39}
40
41// Locate the JSON member @p name within [s, e). On success sets *vstart/*vlen to
42// the value extent and *type to 's' (string, between the quotes), 'n' (number,
43// including a leading '-'), or 'a' (array, including the brackets).
44bool find_field(const char *s, const char *e, const char *name, const char **vstart, size_t *vlen, char *type)
45{
46 char needle[96];
47 int nn = snprintf(needle, sizeof(needle), "\"%s\"", name);
48 // GCOVR_EXCL_LINE every internal caller passes a short fixed field name (alg/iss/aud/exp/nbf/sub/email/n/e/kid), so
49 // the 96-byte needle never overflows
50 if (nn <= 0 || nn >= (int)sizeof(needle))
51 return false; // GCOVR_EXCL_LINE unreachable: field names are short literals (see above)
52 const char *p = mem_find(s, e, needle);
53 if (!p)
54 return false;
55 p += nn;
56 while (p < e && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r'))
57 p++;
58 if (p >= e)
59 return false;
60
61 if (*p == '"')
62 {
63 const char *q = ++p;
64 while (q < e && *q != '"')
65 {
66 if (*q == '\\' && q + 1 < e)
67 q++;
68 q++;
69 }
70 if (q >= e)
71 return false;
72 *vstart = p;
73 *vlen = (size_t)(q - p);
74 *type = 's';
75 return true;
76 }
77 if (*p == '[')
78 {
79 const char *q = p;
80 while (q < e && *q != ']')
81 q++;
82 if (q >= e)
83 return false;
84 *vstart = p;
85 *vlen = (size_t)(q - p + 1);
86 *type = 'a';
87 return true;
88 }
89 if (*p == '-' || (*p >= '0' && *p <= '9'))
90 {
91 const char *q = p;
92 if (*q == '-')
93 q++;
94 while (q < e && *q >= '0' && *q <= '9')
95 q++;
96 *vstart = p;
97 *vlen = (size_t)(q - p);
98 *type = 'n';
99 return true;
100 }
101 return false;
102}
103
104// Copy a string member into @p out (minimal unescape: drop the backslash). False
105// if absent / not a string / does not fit.
106bool get_str(const char *s, const char *e, const char *name, char *out, size_t cap)
107{
108 const char *v;
109 size_t vl;
110 char t;
111 if (!find_field(s, e, name, &v, &vl, &t) || t != 's')
112 return false;
113 // A while loop so a JSON "\x" escape can consume its second char without
114 // mutating a for-loop counter: take the char after the backslash literally.
115 size_t o = 0;
116 size_t i = 0;
117 while (i < vl)
118 {
119 char ch = v[i];
120 if (ch == '\\' && i + 1 < vl)
121 ch = v[++i];
122 if (o + 1 >= cap)
123 return false;
124 out[o++] = ch;
125 i++;
126 }
127 out[o] = '\0';
128 return true;
129}
130
131// Read a numeric member as 64-bit (epoch seconds exceed 32-bit). False if absent
132// / not a number.
133bool get_int64(const char *s, const char *e, const char *name, int64_t *out)
134{
135 const char *v;
136 size_t vl;
137 char t;
138 if (!find_field(s, e, name, &v, &vl, &t) || t != 'n' || vl == 0)
139 return false;
140 bool neg = (*v == '-');
141 size_t i = neg ? 1 : 0;
142 int64_t val = 0;
143 for (; i < vl; i++)
144 val = val * 10 + (v[i] - '0');
145 *out = neg ? -val : val;
146 return true;
147}
148
149// True if the `aud` member equals @p want (string form) or contains it (array).
150bool aud_contains(const char *s, const char *e, const char *want)
151{
152 const char *v;
153 size_t vl;
154 char t;
155 if (!find_field(s, e, "aud", &v, &vl, &t))
156 return false;
157 size_t wl = strnlen(want, vl + 1);
158 if (t == 's')
159 return vl == wl && memcmp(v, want, wl) == 0;
160 if (t == 'a')
161 {
162 const char *p = v; // points at '['
163 const char *end = v + vl; // just past ']'
164 while (p < end)
165 {
166 const char *q = (const char *)memchr(p, '"', (size_t)(end - p));
167 if (!q)
168 break;
169 q++;
170 const char *r = (const char *)memchr(q, '"', (size_t)(end - q));
171 if (!r)
172 break;
173 if ((size_t)(r - q) == wl && memcmp(q, want, wl) == 0)
174 return true;
175 p = r + 1;
176 }
177 }
178 return false;
179}
180
181// Split a compact JWT into its three segments. Returns false unless there are
182// exactly two '.' separators and three non-empty parts.
183bool split3(const char *tok, size_t len, const char **seg, size_t *seglen)
184{
185 const char *d1 = (const char *)memchr(tok, '.', len);
186 if (!d1)
187 return false;
188 size_t rem = len - (size_t)(d1 + 1 - tok);
189 const char *d2 = (const char *)memchr(d1 + 1, '.', rem);
190 if (!d2)
191 return false;
192 size_t rem2 = len - (size_t)(d2 + 1 - tok);
193 if (memchr(d2 + 1, '.', rem2))
194 return false;
195 seg[0] = tok;
196 seglen[0] = (size_t)(d1 - tok);
197 seg[1] = d1 + 1;
198 seglen[1] = (size_t)(d2 - d1 - 1);
199 seg[2] = d2 + 1;
200 seglen[2] = rem2;
201 return seglen[0] && seglen[1] && seglen[2];
202}
203
204// Right-align @p len decoded bytes into a fixed @p width big-endian field,
205// tolerating a single leading zero byte (some encoders pad n) and leading-zero
206// omission. Returns false if the value does not fit.
207bool right_align(const uint8_t *src, size_t len, uint8_t *dst, size_t width)
208{
209 if (len > width)
210 {
211 if (len == width + 1 && src[0] == 0)
212 {
213 src++;
214 len--;
215 }
216 else
217 return false;
218 }
219 memset(dst, 0, width);
220 memcpy(dst + (width - len), src, len);
221 return true;
222}
223
224bool parse_rsa_jwk(const char *s, const char *e, DetwsOidcKey *key)
225{
226 char b64[400];
227 if (!get_str(s, e, "n", b64, sizeof(b64)))
228 return false;
229 uint8_t tmp[DETWS_OIDC_RSA_BYTES + 8];
230 size_t nlen = base64url_decode(b64, strnlen(b64, sizeof(b64)), tmp, sizeof(tmp));
231 if (nlen == 0 || !right_align(tmp, nlen, key->n, DETWS_OIDC_RSA_BYTES))
232 return false;
233
234 if (!get_str(s, e, "e", b64, sizeof(b64)))
235 return false;
236 uint8_t e_tmp[8];
237 size_t elen = base64url_decode(b64, strnlen(b64, sizeof(b64)), e_tmp, sizeof(e_tmp));
238 if (elen == 0 || !right_align(e_tmp, elen, key->e, 4))
239 return false;
240 key->loaded = true;
241 return true;
242}
243} // namespace
244
245bool detws_oidc_token_kid(const char *token, size_t token_len, char *kid_out, size_t kid_cap)
246{
247 if (!token || !kid_out || kid_cap == 0)
248 return false;
249 const char *seg[3];
250 size_t seglen[3];
251 if (!split3(token, token_len, seg, seglen))
252 return false;
253 uint8_t hdr[512];
254 size_t hn = base64url_decode(seg[0], seglen[0], hdr, sizeof(hdr) - 1);
255 if (hn == 0)
256 return false;
257 hdr[hn] = '\0';
258 return get_str((const char *)hdr, (const char *)hdr + hn, "kid", kid_out, kid_cap);
259}
260
261bool detws_oidc_jwks_find(const char *jwks_json, const char *kid, DetwsOidcKey *key)
262{
263 if (!jwks_json || !key)
264 return false;
265 const char *all_end = jwks_json + strnlen(jwks_json, DETWS_OIDC_JWKS_MAX);
266 const char *p = mem_find(jwks_json, all_end, "\"keys\"");
267 p = p ? (const char *)memchr(p, '[', (size_t)(all_end - p)) : nullptr;
268 if (!p)
269 return false;
270 p++;
271
272 while (p < all_end)
273 {
274 const char *obj = (const char *)memchr(p, '{', (size_t)(all_end - p));
275 if (!obj)
276 break;
277 const char *end = (const char *)memchr(obj, '}', (size_t)(all_end - obj));
278 if (!end)
279 break;
280 end++; // include '}'
281
282 bool want;
283 char this_kid[DETWS_OIDC_KID_LEN];
284 bool has_kid = get_str(obj, end, "kid", this_kid, sizeof(this_kid));
285 if (kid && *kid)
286 want = has_kid && strcmp(this_kid, kid) == 0;
287 else
288 want = true; // no kid requested -> first usable RSA key
289
290 if (want && parse_rsa_jwk(obj, end, key))
291 return true;
292 if (want && kid && *kid)
293 return false; // kid matched but the key was unusable
294 p = end;
295 }
296 return false;
297}
298
299DetwsOidcResult detws_oidc_verify_with_key(const char *token, size_t token_len, const DetwsOidcKey *key,
300 const char *expected_iss, const char *expected_aud, uint32_t now_unix,
301 DetwsOidcClaims *claims)
302{
303 if (!token || !key || !key->loaded || token_len == 0 || token_len > DETWS_OIDC_MAX_LEN)
304 return DetwsOidcResult::DETWS_OIDC_ERR_FORMAT;
305
306 const char *seg[3];
307 size_t seglen[3];
308 if (!split3(token, token_len, seg, seglen))
309 return DetwsOidcResult::DETWS_OIDC_ERR_FORMAT;
310
311 // Borrow the large decode buffers from the per-dispatch scratch arena rather
312 // than the worker stack (was ~2.6 KB of stack frame: hdr + sig + pl + iss).
313 // ScratchScope reclaims them on every return path; fail closed if the arena is
314 // exhausted. Sizes: header 512, signature DETWS_OIDC_RSA_BYTES, payload
315 // DETWS_OIDC_MAX_LEN, issuer 256.
316 const size_t hdr_cap = 512;
317 const size_t iss_cap = 256;
318 ScratchScope scratch;
319 uint8_t *hdr = (uint8_t *)scratch_alloc(hdr_cap, 1);
320 uint8_t *sig = (uint8_t *)scratch_alloc(DETWS_OIDC_RSA_BYTES, 1);
321 uint8_t *pl = (uint8_t *)scratch_alloc(DETWS_OIDC_MAX_LEN, 1);
322 char *iss = (char *)scratch_alloc(iss_cap, 1);
323 if (!hdr || !sig || !pl || !iss)
324 return DetwsOidcResult::DETWS_OIDC_ERR_FORMAT; // scratch exhausted: fail closed
325
326 // Header: require alg == RS256 (rejects alg:none / HS256 confusion).
327 size_t hn = base64url_decode(seg[0], seglen[0], hdr, hdr_cap - 1);
328 if (hn == 0)
329 return DetwsOidcResult::DETWS_OIDC_ERR_FORMAT;
330 hdr[hn] = '\0';
331 char alg[16];
332 if (!get_str((const char *)hdr, (const char *)hdr + hn, "alg", alg, sizeof(alg)) || strcmp(alg, "RS256") != 0)
333 return DetwsOidcResult::DETWS_OIDC_ERR_ALG;
334
335 // Signature: RSA-2048 -> exactly 256 bytes.
336 if (base64url_decode(seg[2], seglen[2], sig, DETWS_OIDC_RSA_BYTES) != DETWS_OIDC_RSA_BYTES)
337 return DetwsOidcResult::DETWS_OIDC_ERR_FORMAT;
338
339 // Verify over the signing input "header.payload" (ssh_rsa_verify hashes it). RS256 = SHA-256.
340 size_t signing_len = (size_t)(seg[1] + seglen[1] - token);
341 if (ssh_rsa_verify(key->n, key->e, (const uint8_t *)token, signing_len, sig, DETWS_OIDC_RSA_BYTES,
342 SshRsaHash::SHA256) != 0)
343 return DetwsOidcResult::DETWS_OIDC_ERR_SIGNATURE;
344
345 // Claims (trusted only now that the signature is valid).
346 size_t pn = base64url_decode(seg[1], seglen[1], pl, DETWS_OIDC_MAX_LEN - 1);
347 if (pn == 0)
348 return DetwsOidcResult::DETWS_OIDC_ERR_FORMAT;
349 pl[pn] = '\0';
350 const char *ps = (const char *)pl;
351 const char *pe = ps + pn;
352
353 if (expected_iss && *expected_iss)
354 {
355 if (!get_str(ps, pe, "iss", iss, iss_cap) || strcmp(iss, expected_iss) != 0)
356 return DetwsOidcResult::DETWS_OIDC_ERR_ISS;
357 }
358 if (expected_aud && *expected_aud)
359 {
360 if (!aud_contains(ps, pe, expected_aud))
361 return DetwsOidcResult::DETWS_OIDC_ERR_AUD;
362 }
363
364 int64_t exp = 0;
365 if (!get_int64(ps, pe, "exp", &exp) || (int64_t)now_unix >= exp)
366 return DetwsOidcResult::DETWS_OIDC_ERR_EXPIRED;
367 int64_t nbf = 0;
368 if (get_int64(ps, pe, "nbf", &nbf) && (int64_t)now_unix < nbf)
369 return DetwsOidcResult::DETWS_OIDC_ERR_NOT_YET;
370
371 if (claims)
372 {
373 claims->sub[0] = '\0';
374 claims->email[0] = '\0';
375 claims->exp = exp;
376 claims->iat = 0;
377 get_str(ps, pe, "sub", claims->sub, sizeof(claims->sub));
378 get_str(ps, pe, "email", claims->email, sizeof(claims->email));
379 get_int64(ps, pe, "iat", &claims->iat);
380 }
381 return DetwsOidcResult::DETWS_OIDC_OK;
382}
383
384DetwsOidcResult detws_oidc_verify(const char *token, size_t token_len, const char *jwks_json, const char *expected_iss,
385 const char *expected_aud, uint32_t now_unix, DetwsOidcClaims *claims)
386{
387 char kid[DETWS_OIDC_KID_LEN];
388 if (!detws_oidc_token_kid(token, token_len, kid, sizeof(kid)))
389 kid[0] = '\0'; // no kid -> let jwks_find pick the sole key
390 DetwsOidcKey key;
391 key.loaded = false;
392 if (!detws_oidc_jwks_find(jwks_json, kid[0] ? kid : nullptr, &key))
393 return DetwsOidcResult::DETWS_OIDC_ERR_KEY;
394 return detws_oidc_verify_with_key(token, token_len, &key, expected_iss, expected_aud, now_unix, claims);
395}
396
397#endif // DETWS_ENABLE_OIDC
#define DETWS_OIDC_MAX_LEN
Max accepted OIDC ID-token length (also sizes the Authorization buffer).
size_t base64url_decode(const char *src, size_t src_len, uint8_t *dst, size_t dst_cap)
Decode src_len characters of base64url (RFC 4648 section 5, '-'/'_' alphabet; an '=' ends the input).
Definition base64.cpp:200
Base64 encoder/decoder.
RAII scope guard for transient scratch borrows.
Definition scratch.h:110
OpenID Connect ID-token verification, RS256 (DETWS_ENABLE_OIDC).
void * scratch_alloc(size_t n, size_t align)
Borrow n bytes of scratch, aligned to align.
Definition scratch.cpp:85
Shared per-dispatch scratch arena (Layer 5, session-scoped memory).
int ssh_rsa_verify(const uint8_t n_be[SSH_RSA_KEY_BYTES], const uint8_t e_be4[4], const uint8_t *msg, size_t msg_len, const uint8_t *sig, size_t sig_len, SshRsaHash hash)
Verify an RSA PKCS#1 v1.5 signature (rsa-sha2-256/512) with a public key.
Definition ssh_rsa.cpp:181
RSA-SHA2-256/512 host-key signing and public-key serialization.
@ SHA256
rsa-sha2-256