ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
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 * pc_rsa_verify() (real RSA modexp; mbedTLS on ESP32). Claims are read only
11 * after the signature verifies.
12 */
13
15#include "shared_primitives/strbuf.h" // pc_sb frame builder
16
17#if PC_ENABLE_OIDC
18
20#include "network_drivers/presentation/codec/base64/base64.h" // shared pc_base64url_decode
21#include "server/mmgr/scratch.h" // per-dispatch arena (keeps the decode buffers off the worker stack)
22
23#include <stdio.h>
24#include <string.h>
25
26namespace
27{
28// base64url decoding is shared with JWT in the base64 module (pc_base64url_decode).
29
30// Bounded substring search of [hs, he) for the NUL-terminated needle.
31const char *mem_find(const char *hs, const char *he, const char *needle)
32{
33 size_t nl = strnlen(needle, (size_t)(he - hs) + 1);
34 // Both callers pass a quoted member name ("\"alg\"", "\"keys\"", ...), never the empty
35 // string, and strnlen's limit is always >= 1, so nl >= 1 here.
36 if (nl == 0 || (size_t)(he - hs) < nl) // GCOVR_EXCL_LINE nl == 0 unreachable (see above)
37 {
38 return nullptr;
39 }
40 for (const char *p = hs; p + nl <= he; p++)
41 {
42 if (memcmp(p, needle, nl) == 0)
43 {
44 return p;
45 }
46 }
47 return nullptr;
48}
49
50// Locate the JSON member @p name within [s, e). On success sets *vstart/*vlen to
51// the value extent and *type to 's' (string, between the quotes), 'n' (number,
52// including a leading '-'), or 'a' (array, including the brackets).
53bool find_field(const char *s, const char *e, const char *name, const char **vstart, size_t *vlen, char *type)
54{
55 char needle[96];
56 pc_sb sb_needle = {needle, sizeof(needle), 0, true};
57 pc_sb_put(&sb_needle, "\"");
58 pc_sb_put(&sb_needle, name);
59 pc_sb_put(&sb_needle, "\"");
60 int nn = (int)pc_sb_finish(&sb_needle);
61 // Every internal caller passes a short fixed field name (alg/iss/aud/exp/nbf/sub/email/n/e/kid), so the
62 // 96-byte needle never overflows, and snprintf of one %s into memory cannot report an error.
63 if (nn <= 0 || nn >= (int)sizeof(needle)) // GCOVR_EXCL_LINE unreachable: field names are short literals
64 {
65 return false; // GCOVR_EXCL_LINE (see above)
66 }
67 const char *p = mem_find(s, e, needle);
68 if (!p)
69 {
70 return false;
71 }
72 p += nn;
73 while (p < e && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r'))
74 {
75 p++;
76 }
77 if (p >= e)
78 {
79 return false;
80 }
81
82 if (*p == '"')
83 {
84 const char *q = ++p;
85 while (q < e && *q != '"')
86 {
87 if (*q == '\\' && q + 1 < e)
88 {
89 q++;
90 }
91 q++;
92 }
93 if (q >= e)
94 {
95 return false;
96 }
97 *vstart = p;
98 *vlen = (size_t)(q - p);
99 *type = 's';
100 return true;
101 }
102 if (*p == '[')
103 {
104 const char *q = p;
105 while (q < e && *q != ']')
106 {
107 q++;
108 }
109 if (q >= e)
110 {
111 return false;
112 }
113 *vstart = p;
114 *vlen = (size_t)(q - p + 1);
115 *type = 'a';
116 return true;
117 }
118 if (*p == '-' || (*p >= '0' && *p <= '9'))
119 {
120 const char *q = p;
121 if (*q == '-')
122 {
123 q++;
124 }
125 while (q < e && *q >= '0' && *q <= '9')
126 {
127 q++;
128 }
129 *vstart = p;
130 *vlen = (size_t)(q - p);
131 *type = 'n';
132 return true;
133 }
134 return false;
135}
136
137// Copy a string member into @p out (minimal unescape: drop the backslash). False
138// if absent / not a string / does not fit.
139bool get_str(const char *s, const char *e, const char *name, char *out, size_t cap)
140{
141 const char *v;
142 size_t vl;
143 char t;
144 if (!find_field(s, e, name, &v, &vl, &t) || t != 's')
145 {
146 return false;
147 }
148 // A while loop so a JSON "\x" escape can consume its second char without
149 // mutating a for-loop counter: take the char after the backslash literally.
150 size_t o = 0;
151 size_t i = 0;
152 while (i < vl)
153 {
154 char ch = v[i];
155 // i + 1 < vl always holds when ch is a backslash: find_field walks this same value with
156 // the same pairwise escape skip and only stops at an UNescaped '"', so a backslash it
157 // examined always had a following byte inside the value. A value can therefore never end
158 // on a backslash that this loop reaches as a fresh iteration - the bound is defensive.
159 if (ch == '\\' && i + 1 < vl) // GCOVR_EXCL_LINE i + 1 < vl cannot be false here (see above)
160 {
161 ch = v[++i];
162 }
163 if (o + 1 >= cap)
164 {
165 return false;
166 }
167 out[o++] = ch;
168 i++;
169 }
170 out[o] = '\0';
171 return true;
172}
173
174// Read a numeric member as 64-bit (epoch seconds exceed 32-bit). False if absent
175// / not a number.
176bool get_int64(const char *s, const char *e, const char *name, int64_t *out)
177{
178 const char *v;
179 size_t vl;
180 char t;
181 // vl == 0 is unreachable for a type-'n' value: find_field only reports 'n' after consuming a
182 // leading '-' or at least one digit, so the extent it hands back is always >= 1 byte.
183 if (!find_field(s, e, name, &v, &vl, &t) || t != 'n' || vl == 0) // GCOVR_EXCL_LINE vl == 0 unreachable
184 {
185 return false;
186 }
187 bool neg = (*v == '-');
188 size_t i = neg ? 1 : 0;
189 int64_t val = 0;
190 for (; i < vl; i++)
191 {
192 val = val * 10 + (v[i] - '0');
193 }
194 *out = neg ? -val : val;
195 return true;
196}
197
198// True if the `aud` member equals @p want (string form) or contains it (array).
199bool aud_contains(const char *s, const char *e, const char *want)
200{
201 const char *v;
202 size_t vl;
203 char t;
204 if (!find_field(s, e, "aud", &v, &vl, &t))
205 {
206 return false;
207 }
208 size_t wl = strnlen(want, vl + 1);
209 if (t == 's')
210 {
211 return vl == wl && memcmp(v, want, wl) == 0;
212 }
213 if (t == 'a')
214 {
215 const char *p = v; // points at '['
216 const char *end = v + vl; // just past ']'
217 // The loop always leaves via break or return, never via this condition: find_field sizes
218 // vl to include the closing ']', so a quote found inside can be at most end - 2 and the
219 // p = r + 1 step below therefore always lands strictly before end.
220 while (p < end) // GCOVR_EXCL_LINE p < end cannot be false (see above)
221 {
222 const char *q = (const char *)memchr(p, '"', (size_t)(end - p));
223 const char *r = q ? (const char *)memchr(q + 1, '"', (size_t)(end - (q + 1))) : nullptr;
224 if (!q || !r)
225 {
226 break;
227 }
228 q++;
229 if ((size_t)(r - q) == wl && memcmp(q, want, wl) == 0)
230 {
231 return true;
232 }
233 p = r + 1;
234 }
235 }
236 return false;
237}
238
239// Split a compact JWT into its three segments. Returns false unless there are
240// exactly two '.' separators and three non-empty parts.
241bool split3(const char *tok, size_t len, const char **seg, size_t *seglen)
242{
243 const char *d1 = (const char *)memchr(tok, '.', len);
244 if (!d1)
245 {
246 return false;
247 }
248 size_t rem = len - (size_t)(d1 + 1 - tok);
249 const char *d2 = (const char *)memchr(d1 + 1, '.', rem);
250 if (!d2)
251 {
252 return false;
253 }
254 size_t rem2 = len - (size_t)(d2 + 1 - tok);
255 if (memchr(d2 + 1, '.', rem2))
256 {
257 return false;
258 }
259 seg[0] = tok;
260 seglen[0] = (size_t)(d1 - tok);
261 seg[1] = d1 + 1;
262 seglen[1] = (size_t)(d2 - d1 - 1);
263 seg[2] = d2 + 1;
264 seglen[2] = rem2;
265 return seglen[0] && seglen[1] && seglen[2];
266}
267
268// Right-align @p len decoded bytes into a fixed @p width big-endian field,
269// tolerating a single leading zero byte (some encoders pad n) and leading-zero
270// omission. Returns false if the value does not fit.
271bool right_align(const uint8_t *src, size_t len, uint8_t *dst, size_t width)
272{
273 if (len > width)
274 {
275 if (len == width + 1 && src[0] == 0)
276 {
277 src++;
278 len--;
279 }
280 else
281 {
282 return false;
283 }
284 }
285 memset(dst, 0, width);
286 memcpy(dst + (width - len), src, len);
287 return true;
288}
289
290bool parse_rsa_jwk(const char *s, const char *e, pc_oidc_key *key)
291{
292 char b64[400];
293 if (!get_str(s, e, "n", b64, sizeof(b64)))
294 {
295 return false;
296 }
297 uint8_t tmp[PC_OIDC_RSA_BYTES + 8];
298 size_t nlen = pc_base64url_decode(b64, strnlen(b64, sizeof(b64)), tmp, sizeof(tmp));
299 if (nlen == 0 || !right_align(tmp, nlen, key->n, PC_OIDC_RSA_BYTES))
300 {
301 return false;
302 }
303
304 if (!get_str(s, e, "e", b64, sizeof(b64)))
305 {
306 return false;
307 }
308 uint8_t e_tmp[8];
309 size_t elen = pc_base64url_decode(b64, strnlen(b64, sizeof(b64)), e_tmp, sizeof(e_tmp));
310 if (elen == 0 || !right_align(e_tmp, elen, key->e, 4))
311 {
312 return false;
313 }
314 key->loaded = true;
315 return true;
316}
317} // namespace
318
319bool pc_oidc_token_kid(const char *token, size_t token_len, char *kid_out, size_t kid_cap)
320{
321 if (!token || !kid_out || kid_cap == 0)
322 {
323 return false;
324 }
325 const char *seg[3];
326 size_t seglen[3];
327 if (!split3(token, token_len, seg, seglen))
328 {
329 return false;
330 }
331 uint8_t hdr[512];
332 size_t hn = pc_base64url_decode(seg[0], seglen[0], hdr, sizeof(hdr) - 1);
333 if (hn == 0)
334 {
335 return false;
336 }
337 hdr[hn] = '\0';
338 return get_str((const char *)hdr, (const char *)hdr + hn, "kid", kid_out, kid_cap);
339}
340
341bool pc_oidc_jwks_find(const char *jwks_json, const char *kid, pc_oidc_key *key)
342{
343 if (!jwks_json || !key)
344 {
345 return false;
346 }
347 const char *all_end = jwks_json + strnlen(jwks_json, PC_OIDC_JWKS_MAX);
348 const char *p = mem_find(jwks_json, all_end, "\"keys\"");
349 p = p ? (const char *)memchr(p, '[', (size_t)(all_end - p)) : nullptr;
350 if (!p)
351 {
352 return false;
353 }
354 p++;
355
356 while (p < all_end)
357 {
358 const char *obj = (const char *)memchr(p, '{', (size_t)(all_end - p));
359 if (!obj)
360 {
361 break;
362 }
363 const char *end = (const char *)memchr(obj, '}', (size_t)(all_end - obj));
364 if (!end)
365 {
366 break;
367 }
368 end++; // include '}'
369
370 bool want;
371 char this_kid[PC_OIDC_KID_LEN];
372 bool has_kid = get_str(obj, end, "kid", this_kid, sizeof(this_kid));
373 if (kid && *kid)
374 {
375 want = has_kid && strcmp(this_kid, kid) == 0;
376 }
377 else
378 {
379 want = true; // no kid requested -> first usable RSA key
380 }
381
382 if (want && parse_rsa_jwk(obj, end, key))
383 {
384 return true;
385 }
386 if (want && kid && *kid)
387 {
388 return false; // kid matched but the key was unusable
389 }
390 p = end;
391 }
392 return false;
393}
394
395pc_oidc_result pc_oidc_verify_with_key(const char *token, size_t token_len, const pc_oidc_key *key,
396 const char *expected_iss, const char *expected_aud, uint32_t now_unix,
397 pc_oidc_claims *claims)
398{
399 if (!token || !key || !key->loaded || token_len == 0 || token_len > PC_OIDC_MAX_LEN)
400 {
401 return pc_oidc_result::PC_OIDC_ERR_FORMAT;
402 }
403
404 const char *seg[3];
405 size_t seglen[3];
406 if (!split3(token, token_len, seg, seglen))
407 {
408 return pc_oidc_result::PC_OIDC_ERR_FORMAT;
409 }
410
411 // Borrow the large decode buffers from the per-dispatch scratch arena rather
412 // than the worker stack (was ~2.6 KB of stack frame: hdr + sig + pl + iss).
413 // ScratchScope reclaims them on every return path. The four are live together,
414 // so PC_SCRATCH_WORK_OIDC is their sum and the arena is sized to hold it.
415 static_assert(PC_SCRATCH_WORK_OIDC <= PC_SCRATCH_ARENA_SIZE, "OIDC scratch exceeds the arena");
416 ScratchScope scratch;
417 uint8_t *hdr = (uint8_t *)scratch_alloc(PC_OIDC_HDR_LEN, 1);
418 uint8_t *sig = (uint8_t *)scratch_alloc(PC_OIDC_RSA_BYTES, 1);
419 uint8_t *pl = (uint8_t *)scratch_alloc(PC_OIDC_MAX_LEN, 1);
420 char *iss = (char *)scratch_alloc(PC_OIDC_ISS_LEN, 1);
421 if (!hdr || !sig || !pl || !iss)
422 {
423 return pc_oidc_result::PC_OIDC_ERR_FORMAT; // scratch exhausted: fail closed
424 }
425
426 // Header: require alg == RS256 (rejects alg:none / HS256 confusion).
427 size_t hn = pc_base64url_decode(seg[0], seglen[0], hdr, PC_OIDC_HDR_LEN - 1);
428 if (hn == 0)
429 {
430 return pc_oidc_result::PC_OIDC_ERR_FORMAT;
431 }
432 hdr[hn] = '\0';
433 char alg[16];
434 if (!get_str((const char *)hdr, (const char *)hdr + hn, "alg", alg, sizeof(alg)) || strcmp(alg, "RS256") != 0)
435 {
436 return pc_oidc_result::PC_OIDC_ERR_ALG;
437 }
438
439 // Signature: RSA-2048 -> exactly 256 bytes.
440 if (pc_base64url_decode(seg[2], seglen[2], sig, PC_OIDC_RSA_BYTES) != PC_OIDC_RSA_BYTES)
441 {
442 return pc_oidc_result::PC_OIDC_ERR_FORMAT;
443 }
444
445 // Verify over the signing input "header.payload" (pc_rsa_verify hashes it). RS256 = SHA-256.
446 size_t signing_len = (size_t)(seg[1] + seglen[1] - token);
447 if (pc_rsa_verify(key->n, key->e, (const uint8_t *)token, signing_len, sig, PC_OIDC_RSA_BYTES,
449 {
450 return pc_oidc_result::PC_OIDC_ERR_SIGNATURE;
451 }
452
453 // Claims (trusted only now that the signature is valid).
454 size_t pn = pc_base64url_decode(seg[1], seglen[1], pl, PC_OIDC_MAX_LEN - 1);
455 if (pn == 0)
456 {
457 return pc_oidc_result::PC_OIDC_ERR_FORMAT;
458 }
459 pl[pn] = '\0';
460 const char *ps = (const char *)pl;
461 const char *pe = ps + pn;
462
463 if (expected_iss && *expected_iss)
464 {
465 if (!get_str(ps, pe, "iss", iss, PC_OIDC_ISS_LEN) || strcmp(iss, expected_iss) != 0)
466 {
467 return pc_oidc_result::PC_OIDC_ERR_ISS;
468 }
469 }
470 if (expected_aud && *expected_aud)
471 {
472 if (!aud_contains(ps, pe, expected_aud))
473 {
474 return pc_oidc_result::PC_OIDC_ERR_AUD;
475 }
476 }
477
478 int64_t exp = 0;
479 if (!get_int64(ps, pe, "exp", &exp) || (int64_t)now_unix >= exp)
480 {
481 return pc_oidc_result::PC_OIDC_ERR_EXPIRED;
482 }
483 int64_t nbf = 0;
484 if (get_int64(ps, pe, "nbf", &nbf) && (int64_t)now_unix < nbf)
485 {
486 return pc_oidc_result::PC_OIDC_ERR_NOT_YET;
487 }
488
489 if (claims)
490 {
491 claims->sub[0] = '\0';
492 claims->email[0] = '\0';
493 claims->exp = exp;
494 claims->iat = 0;
495 get_str(ps, pe, "sub", claims->sub, sizeof(claims->sub));
496 get_str(ps, pe, "email", claims->email, sizeof(claims->email));
497 get_int64(ps, pe, "iat", &claims->iat);
498 }
499 return pc_oidc_result::PC_OIDC_OK;
500}
501
502pc_oidc_result pc_oidc_verify(const char *token, size_t token_len, const char *jwks_json, const char *expected_iss,
503 const char *expected_aud, uint32_t now_unix, pc_oidc_claims *claims)
504{
505 char kid[PC_OIDC_KID_LEN];
506 if (!pc_oidc_token_kid(token, token_len, kid, sizeof(kid)))
507 {
508 kid[0] = '\0'; // no kid -> let jwks_find pick the sole key
509 }
510 pc_oidc_key key;
511 key.loaded = false;
512 if (!pc_oidc_jwks_find(jwks_json, kid[0] ? kid : nullptr, &key))
513 {
514 return pc_oidc_result::PC_OIDC_ERR_KEY;
515 }
516 return pc_oidc_verify_with_key(token, token_len, &key, expected_iss, expected_aud, now_unix, claims);
517}
518
519#endif // PC_ENABLE_OIDC
size_t pc_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:333
Base64 encoder/decoder.
#define PC_SCRATCH_ARENA_SIZE
Definition c2_defaults.h:53
RAII scope guard for transient scratch borrows.
Definition scratch.h:196
OpenID Connect ID-token verification, RS256 (PC_ENABLE_OIDC).
#define PC_OIDC_MAX_LEN
Max accepted OIDC ID-token length (also sizes the Authorization buffer).
int pc_rsa_verify(const uint8_t n_be[PC_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, pc_rsa_hash hash)
Verify an RSA-2048 PKCS#1 v1.5 signature over msg.
Definition rsa.cpp:55
RSA-2048 PKCS#1 v1.5 signature primitive (RFC 8017) - verify + software sign.
@ SHA256
RSASSA-PKCS1-v1.5 with SHA-256.
void * scratch_alloc(size_t n, size_t align)
Borrow n bytes of scratch, aligned to align.
Definition scratch.cpp:135
Shared per-dispatch scratch arena (Layer 5, session-scoped memory).
Bounded no-heap string builder that fails closed on overflow (one shared copy).
size_t pc_sb_finish(pc_sb *b)
NUL-terminate and return the built length, or 0 if the build overflowed.
Definition strbuf.h:648
void pc_sb_put(pc_sb *b, const char *s)
Append NUL-terminated s; leaves the buffer untouched and clears ok if it would not fit.
Definition strbuf.h:60
Bump-append target; ok latches false once an append would overflow cap.
Definition strbuf.h:30