From b65f56c990d112af81e2bce9f4c722e35953be16 Mon Sep 17 00:00:00 2001 From: Shane Date: Sat, 21 Mar 2026 18:52:01 -0500 Subject: [PATCH] fix(jwt): restore verify defaults while enforcing audience checks --- .changeset/khaki-plants-grin.md | 9 + bun.lockb | Bin 263984 -> 264296 bytes packages/openauth/package.json | 2 +- packages/openauth/src/client.ts | 12 +- packages/openauth/src/issuer.ts | 55 ++++-- packages/openauth/test/client.test.ts | 23 ++- packages/openauth/test/issuer.test.ts | 30 +++- .../test/jwt-audience-validation.test.ts | 167 ++++++++++++++++++ packages/openauth/test/scrap.test.ts | 13 +- 9 files changed, 282 insertions(+), 29 deletions(-) create mode 100644 .changeset/khaki-plants-grin.md create mode 100644 packages/openauth/test/jwt-audience-validation.test.ts diff --git a/.changeset/khaki-plants-grin.md b/.changeset/khaki-plants-grin.md new file mode 100644 index 00000000..8731af2e --- /dev/null +++ b/.changeset/khaki-plants-grin.md @@ -0,0 +1,9 @@ +--- +"@kagii/openauth": minor +--- + +Improve JWT audience validation in `client.verify()` while preserving existing behavior. + +`verify()` now validates token audience against the client `clientID` by default, with optional override via `options.audience` when needed. + +This release also adds audience validation regression tests and improves `/userinfo` handling for invalid tokens. diff --git a/bun.lockb b/bun.lockb index ec8160e6bb7a1c694bc37a75aa9c60d054d4def1..3ec27ddf3491e591f4b7236bce50f8e9ecf48998 100755 GIT binary patch delta 21993 zcmcJ1dtA>~{Qvv?{`8@Dgxr!3(#$1RNwVsDv9Im>`{&oA*Ll6q^SsXMyw2-f-ejT)^iiOM0l(&=UZ)RG5SEa%H zoC!_r+*dW0q$-k>v=F=!c>5-Tdx2L(d^Ye3;Hy}HotC)BLz3zuHW|DQ_+aqb;9U&- zEpQjaD}mPozx9?Ry$OBEYlFzyrbG0DlwQ72Im@>&=ARQE-%(n6v>G zBrs0^Nn^onh(~}sf{%`giysmfC)M^9@lj!;Vj_o1Qh40pv5`?jrQNM1sVd~Mz^j4J z2d@r3&A>+)@d4m9fp-BXIUhYeQA%<(1XM%d&sIXg1#o8w9tNk3+rcSgCO8!^7n};7 zWW>Y4Ya;$GILUPYuL153PV&{jYl1&VMJRp;ylNsY?l+VqC-CsNsK`fEG=a`!vE`lprgsyn!sAAd2@jW~c*s&+ zp8=;L!-m9+AzLGWQxOf{5qMvgU*0wGy&l4Vh_E=1VNqejyCGfyQnsF=X-9yQp?+ zW^hiOoOQIT=+Qe1fSxbQKOGztY!^(BmNnv9$gryku3oJPoF z;FPZs;?!;n(FMuBHHL|bJVV@BY9}Qn>K9T{&EcXUM#RA+Ns{ahg=WKrAYKCsG;pfP zW0vXQtSyU5^^z^P3Kg6lJ6ptwkK2d|9;CvYnGaf~SNDmWE*+~9fO zRH;=4p9fBcOg4Cg!TW=gR~L*IUXI87GZ=>U1YQC7keH~LF%r7+j0vLIPJ+_}@;@MtDG zx;hO+jLPT-P8N4z-j2>goL# ziMrgHEu3zbE^2cRIIK@hI){WR5VTt?1SXD;j2{vqNgQ!9AYh45=nhWR`ozE&EEUe0 z4Nlb`4Q>P9lOYPoGWa}j%6I%r;jR$KIU^pY>%rz>ATD4@(vTrBV`=S9L9GWJKLK7J{K866fi}Rs5pNDoLn(E&@JxH~ zn&@IlaR%@%xEtaP!O6mk;N;4FSweA3a7wRb#AR@jyR}yELh!nXOUOqCUinHCXhXqN z!F4d4JXN$_c)aNb_@DaU3S3aXn-5Mk-UR_t+-;*6M2*3zUl65`~kKM=1CJ}g&s+5&^u+bQB>!iR@XK>ppp$4g13W&Efcr$QSk}OGY%wp%NHj+m%xtgVT6KqdlS`qa)*`9?ZL% zbE3IdHuuoxp4r?Jn|oh#Pi$_|&CR*F2RHZLnXQB?&Aqj`hc@@l=4Rj6%y)XRIX5@o z=3Rk#7a(@~Wt(<=^NnhZ36Dq^9WF_2m|ER6ap*Bo$pi2Qb#@2%m3U|1mxn-%QGM>pK-p8ZbW zt@G;Ne#_At>g@DxgRHEmU2C~N%Ma4jZH*7}XP0EIh~Bqn>3WNdFuZprcx4rOv5&3>7?By}@#)nc3bSXp$R2J&^5)<=^Y zva<+3VQ#^ie49lg^kF#&x3fnGtFVA~HEX1&B=uuYg9GJnSP|kb&2g)u$D>%-dz!TX z@kl-1f|Vl9yv+FDEV-{{4M7!$>*?Pzr+!psCc+sMTCcQ_q)%<9Ovvw#mZ>w*{YVpfEBvv$}r>GI=Q*hiXm58^_< zm6ami9Ze$QX)HNZvlb&R^fzEmA8Xd=4rcmxmWjChwi)lvya#G>Doa4vkL4q@W9lGH zj%L9Kx3hGF*I3CQO?B%eT3%-Pz5V4-78|C?oaG@bWb$B5trmzy8EtG~Y3KtVv9!UO zbvLl~T>eF_#_BiqlK*0ELo~S^i$pkyJFr*}f4Mx%2iC8LQCQgUj!Yh@sbM`N z2^CH%ujg9L(ts5Mqng<1B{pGhQJNax^M$L!{N>Fo2bg1!Bz>f7qrn)-0-`lF4;XqU z@@5Y5m+!EmXzJQ7qcnLW3mc`W8+u{bARSG%r6Vf^-i`UiX!10cjBr0IKvrm zeMEgMx@AjP=4eg1*@rc4*;RIB-m#k6KiCu?$E;%cz^(#Ar+|8Lc5@ay1`YVGB((-s zNpHaAtYnO4Jp-&e>ktztw_>qznw-S)5Ed{wUIWq{;S81*uUUt{)^2Rdz(C9lZeulT zm-i*9uO3flIb${TBI05`3Xb=OT>;}XHKf02P(`-^9cG+n{S#OpJzp0VHeOR_V-cmE zDYLYW{_;UqI$o2XGQSBLdTBDk$E*P156me+Lzf6an8z{^+OwMo6Pfo!8UqOkJFt9& zvzaOx4s@L&QL^u#&g@8TfOsjV5LAeF~4L@_F~D&nmTQmB%v=FBl8$@nx?7Eh6}%m z(L9l5PSdO#fOXVuzsW&?rtv7|lv%Xx9Ca zxUbeTdb8M1G&OF7aCkMnQL>r*>0J39OZ!xx=4Wd1&&+M6rusyS-dR~+UQ$>NQqJq4 zCksf?)WlJe)Ej1t^y923MU!2a%PdXyWnr^4b!n`G=N;6nVS)bgb5@F!K`i+*P2NkP zS}zV0`-}8R%xShJ|A^4}i_TuMH_M!@S!cylYt)?;C>JyDIU4$KKEgx_ttZAxQZGHN zE(=bjAytAfmqM#$Jk7_HuQ!YRTvJyePJ>uxIhZ0YGkLBiS7+T3zRS`O&SPg0USMwX zG}R+P$V&QjGLq#0TMGxM z(aM77`op#Pz~ZKg1}vwK;%pYYP-B-C+OeezJFC@kOMvAS@|iPQv-!G2 zsng2B68z=mENv0h_$*TDCu3YAhlRAM5s1+!w&>+$v7B^GKF1!VL&G$oS)o2}?TA=s z=Fu_`vqRBhP5zd-EYY0Q=|V=9#GgGYa&N8r8E831GeaaRU81Qgfzh&Ir{{j z(DbWNz3ktbDM=j=qn%MLHhNhT*pa?Wlee;xWtu9dmK*#m73ZxRgy5#C6!}o zCp)qtAbWveqhWaZAq!ijsRKS2n4Lao(pc##O{qASwO!p69XxrprWVc>Rfj9#qUX$M z4Ql0wD zS!iIO+=7*4Y3ip;I8babcd^)YntB%)mOmIxRqx5Ha4fHP^-LC) z4LgB-q{|FtrHHRtgb9>&SRJUkq)QT3GqD#@MlUL)EbM$WUK1_TgNhi z6$2x?gwsD@-kY(xULu-Hq1jKZxKxs`m?t@sQ+)UCj! zlE=!FpQp)7Snw`QE~QY_zQS`OQ=hz!l_2FdbKk95>#wJ+0`_dy(YR`_w|NPZzt+@_ z8%(=sJG-5w0kdR_zHiZ2ERDJC(bQ2J@rpv%P5ZLTEC*Q6O=fw7ZDax8U=rMnR~9ei zQdki%DaX`DE%O}<+pDSWTg(&Z#u+iO zaoo?`4rywWUBWieUq-N;L$ugFLih^{_)b&(cbl6{@@X2R10KoLW14)31s~Jw|29%y7>Px@ z!V4Q)?hK804tLo^#^7X@OpW&&NNhu8HG8>k=XwwS`b6k5w4 zrui1q*52k7ZW~KZlYWHO1_jiEC_U1=!sb=}Nt5sEVQZF#>q&aJmqOKYRBSXH_4%hW z%Q>T|p95=YHio>91)SB?2f(OD3O_`%B4BHe34fK>N5B;pc1}}W3&n;++?7pbrNE8@ z^G99>x@E9B9w$%j=^d!{pex{FRLx>eKWpj%U~ONN{fuP-^Ex3gdwo?I!Muw#bq%nN zki*^D41e5!Mwbos<_x*jy4u4| zOK;E2&i&Iz&gf_VtnxEik|xXiD<8QJUhCUQk~D><&yxGgQ+X+|MBe=~uq2*IESWz7 z<5{z1M;t+KOFb9C+8~~s2kU0SCQvvKDU?x{n0qjhbEApsRxhJpkxxAVe zd&w2#*)#%r$_lSES9a!wpF=v8yUd03;kn2@WiEiZynuiS^8k3v1CYiO<^gDu2B4S# z#@*8ZoFO1J4Zs3^mVlHm0Ce~Q0Ozy50MG_+VfY;a(zzc4aD#vh24D%lNx-7{0D|TN z$l&So0d!jc;3)yic=rVW9uknf06->xL_pR;0HF&3tmNw!0vNynIC221c?bt!zX-q{ z0@iYM5rCZpL@xrcj^_~&kq)44I)L>&G95sz#cj|j-h1Q41DU_W1% z31Gkq0FEmF9ONM@0NAetu!n%}xVjR+P6DD=0w~~l1VpR?PhHpvhVQ#RQzu_>C73Fd+wkM-G5eo{$5e$rb>`1l;HDTL7FP zAax6XKlxb#Qnmu(5 zfSm+H=K)Z89sv=%0My+DpaPHF1)$b$0EGlNa+loz4ihkCH-O5#fPe{K1Mv77fD=#n z8bFgh0E!8y#@+V-I72|{9so7?SprhN0np(a0M2~YHvrn~1#pJ|7w)$gzzqU2_5!f+ zn*=QS7C_Lq0P6DeZvk}62k?{tSKd7zz(WGE^8vW=M+9W;0}#3oKm)#RAAkY-0XXgl z(2$4h2Vj2yz#ak`bM*j#odiT50N~E^2#7ccpzc8c&3NQN0JRPQC?vpxyBq>=n1CsV z05sIP)vXicRvi^3<0T!0r>K>1f&!I=uiNlHJ?=gpv@5g zcL?y~en$Y@ARyxifOh;Q0gH|T2s#R&15ZB+pxZG3PYLkn-H!oyNI>>600I0F0a=9r zLJI)|@^ys(1{??AcpN|%9&#Lj{Rsej2++8C0>DlJqE7(m&hrR}I0>NcNdP@~e}SwA3Xa~i-M0z$aoX#h6}$T$t)Lw*y0{1FfM5iFFa6Z@E#5*x_7{{%LO zXA%qJkBANC!DqmR@O8w7a``M+I1eE1oB+nx@f;*iD8_6SyMe%%M z(cI-{uu(jgSPU;9Hk!K?gT?X$Fy7<>Of4>ksd3!>0)R6Fq+S3pmY*dc*=F$hZVx62D2nq7nc>B><-I^b!EwE(3Tt^#np3g8nSauo)DnwxV~o>{>UKlj-+d9k8rG`}8Oms|Qk{>#pK zCKPYuNpB1#V`PW@EmxDr<&OPZo-SKs@kCUm)Jc@eCqI+zt=@Rp>7Zl0{_nEYUjN!a zlCI=BJ(sz~nluT9<|0d=OnDs5k4Dxd;-xm>Yjs>bghxyc*bSb{&9o{@D!7<$W`{&Y zdeR4IR15tRrsUqcA=eTazTkl0nGqS2Yt%Z)+^O|ZNTLUPzER!GzWx#iL&4+m(&)KC7^Ur zCBzqjlN|n~P6*n8R^$2_h|B!$dZkgKi4iBfDrhmTmx6BMIum?3Xc=fO=u6_jNiU_F zwh%_N9{5+Fbsz@H0+9~NyBb6`tp%+FA#b9th%&ALKmw~kl!+>u416PaTe#5&G#}TW zg8P9e4;efb*QTKjaJ>gI@!)YF(iaO}5pqI3Qx>IDh{ z1%hzfDfI;J0a}QRtr33**WE$gKvb(v-~k}2c}MWKK?`tw57YtIGmvK+uG{0fC8!0+ z3)CFsnTTKu5_3SCK<|S#gUp&x+Z_Ydhg=2FAGp2`Dg{vi7eJ)^Jm@HB7l^#K6SN() z6_g7i8R9!YgqzoibdhHOrqFkY7l6J6eFGww?go+U*9NC+!uEnlp5m0}0BApm^6UfU zgD8#SBzG8e2t+amsr}8&M*#c)DgsfBO=C{tniQV^9S0SHNZBdS_aKs^Ybu2JkD${a z(qYP&*FU|4ll=G8{%3J<21H5;BsvE&4J*d=&jwEJBb?|Wh!mT0zu}r{cNcUAbQNUO zQYyjqCD0YnWf0{duis8YK()IL`UP|iL^U#vxrOVSpc@AL3Qn1SH#jMwg2)icL-9XB zBu5ym>ga z^c+O_$Y9b-H|JEbzd>dhWCY0?@kH}N2gwLhM%*l$p0Nj`%__L)yo-PzE)8Hxsk{j*Hu7&;JPBX>0-y1uF2h%5w8RycaxFiYHC*M z1Eh!gQBB}AX#7_PRRdK8If2NHrcwX2{NHfXtyCDf-K;6$l!xM`E^0Pv7V=sm)s!M; z2I@R@Ay5Zo1G#`|fvARLIVmKWm*cgO_A>VJ^~>^9AnBlyLU=s|`&QtN3#gFc0LsWB2CGs1NFf2m$g=dmeO5aghzSiVr`gWaHUU zjY6dzo}mO6Dn59~@kybw1h*V+#}yx&d1z*L^_o>f=cZ4ylUsVWXhVg~<1xn-7hm&~ zP4MEocRMaGdR8W29K=cbvBmD^slWG^%Tli6)(&QxCwI0sv5=DUaw*&&0>0)^q0oZZE}h?*@_m_rd6=kmo9pIrH?=v>wYw{<_YgXPMk&Uil>C7IF8JN|0Qe z`!}_q;V}Go8sX3RdN7;$o#MN1pKMmAuG3mO*~_y3sdJ03Sl)L@_w^}-iFIzvD={C`C`-6i*yp?AwsR~js6C}0u?$Nh)^2H5E zF@~}yk49FE!i#@_b>f*nD9N_I5T|lK&)m}Q>68=wbi=&Jun6AaH00uW?@NjcAAMTs zCC}u030la{o>qp-Yw%P8!#M{U8bZVSwYwF$mToO5EAU4?_eY3d=KepUhQC6tGURGh z$=N+|?x;n&oZc#rc`+2p9eMUoD5>dBN^RRa0pjD-Z!2A2r)?cd{_=*uaC811ANUhW z4TV4zXq%AtXKQQQ?&~1{ccT81P-8O>ptV`(G2d-}RhTu^%Tq5-2;h;<6BK7N51RE4 z{vjyz_kt3`d^jM7H$Q_}s|P=N2DSK^Upu1&*~}wm(eCvIe>Hz_Z$z1kcX7Aeke@)3ZF)Cx!#=XZ{FK;JKYxc3eMMiG&MoIqH}gx~Ud{Kd za5=KSIs{sJq7kX`!4Sa1g@L6gV+3DDF}+pv`QakJa}M}@Uj96APu}i4V(oap^U8L4 zGOzG6K+za-3J;+e=c&XR@qDz9=n^in8~^hq$#@=EjQ(unF~y3Te2%9TL-GoDE&0zT z6=TBJJX2PZay~w%*AaVB6Y5^(VX(B^{re-|@J>-=tN?AOjupAb1r*VYKLz81E+|!v z!Q^TVCi5WLu(SSK`gyduRz>d751S<(c2TK|S0OU`E8c^=qy_I3ek*w~DCM?1^r8|2 zR}@}+QI}Wq@s^j+Zf1F62pAF~>Fo|;_y*y@)qi%PSIpsdB}#3>d9pEWykgpmB8_aH z@!jyV;fw!O3*XC%kGdm7yf5y=V=qHAh|dPInWy0z)vwvwb#lLAIKH)K3tvw^X$jv; za@qXsWu=+A3tvFcgPc%aa#g8e_3~^jMRKRBO7(Ir=z1IvxB}rh{6jFCc{HzX+B>6$ zEN9~t8PC%6KRtW(w)(u$=_7H(q2a+b?=`lKuq!LtJj(Y=pNW>Be(AX{rIZyfRF%_J z>87~gxu@F=Sh0k!zXjHb54@@*sCPaVGoUW-g6EiIHLrXPYr$hA;W12-b~&m`)BM~# zQBEs#E_I@se8cDc4Xx^I+YtHx@5`ulfF-$VYPu`eX>tfdHO_ z9wnB_uU%7oe5(!;bJV>9Q${cS>DVcV`(l`4owgwbk3N!O7M-X(HKiv<3c3qTK8<pQ6a^fjC08A76s3wn!tITkGJ@ zCr>`RY8dI=290&%7bU3LKMSuSU*gTLW9*7rE7DLNbzP}t`}fx9#FvqKj6OkDiLCtg zb)}`*8ivDr-%zZw;a1(BF0$@TXG;s|F<*HDvr8!dNie+?U(T1v{cob6g*^0@;#}8P z5=SDXq(R+>RCr@nzjLUYkC^ys@cf$)X(^e9BukzZZfw%hQLG{@$mzO>q3jjoMTzzG z-ftexd}sUfOBX)6M9ZBqE?&09$nIiR?B$;4l6&En(nhwK=LI)r-JkjH;vB3m-qiZ& zEKRxhZPcnI?*Zmvo;y7H{=&lVf0#B)pu1sJtm+zqLE!+lXGf#hRN=&>_RsC&IncNlLo8+GI z$UA5uXP!agjibcbpAR?N4+xK(5mlWQUA;PwpoUeJ}wcDswou7@gD=rf- z&&2M?U$LcC)oZKEQhwlb??JbDh}O-n^74y)b1s(&RO0~XMd5U(IHFK3+!P*?Ot+yxbMkVvyd(nWePImue*)=^ypCnK6 zY}%E)!M`I#_4t!geaUV7JNkkIRm~HspvUh*q zuxy4R`+Bz0{cj$rZ8-4FsSkVg?PZ4@7X}bo<`fUQ54V|Ta8tiraH__it`TJdcX=`d z(94(HSBBeOGhbs(pgNuAb^n0eCEofEWr@u^4ST`TWcsdy{pQ=#?5)ei^E)J$%*+3Y z8fNfze_|UfW-q?$Pi3<85guzebJxpC4c_O0VviSkSU0Md!xqt)pQ@MhrE7f;UX!nU zfDKC*p8o(!`|o<_U)1$;d* zeeQc%w*fixDi4*Nwn#h*SFwb2{&9c$)xEVyO-oPAtcIFeitiZy^dX8(=HZW&fexcT z!BZ8;*Zhw^(ZNUXQVT(MxvbNspCQnCf#MD?ZiCv&aiptaJ|h+A^yO zUa>xXj6tLO1B%8)(lKW|IkAhZqN$H3RFNY-6*V)DDo;+!|DgQ0^)nPXi}!e zclWjVT$k4;vv+t71VlY*)oE!SMeO;Ybkxst$L-ghqx*O;e-1%Kc%hcfJj1vzu~$jV z#oZr6kQxIPOj;0R!4!hVqHRZ=he;K-?cI>9E7D6$S&(eO%Cuk!q|9GDxmE71JtHn$ zU0Y_?Y98Cb;^J!_rF7_hH0j9l;#LsA`bOoLXEAGU3Rqg%ar9%l_t5Vlr8B&)4Bh5o z&iy}izMXu1(woRiYZ5Gc%7Y=GIDN*WWJ@iZd4Rj(;WkC}KPb0Vk&Tfcb>Pb&D8JA1 zWXpDZy%eLsB=bD2$G2JMS37%0>NV3{WF8T|_j~{M7p+LsX9Jozy`;T-53<_Kv&E0H z+rRznTBKu{&B7_?cm)f}6Lptwaeu^GJ;8f9ybnuSyypIA2dg|Bh4RSLm@UkM<8zNc zp4<4ICvLy=EgEWQ^ii96)I5H-)82kZi=RQls9P(ZPet|O=ZPKQnf7G`izJqL$B&;JGB))=dBep}@7ZKqv~Z0$BTJYMHRR500-pS%VWtAM!Or}0j_&T#BAn)`yUtnHfPR3tkN&$ z;Z*ME0R6A1;*A_o2LUM!IseMR(!!KDz)LDvYKsy_@n#iJa0>5I0V)iq8mvFhrnDC| z(tF4~{sf@SJkEdZ>Gl5i-`rOP)?g|p?~2U-(RU1ef+=>3`J#%j?Uhpavx=75zW?re z^XPu&)SutbrrJ^I#zGLsgV474%ArCPuveRg^X-lnA7Aqa1wI%lzuBV2v_WMpYX0QF zcS-9{FUncnyiBcOozaOycyJ|nYc!XuqmbErStWSH{9S^({=NkhtlPuNJfgSi3+K+` zZZr_GcspWycoFF^*l`}2me_i=SH^c{@`tZ@~6=cR{bJ-2zBA00KSBKdX9#$2- zu#ywJX=T|w zu~%(1Mx_B7l{4}gps+%|z$d{NBfBE4=9{ac-Cr`Xj`_m}u~!2weY<2wMHph-aW-7d zKXS%cGxWFT9cn_=yF9TPm{FSHQDLRc{BeZ?ZMyfWC;vi|zoE-$wp6~CbiAmIQ!O;9 zq4));H^-|nL&Rt*!yqH}Qf2UsWo8ZNzr}ZhDMkfoJ-=52OOE+l4tXP*R!)+p(Q`J# zyjQE(k|&ev`|(&Z_W!KgKP#o%Z)kj_8m2*}NB`dvbsxP>-D}n0(N0M%SZ(x4gKgl= zU9gCnzcsOK$nfHF_2S3E0mcyi_eHdbPjNvb2}MfN4g8o3deAFf-e&%m#aee;XwK;n zr!u`Oco1A^`}c8R_}b|0VlL*#Yhw_KBK~7e%iwiw(0wo)uf8ynPInq|Z{nmSv{DMj_=q?8Mq5!>TiV4gYzXziK9LTej=LbLE9si)r8|E*D zw7>s-=UdBLSj$o-at~K{-2ADL<|$eEOILNEXG@01zv5vKFnUQXqY3#&S2U+6_O(d~ z+`B&9|5}OHqomT7uB+ z_@Ks?6vcfvzttEXY{Ba`q5Bzpdsq!qM3=OoyA$GC+o2qBhakR6M9PF4E9bjDyYGV( zV@BM_M4bq!q>zn+%4_Q2fwaOE!MIpmMtayrKwMy zQTxQZmKDEFY!?5~{h3DLcm!2vKmWWbCMNT@VzMsYzh1iYo;M^~;hqUC*n{UcMX~xz z*XsN?DnNhvSshz;D9~R*R&T3czxKVNp7Pq)hI-1oJhT~x!E4{3>H=LjUT@OAX+6oA zzb13Id>`KRM)W>EySu5;mGFe41(!W7J~s1L zZoJ#4KT`_lOJ#knGcWSQ`(aF?_!JSRx@cpVU$xzz1ABgP6jB(>bcbgCYL4~XlP=Ml z3pXRh*!~aY8J@8JH81U2;qDw3=miAf$&PDt*gt~%HHZB==mIvAVgvbZq`hhW#Lk8o z?`=_chkRLPWH)c|9mipB{;;{_u){+mo~Ps<@v@9d6G;B2|?l!q?X2#5JY2*h#IPvC@rn+5g{QFS?nc=J@&Y`mRe#d zvB#F!MX7y>wrVGo+PA8r)cZYiXFT-P-|zkF%_rxcIp2Hkx%ZyC&dhV?)^v{zQ$3~! zR=M}%qxQe=^m^H1_Qs@T&wihB&ZA4+CB;{-`}A>_x{*JRpR(B5Nus}T8DEuC%ChVs z{!Uevek@5Il9W0Nya;#$f5B^j7lu3$cp>nm?68xT!UH797t(O>GT?Ub(%{Vuyc)O< zWOwkg;J2$wQbq8?;N`)W8}fK?ithzp0lX>rN8lB}%Yi!?{AM+wcLW^yrKGOL1u0Au zKx#6$4RRlFcklu6Ny(8(Ns>>nko(*F$H(-Nq^P8x17l))N!ve>q+-xp0bU%OfqQ|c z8F-u_e+6C=cr$R)tEI=MNU3EF1y@7iXlYTMqlHmWKA|&4fFP4IfKPpL*CwNp+Y)t=TNqW&llH4G72@?$& z8#SmuHFA5%gohfuCAbQ?S5(x1fS4rd4$>8cTmVk^=ix%$08Z(afK!L~1@VQ!H-KA_ zcj|KH=~BbW9@x3$c3O)`g75=G2m2Cd*4B@AxTdl zQ(wFcP97VW7!%MpNs_0RzzE@Nja-kLo0H^q(;M4~daOyhs+X`;Pr=&g#6$!?o z)|Amz$W+zw?L;;DCIt+&$Hhwa#6AP;iAhnzffq*n5sWh8vm=DTO6^6p`ozZfw8th% zKf?~i?**rVl>b6B{R+r5c+$bkN|92k=`U9R+y$J9*`0ks1b0?KHW&B>91d^bmfo3{Lh-gOlBXcHK@2dUB*a*4`^B8UPvU z08V~s0ZxjZNI=yYYLAVLNlKQIqT&WcB}!5pGN9_ei4>DjNyyZrgTZNpyui4pbd?}e zyUjuuB>#H#78Q97xs=pEN*$?RNU0_Ih=%B!gd$0j!wKHVZ3UF6`BC(TMlKi$d!PI9WdpPMzj}!E+71 zeuU6l23`p9o-C{ICskroL>ZC>Ma3qg)qACi*iPV7wie(X;0>ATUd}TR66H}5oJ#7& z>bcjb(hfG@>C~_>l2jQy1f0q}C?+|o_drSN7aKJYy;BNj8SdqR#*Pzdt>EOoCu7Cr z(JN|@J$7J}6d51iF9s#K0i5a)zzW<;#Z;OodQDGzQdB_QI#OXnewr>C(|59H++@g9 zUMpm3hC&3SbLNqa$ARV|=nr%bgrAWQ!i2``c7Y3h0CYN?F@Lt~u z*L@96&Ikp!flpo_G8k;|?%S{vBjX3s`u!D}un6)`osW_g zLBxQ>n4~x?TvH)a8;t^|8h@Q>6ljUqGlhVc0e%E{dGNJMMFm`d2SNS^IQ7de%Y|nO zfm16rHDo_=Nu;9!Zd@fY zyn~FXhC@(r^3>wh!sRc(sQ?LUgeN1xslw^NQ^;_sbz($4TPrd?2TmDm2CoD@6P#)q z3r;J|a3mxHL$ifm6gXwvVxz!YLM9K@0w?>$!D$ZNx=A$M7fpnI|4Hyam2}5u;c~Yv zqJ&A&G5umNl%GMSj241Z+g<`EPyGO18ay~hblN!vzi;rws6J6ckbWX$O1~BQ?%-F^ z=2XyQ2H%zr|I@f^1b`G)fYWFg7T*iWC25u+%R5ATOj5w$q*zHB1DVo2-!5F=D=8|O z^n1k)A|^$~1jO|39WRYRdQa#@fz#~y893!LVwV_2jZzFC2%OrolEDW@$3#X442Zk6 zThxGqQ_TnT>l4r`CQ(W^a34dz2zVjHTMYhkf+*NygC{3N#`o_X*)KV!|Inx*QIT=> zJ~5H@q~saJT~iwE6aB@UKnDaQ42;4Q`5d?kn|Hyf&;tTuW8z|xC36dk&0%sf=1_Bw zZ*IlSExNfSH@Dp8*4o^18}|SyspjV2+~b>jdvgzO?%mBjy16$uZzjx}1#|Cj?(NN+ zj*+mZ>5QU8g^Ykhw*JeRprR>Fzi597MT=bbhaPqLZ9ri~& z$>qSwOKt{_jJDI>LXr*nucN}droAmE#h?h>l;!oX(5A)Z?^S0PSu^=dmfu=aH+?Kg zO<+xCVRb^~KbXCZ=Gqctt|2gcTKkUIt821AeE!~8l@ zp4kXTQ)s}_88$HRdTII3YQnMOvu;X%2ip=*P3-uJxOY=$M0l$kfr*jehU`y zjb@$sR=&szAXjZ5NuTTbgIGcr&AJn^u z-^{WhON~vr1qhc6{r8v84uL&1;_SDp3;S$v+)xt8-8NOkeJvHlgU=4ZRO}RL$S+%D8j0Ht% za(y-w;V_mLsafwelcdHDOI2A!FHMd9RCFsVi(sK}WdX2@z~IePEAw2_ge632){f1M zqzzbV6<3)txwod?07(7RiTPCxmHk*oZ%zJ+okKW-`SsD%QyM&^7c#$Z6PDdalRsxK z5Kd-c(VBV)oi!M-ZY(VzRCZ?hz&f-P)v~gnkWe|B*<&>IH()eET=jk*z#aqZ^O+>I z({rXm=diTCntB^pbzlmUYlX@sm}fst?a@k-Q0Y{RbGT_4%LH~27@7v9rhGqUL9v>e z^!Z!A+e78`EDu=W*4PZ`_Gn~AvxqoN%>{<8inQ51Lgjm`AddQZmHwLCmnHPq)YWY; zh7d379+8+FugMx~i7<_2Al%2!A#`Sb12nY(DnRwJ=w+M7vIl6&t#+(_o#t{`7M7r? zUq+Y;RM4d?AJ}zZ=nqB)fy|zW7Hls`nDA1I=q zYNxM6vnqNe(PIW_)~CSQ>FJuWgu$9Rv6E;>nPoN!mG?7wh$g>cEfIdhG7!FC=MetH z{Dx|BNfv`Jmt`Y#VJ{GlWMRW-NTefd$np_RWIn?+INUy5v-)?Fq(nAlSU85xW5{c} zV;$DzFIn2Rnssvz8knz#hRZLR=LpR@$xd~;Up-vD#WF`|a(Q+eVI&J0NrP`F!oOMG zNKFln6eGdH9#;>Q`>}`=&AJF!59V7tT(+@-6ipt;s-)6nl#r^a3KkF=gHF2N8!|af zlh?792y3v6G)+zGElKE;#uz=y{6=YN)jq;^Vt@~2*`qY;YG6(D^511)qcv-VXe$4R zfN(j6<&V~sEzzuW?dG&LjG^(Ig)o*q9-~?J#Yj?rJz-6jHda#;`-%b;*BfRH^Bgx# ze#kP%=`;U$O}@Z_#%pS=IMGLo>MPE8mWP=0dKkbWCTQx&{*u%d#TN0$S-}KN_GVSm zHMtH;NY~W)3E1u+USQcxwl4X{M*`k1XPSY^=bw(J$G7(N=w-H`qLDMz0`cR=O=`%}TmN#9qeh;jHm=8T_ z)r5hF49s-HXr_BRi!xS_K?A4C49)rmQFYksKH+NUaC2CM)%k=qY2`YDO-CQiV47T* z`OTzhJ_aEM^-Rrr`CE)x7B@2-JuhsQCii6N2zRsmS(@rJQWQ(kCxNESK3h}21x5?K z=vN!rTe6x%+( zx$Mg1?=&@JwAt?ys+3~oYx>KHECcu|;IwKu(VC=QgM`TgMYB%}Rr`#QB=k!|V6a~jnNS5M)J9B8Flsm>FGeayzup%_1&nVLEf7!6IhxK$`yoY_L% z0|c8C@}yCz#w=)w<~nDhuqF0lwDz4~c}q04+$1qdoDI)LEY;LSlZChSrBrgQ9$#4YrvvV>)tdJtF#U4vG!kD0t&v-X%uwdgu6T)luRn(Cd*5~=m4Nm5T8 zA^q=|-}jn&0vN4GF8Yo^VcEdyPsgo4Fjq6B{38onp{Z^eVl~v81Y;;4*g{~mhO2r# z$};;(%p)^I$wdb|!XB^GtTLl9i*m^wSz4B6O@~}Zmv=GGRhnv>DHfqZOl}q`cVn5W zutNmaL~oJPxS~>8%o3^2v&^1n`nxX6Tdk=JfYY8q&)a$(Qa#-pWo8QtqMWg;V2!43 z1}4S={Gq;qM6-bl)kGb@CFyHDNAlGXCa=@f+H-_Q#lkg^WdOScjC>`wKlPd4dMsXZ zv3LS=(_8L0NYqzUy(15qC(5Ddy>$%>+n}jc=D(ZQXqFG`0x)bAQr-1|J($_EHTA%E z!q*m-*S`sS3?y@b$Xzdy`p0|Hrwc_Zh_RW?JU3~oyh!vqv3#^+nZUjSMtdQBjH~w` zk*9^l@{2`_D|+kpVtJc2c@a~$XzCfnghQ{8K1O`-Bq23L{N>Vf=j9Fu}>|t__rWRU;dvIOL#AmUL9PGuHV=tw*<7r%Vcx$Kf_vRjpc95f3 zcCM!01NNn?7qk3rn$<0fmgY-Mn=$)#P2Q`ACD~(K4`KCoXz~u0 zwgWTZs&{vaq0Doqrfyg*+P9GIm7*+jr)C|q#+*G)~&%?6JE8kh~1hxVm&q`dZ{VZLrCNwMYr+Q21y$7Ryqku*r$FvJ6nut-In?s61Al+ zS-;!}O%@j(j)f#+ucp3)EIdQ4)Q<9L6e&>`wy67x1t|1-yg!Q1-R;<%+r+50(%xMC3sO_PmS!)h z$JB$GJdQ;i)YL88Mb8xb;v%d7Si2qO{6Twh&Jqsc?gJRDBBEu=G5N5jw%sX7fxu+_ z9$*B^IIPK=*g3@fi5Qw!WLD5S)Y@PdN+4GL4S_){`-rCgNRVz0>lIeIu%nvVa`!tE zjJ$#61A7LHnorz@*Jt))nmTKbxnk38#!>bdSkb*At%aryxh+dOuE|r_VT8DYJ3+hN z&Ir4+%oCc*^Tf(2(MHDeq$x?_50mCEA#CVLS`hP2YU=2HVyP0Ax3Gv)m>7N#&TwWK zwVJR3AR~a(MLC`IZQdr9kgr)E6Q=KkvDlo}Fip2axQ1mQe6EMhnBPyDJe@-8#RHi5 zSnJc_xDgNgS(E$g;Z{BTM-Q8`eBe{{@QfaoWsiT+*%ea7A0&C2Uw+)v|GrxM_VPI7N5-h}kb|a*s_}ms=Iy^y*Gc zF9elcbG~+wf2hvgC(57Z^sFP#>FDAlIiZoi<(((WU&$kQHnEXhodlM`?Zi^~HezYq zb28W{9s|asC(G{q_++`2Jcj#B0pL9az=$aT#__`h93miKDu4+zK)>Qcrpn7@g_oTs zm*kVD$)C%U`MGJp&rXA4qv=qb%G0Ld05F5!Cg2tUt!Dt3 z$>+`hFlPpU*96SwEg66o48U3jU=Dvlz;gn+&IB-zXUzn#awY)xSpdG{oo4~)Gz-8k z0v2+0HUQVz0ODo?Sj@K(kV}9s2e5?4Z~)O9z;ObWai2K=yypNIF$ci+{4fEB2nd)9 zU?m?q7r>CY04@=*idUHjz<(ZqDf0lV5q>&5PcF_2)NAEH2_@K0Ek-y;40rn zKrR8kYXMy2F>3)tuLW?NfZw>!Iso450E}1%;3hvzz##$x)&sc3hpq=OWIcdO1l-|O zHURM70AR`n0QdMg0?rc9C>y{7o}LY0LNoVd?60N&dGjMxUinI9(L5CH+( z0l4y^+W`#O4&V|2ZoJA40RB4wOxXdTFh57YSppjE1W<&h?*uSmCxH6|c<_3=0EFxU zuwWN}V*EA%w+Lvx8-N#|yBolq-2h$_P?ERY1E9qo0BiRE@a8WFcuqjqy#Pw{ti1qM z?gikU2cQh^oCly&9)Mj0l;!F^0IvH0#O(u6o^K-{mjK@%0DQz_egF{t1AyZMROCMU z0eJ5RFk(M|%KR_^hX@Eb0KlIQJpf?H0RWc>sLHGS2*CeG08@ShP@SJ6;4A@+4gv_| z=?4K!I0)cA0X2EOLjXb!0a$PdKrMcofLjE#J`A7^pL-aj{|T$4j}G0fF^t!0l5VD zo&eC4$D9BVeFDI70-AB3lK{L=0vK@;Ky!YWfI|cXoC45-4?P87$SDAq2>6Uw$p_${ z4`50@fY13k0?rc9=rn*fJpD9)38w+vC!ig#_Y;7Sp8zcQ2|#;(n}AycwEh`D2R`>_ z0CRo@@S1=xdCOk_wD<+U+Ft;4;x7Qmoq5C=u&;R*7+-k?(eA$@x(o08EAXy7n^-rl zo(1d9?ZkTUZN%){^Bh=D9z!gW=Mn40ea?eL@dRSM`C(#xxZee^Xg-u!49_Rlmshz6 z){mzVi{-0IVzk;C>Cjx4iQ;0G+M@*hRodu3iV= zdL2OAbpWY+8v(fl`2Gf96p#50K=f|_juSA3``iHFeM5H78F51%UdTGMDqeokQ?XKb z{_mtF#>d7E9Dvv3Iew4jqjHJpVDZ*c!DhyMJGd%G{zG*6Q`U3-ilW%ylC;C zQAGvwvldUmJL(*~rCuLjQqeFvHhngqhXo_-p5eF*bFb3jEPbMVQ$V2x6WzgnYIl&4@B zS4ySK1w0>g8!C&z7l9Unrh*oL$P}5Ucrz=)h*km50<8qi0IdL#4NAKVL|J|hS^|1E z?Q+Nzzf{Ig6(|waBMk=Eg4ct~YJnK8$AX7|D6<=&WL%pC{Rr1Pp_2rj2qJ?6zzai< zDp4QTRIpCqUxCOu9l_g!+Jf4ES}W*JpW~t_2sd(4EAY=iv!MJ5|+;leE0 zMOq`uBmqSLH9t{L1rzl;`$2c8mIt7X~^w&K~%Y$pc|myK%1!j z&C=Y)^)1lv2HgRt#E%V5MkpgH2&EzUDTwq4BbjRT7l<-a(T84wzW_Z0{Rtx5q)$yt z*C~`KDUd)3pBvXMNK8Y`G$uiQgE-QE1^Nd>>8QYD_%(=X_BY7PgbG2rrfhP;s1UG~ zqF6pgNLjIRbg>vYl{T+fNxgrB{BT-3Q7X|%+ z>q6kBiwnPVP3|rN*&Rggrb3ddsiRUKAUo8LN&xo)6$cdqd4fDZ|4{prE2vceF8P1q zrdug9a=Tem!YK{OrY&kVY8LVuRg|txed;?YWuRaKl?Hi(N`a_`RB|#%O7F@(h8JpNmBlrAWiz!u_3-ils7KQ^xs-;=9*`e!b9m9@qz@Tz(xu7f z3W(Ai1W_jD5H#Z|B0a@d2d@mO1gZcs@rpG6o506LWHs=rAb(I5gUkdp50D{BL&or+ zU8)H_7dT$>=nEGuJT!7>A*03X6HqXy4ydjnHwLG)&B?XRKutm6JbSZJD(F*O`Xde| zb$i4sAt(=8f$Ipww*z$meF6HCzuv5TlhPTNRM!OXOW@R9N#15$rvXm|rGQ3)hJZ$Z zz6A{g4F?f62s9Wp3^de`2_FS2i|ogMj|M&3i~xTRYFLQn{Io?0FX>b_u%5(AA5(H| z=2@FYQP-}#u_tMYTr;p%U`?pb;`fg!J~s2P&K>9KyZT=KZG|ES1qSH~HMrk##Yb+y z8@$7o^7!M*T0H&nJ)tzfQ;^Oll-hVqG3A6ZA2$tEPC`oNT~ETWd7Ng;n|n{(N*}4@ zB-aV76%rUENvnAV6z~{kHR+k>ah#J+_liC7hvLw4;#Z&$Y#!hVc{IFj5s&ft4h8dw z&!lOuzssrMdB+jc3%72tZJr6b8CJ>l&+umZ9SY029SZVcKAiN-GetAf%5)w%=udx# zo;zO;g<$g#Qm9Kr@5Bj*dpQ)$V@kfm+R8V+{Uy~AW9P5Qwt23p=(YTy>aUKzawu>f zl&`dweRxVfvJk)6!&#Hddlsy&wX2>JwNxEy3G+9Bhg{n6=C|VZIbw?O<49^Ve>~{@ z@?}u_BXy%33Jtk@8r6*8He$Vaet^ZxSvNY2%ebzuUz-PsVzxD0S;slZRQUlS2vI8ni%@=cMgS1JfHG>%I}h%`NPTB9!uVw$=DX= z&@0Q!{e>nlL3-wIDqTGrSId=W9&_kz=MPEmR~~i^dghs!-seI$b_@u)p~&dAA=DDC zywT4}H9R31ens(7>xGJ2!E`?9XQW=i z*Q-NgsSlq%2O^*&I?20V5C+={zfvlUOF9wJ7467POS>0=uQg`!aK zIh3JZ8oOaN6l!DCBi~uP01EPQ-tt!r6b&_L z&rd=j_`em*(`23ZJg|Ou`M$rNG$;@^2ut{$0_3!Te{xQlZ!>?xTVqAzy*J19909$W zVoJNf@1Mhf8_ye@hx#b~_`Kr9W6mq(Z07N?IWwE=|2emlH_`+L)}|Wj9`oTl&nv|$ zOl>KCn{6H{v+wgepw0356WVp4jds;-8m4`C#S2PlS>vG>bT@Rq05_E8Q%H*8D@Yp0 zFF>-H$IH%5sQgdkZziaEiS_1v!i!!+Klxz$>lxPPT`wwIrZj-l8L#m)aeIB{SA6`Y}jNTwdlNc60c!X@jn2`(dyTq#U zwpZQ`B%3*q{yp$K@ThJ56~zy4F!KMM*axoy%3XbH!_~CGJC*|Xg+k51 zU}+66xUN)Fx4}YbxUdV)yM?)}CPrH{ukjnksd-ed@QhD8c#b)XjRl^Y>3`;7zD7T~ z`hVeiaEl`*laD}BOa(CJ?+h&Z&MktlnmCWOSMZ=q4DB9?Lh+a&6lVX&s zE~c(|y!8#32;;}^Dn2~*hSEd5ho1|$J4#P3(Mw;#N|!IV?@c6d;aw@~l01T>O1vMj zV4iVXDP^fCHRp>+?!@hXDPHCEos)S+u=B8GyZ$V3d7Z=GlllFd7tzUPM z>5JNu?m=1-R^Nt8^n8fv-RNJoM)Fo@GI1g{)f71x>jpiL zGS3$_U-c%dOm@?vw2Ri4ODUAcA!+gdyA50Mx%bfmo%jyK+sq@G3p>?6qx=wA)=3T` z+xm*}kn%Rqa87@;@>ugh$0|4!%u}A{{{HJnxBJ(}I${pJE?s9 z#b{-OL%}==+G?=;S;QX~JsdGh`6aS#9vvO-aWk5Ja@yc(FzJbIe7 zqsN`4qk0c==y~w&PzW~9sy^&=X;zWXwv=)xn5S6}F8SxwH#cuAgTFABqx4cY{^l`k zo9AEKI@x^V3hlEC1uPp9=LJ-TEj*j_%=5G1TmQb4zjINNL+=T%0fk`mz-^BX?Y-}H z*wMzJ@ZT{a>HlC`WG;)M{10U{D)Zm9oyluI!O%2zB%^uS6I3mR&j+)a2W?%vx_b@3 z-E=+1HQhI2Kjg*gtR|n9Kd1LOQ@3fZ5E`wBLN= zHKm>J@-t2{x(Y3w(iI+03g+?Kr5;fo`oG*U-J$S`FMW#5s3$M^cl(?%fp|+te9{3 z{%6WC{6--3FMYIPnm6ViFFx%r#fqC+Y_Lo6)qg3zHm_7Mka~arZO*BkH|C)PwDSq7 zEmfQqVJYc^N(J(G+2X@XK3Ce<8bY0><->QLU3oG&^Jl2j#tXab7ChxS^6A7A{#Hu) z1_kwkW+7_%Lf}v>4s^5O8PYTZc&WVoy4HcfAQ9aB`7$ITVJA9?ReZvrMp|` zSaF{J##nylrP5n@GL}z&rFio%|Hjrt&$qQ~<$L~CY6qByk7q4eTFSoDlI|gw948J- znn#-N&MxRvf68oc5BRCvE4cU*D0rZ}=1JslGxEN6*;{^rA`j+YyiyvN*~#HKS+A5N zi}HBFtg;qwn|XAv)vU%XJ@@vVtk+X-LULejnP`3T}`;zlDVL@F|C zk;TVm9tj*WDS21%doQOrJStpi393_R)_sdluzBXNQJ+)2z8&AUA{1-UbB&<4hobK` z-n;1Ik=tm`OIe^7HR27>bT;!G<0p5|YyHE4b=bJhSZCb3p{d9yd>U zJCAhX!Rrzq(8`79FcKbS@jS({#fF2b`Z?9e#FU|5$Cup=6Lb%N+6>_t7L;-nUkzrP z3OyQCnadN0?fBl8vI-I&ozJh3!hgFZi^m08N?C#=QGJ_vj=FjNuikaG{5@AMHKrH5 zPvLeai;r?~693l8;%)XK<>4g00dlZ;Bz;rmQY9wOk6eRF-Bp0=c!DcNej8^?X?YvJ zQ~}qcoGo>1=DGMW9j8VA=~88%QQNn9U@E;!ox(6`uoJwVi>0<<)QIDYU0{1M-{AuF z*?bRW_W+R1JY4U6cVm&Z1KNJ$sC_P9Tk)-J{-rC@i*hRGGx%gzxWGJkUo9*0#%zsg^c)0^sQeg;Bh~ zjXKgS@$8|iXX~AnGPcsQ2i!bThcl82JzXWVZ8G>4aUw|ETQH!_EC8ONAh^4gIWcamB zgCg)nQ|?J^+kwZE^uM-NZ@#MtrdabE1Yh@)E7qzts)wT*9=uRd7&0<8y1GB_TokSd z!6Ge1<|Q*>6h!hI*!aMZ0A&!fbm2}MUU+}E9ll|;;7zQYS_GM9@x*aDv60ZC-Z z_j|nUp0bg>s2s2EX{lpEU-SI(NF$Q+b)HD`{;)j4Uwc|=D|a&a02?Nee=_;!#o%=p zo=F7=;VH$yI`Bpxff>ay%3+K|12hV5q%%O>xR1^JFhW3?vEAlO+l#wjb5IC9<>WG+ zTO7@36z0M*Ufv6ZdAf{y_@Xd|?Ls_`%H+rM%77U;8J;pq*p%DLBF<<4^TP{n_4`)% zxOwr*|IXKR%YYJai(&o!I=(L%Dn|W{QW=scdR_CA3~fFOtD4&8C~bO-@o#>#!CwE3 z*67#E#~m?7HG|F1J5;_iu1rpO=Ryt*^8*mu`c^HPDvc^|#Juks3-4YE9nbuJ#Il55 zP2E%SS31<+bCd1=(LbjD-*d*l8|VK$lI!j^3jSWQ4`yUDziRPmVuy0ycWN-o@r1XS3b8ihSHojVV~~ne%8S*!zn{gM-z)up4UI_N zz=kQx{8~oI7o)t&H7P>PVobrtppk{!&B0=f>NB>t2F=OKW_~v#&w13u3SM{SI~ryt z4@IZ0Ykp`W*Q?ChQl(dZ>QFF0#j&A&#XY4@o$c+2dA62MErYem{CLNb1J5hWOKeKh zjnVat5o1gOZtJgf+v!j+I)c$J zcv(L*pUA`PBkxAp%uk%Cv;HjBU~a3^Fl6+$4<`B#Qx86ni!nw;|KtNc!mh~tUP`0t zgAXiyz2lwC3=awAGG8`~enMxX zPq|ik#VfeLEUFILJwRC*kP$=LP5GlO3?($|8;nfeUe)`VK z%$1hzR~4}w{I9FDk!&HKUm1O19nY==wv`_RD`S2dW>7F5MVlkvJ1X=0 zGxtWGTHiJQeEuSi9xAACy z%LM#zkw5Xb)UuhMuDSPScIl_f8`sktQGcp7hKE)`q0MjD47s&*X1Ra`LX4fi=8c!MQwwWJTeB*r}^eiz4I!?^8(uTgb{G#*qPeQ7&y1!gn9BU7{KyzV*9 zMe)`hH(zR%+l;v%q5d6UxySgFs8=HrorI!14Sw}qcG>fml@%AdOYnfl=Y2Wx%wKBe*D;!$- zn{XfbAiqlm`QXcG-GcrS+sj$M788ig<*0b76H)QtjMlxA=w&FqRYHH!?{nV{dECVA z47Aiz{yNGZ1!7RjT&;oO^!XCtpHgm#}lA{cg&MWul6zV z4y)r&_bLvH_vq}%P2+n=uQ)$f!_wMje!}Noudh2^YPh!<^n&oH2?p!&u$tKSe=tM6 z*v0uYQuu%cW5V0QFV(d8xHYM%|HMSvTXS}h diff --git a/packages/openauth/package.json b/packages/openauth/package.json index 446ee25f..a56bacdd 100644 --- a/packages/openauth/package.json +++ b/packages/openauth/package.json @@ -18,7 +18,7 @@ "@tsconfig/node22": "22.0.0", "@types/node": "22.10.1", "arctic": "2.2.2", - "hono": "4.6.9", + "hono": "4.10.5", "typescript": "5.6.3", "valibot": "1.0.0-beta.15" }, diff --git a/packages/openauth/src/client.ts b/packages/openauth/src/client.ts index e418bbcd..f2e03752 100644 --- a/packages/openauth/src/client.ts +++ b/packages/openauth/src/client.ts @@ -287,7 +287,14 @@ export interface VerifyOptions { */ issuer?: string /** - * @internal + * The expected audience (aud) claim value. + * + * @example + * ```ts + * { + * audience: "api" + * } + * ``` */ audience?: string /** @@ -701,6 +708,7 @@ export function createClient(input: ClientInput): Client { options?: VerifyOptions, ): Promise | VerifyError> { const jwks = await getJWKS() + const expectedAudience = options?.audience ?? input.clientID try { const result = await jwtVerify<{ mode: "access" @@ -708,6 +716,7 @@ export function createClient(input: ClientInput): Client { properties: v1.InferInput }>(token, jwks, { issuer, + audience: expectedAudience, }) const validated = await subjects[result.payload.type][ "~standard" @@ -733,6 +742,7 @@ export function createClient(input: ClientInput): Client { { refresh: refreshed.tokens!.refresh, issuer, + audience: expectedAudience, fetch: options?.fetch, }, ) diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts index aac601fe..ae5b3130 100644 --- a/packages/openauth/src/issuer.ts +++ b/packages/openauth/src/issuer.ts @@ -1140,26 +1140,47 @@ export function issuer< ) } - const result = await jwtVerify<{ - mode: "access" - type: keyof SubjectSchema - properties: v1.InferInput - }>(token, () => signingKey().then((item) => item.public), { - issuer: issuer(c), - }) + try { + const result = await jwtVerify<{ + mode: "access" + type: keyof SubjectSchema + properties: v1.InferInput + aud?: string + }>(token, () => signingKey().then((item) => item.public), { + issuer: issuer(c), + }) - const validated = await input.subjects[result.payload.type][ - "~standard" - ].validate(result.payload.properties) + if (!result.payload.aud) { + return c.json( + { + error: "invalid_token", + error_description: "Token missing audience claim", + }, + 401, + ) + } - if (!validated.issues && result.payload.mode === "access") { - return c.json(validated.value as SubjectSchema) - } + const validated = await input.subjects[result.payload.type][ + "~standard" + ].validate(result.payload.properties) - return c.json({ - error: "invalid_token", - error_description: "Invalid token", - }) + if (!validated.issues && result.payload.mode === "access") { + return c.json(validated.value as SubjectSchema) + } + + return c.json({ + error: "invalid_token", + error_description: "Invalid token", + }) + } catch { + return c.json( + { + error: "invalid_token", + error_description: "Token verification failed", + }, + 401, + ) + } }) app.onError(async (err, c) => { diff --git a/packages/openauth/test/client.test.ts b/packages/openauth/test/client.test.ts index 8c75b2df..16a06748 100644 --- a/packages/openauth/test/client.test.ts +++ b/packages/openauth/test/client.test.ts @@ -100,7 +100,9 @@ describe("verify", () => { test("success", async () => { const refreshSpy = spyOn(client, "refresh") - const verified = await client.verify(subjects, tokens.access) + const verified = await client.verify(subjects, tokens.access, { + audience: "123", + }) expect(verified).toStrictEqual({ aud: "123", subject: { @@ -113,10 +115,24 @@ describe("verify", () => { expect(refreshSpy).not.toBeCalled() }) + test("success without expected audience", async () => { + const verified = await client.verify(subjects, tokens.access) + expect(verified).toStrictEqual({ + aud: "123", + subject: { + type: "user", + properties: { + userID: "123", + }, + }, + }) + }) + test("success after refresh", async () => { const refreshSpy = spyOn(client, "refresh") setSystemTime(Date.now() + 1000 * 6000 + 1000) const verified = await client.verify(subjects, tokens.access, { + audience: "123", refresh: tokens.refresh, }) expect(verified).toStrictEqual({ @@ -138,7 +154,9 @@ describe("verify", () => { test("failure with expired access token", async () => { setSystemTime(Date.now() + 1000 * 6000 + 1000) - const verified = await client.verify(subjects, tokens.access) + const verified = await client.verify(subjects, tokens.access, { + audience: "123", + }) expect(verified).toStrictEqual({ err: expect.any(InvalidAccessTokenError), }) @@ -147,6 +165,7 @@ describe("verify", () => { test("failure with invalid refresh token", async () => { setSystemTime(Date.now() + 1000 * 6000 + 1000) const verified = await client.verify(subjects, tokens.access, { + audience: "123", refresh: "foo", }) expect(verified).toStrictEqual({ diff --git a/packages/openauth/test/issuer.test.ts b/packages/openauth/test/issuer.test.ts index 9ded63a1..cf9d67dc 100644 --- a/packages/openauth/test/issuer.test.ts +++ b/packages/openauth/test/issuer.test.ts @@ -147,7 +147,9 @@ describe("code flow", () => { refresh: expectNonEmptyString, expiresIn: 60, }) - const verified = await client.verify(subjects, tokens.access) + const verified = await client.verify(subjects, tokens.access, { + audience: "123", + }) if (verified.err) throw verified.err expect(verified.subject).toStrictEqual({ type: "user", @@ -251,7 +253,7 @@ describe("client credentials flow", () => { test("success", async () => { const client = createClient({ issuer: "https://auth.example.com", - clientID: "123", + clientID: "myuser", fetch: (a, b) => Promise.resolve(auth.request(a, b)), }) const response = await auth.request("https://auth.example.com/token", { @@ -272,7 +274,9 @@ describe("client credentials flow", () => { access_token: expectNonEmptyString, refresh_token: expectNonEmptyString, }) - const verified = await client.verify(subjects, tokens.access_token) + const verified = await client.verify(subjects, tokens.access_token, { + audience: "myuser", + }) expect(verified).toStrictEqual({ aud: "myuser", subject: { @@ -355,7 +359,9 @@ describe("refresh token", () => { expect(refreshed.access_token).not.toEqual(tokens.access) expect(refreshed.refresh_token).not.toEqual(tokens.refresh) - const verified = await client.verify(subjects, refreshed.access_token) + const verified = await client.verify(subjects, refreshed.access_token, { + audience: "123", + }) expect(verified).toStrictEqual({ aud: "123", subject: { @@ -382,7 +388,9 @@ describe("refresh token", () => { expect(refreshed.access_token).not.toEqual(tokens.access) expect(refreshed.refresh_token).not.toEqual(tokens.refresh) - const verified = await client.verify(subjects, refreshed.access_token) + const verified = await client.verify(subjects, refreshed.access_token, { + audience: "123", + }) expect(verified).toStrictEqual({ aud: "123", subject: { @@ -518,4 +526,16 @@ describe("user info", () => { expect(userinfo).toStrictEqual({ userID: "123" }) }) + + test("invalid token", async () => { + const response = await auth.request("https://auth.example.com/userinfo", { + headers: { Authorization: "Bearer invalid.token.here" }, + }) + + expect(response.status).toBe(401) + expect(await response.json()).toStrictEqual({ + error: "invalid_token", + error_description: "Token verification failed", + }) + }) }) diff --git a/packages/openauth/test/jwt-audience-validation.test.ts b/packages/openauth/test/jwt-audience-validation.test.ts new file mode 100644 index 00000000..3b25e3ca --- /dev/null +++ b/packages/openauth/test/jwt-audience-validation.test.ts @@ -0,0 +1,167 @@ +import { + afterEach, + beforeEach, + describe, + expect, + setSystemTime, + test, +} from "bun:test" +import { object, string } from "valibot" +import { createClient } from "../src/client.js" +import { InvalidAccessTokenError } from "../src/error.js" +import { issuer } from "../src/issuer.js" +import type { Provider } from "../src/provider/provider.js" +import { MemoryStorage } from "../src/storage/memory.js" +import { createSubjects } from "../src/subject.js" + +const subjects = createSubjects({ + user: object({ + userID: string(), + }), +}) + +describe("jwt audience validation", () => { + let auth: ReturnType + + beforeEach(() => { + setSystemTime(new Date("1/1/2024")) + auth = issuer({ + storage: MemoryStorage(), + subjects, + allow: async () => true, + ttl: { + access: 60, + refresh: 6000, + }, + providers: { + dummy: { + type: "dummy", + init(route, ctx) { + route.get("/authorize", async (c) => { + return ctx.success(c, { + email: "foo@bar.com", + }) + }) + }, + } satisfies Provider<{ email: string }>, + }, + success: async (ctx) => { + return ctx.subject("user", { + userID: "123", + }) + }, + }) + }) + + afterEach(() => { + setSystemTime() + }) + + function createTestClient(clientID: string) { + return createClient({ + issuer: "https://auth.example.com", + clientID, + fetch: (a, b) => Promise.resolve(auth.request(a, b)), + }) + } + + async function issueTokens(clientID: string) { + const client = createTestClient(clientID) + const redirectURI = "https://client.example.com/callback" + const { challenge, url } = await client.authorize(redirectURI, "code", { + pkce: true, + }) + + let response = await auth.request(url) + response = await auth.request(response.headers.get("location")!, { + headers: { + cookie: response.headers.get("set-cookie")!, + }, + }) + + const location = new URL(response.headers.get("location")!) + const code = location.searchParams.get("code") + const exchanged = await client.exchange( + code!, + redirectURI, + challenge.verifier, + ) + + if (exchanged.err) throw exchanged.err + return exchanged.tokens + } + + test("rejects a token issued for another client", async () => { + const tokens = await issueTokens("client-a") + const client = createTestClient("client-b") + + expect( + await client.verify(subjects, tokens.access, { + audience: "client-b", + }), + ).toStrictEqual({ + err: expect.any(InvalidAccessTokenError), + }) + }) + + test("accepts an explicit matching audience override", async () => { + const tokens = await issueTokens("client-a") + const client = createTestClient("client-b") + + const verified = await client.verify(subjects, tokens.access, { + audience: "client-a", + }) + + if (verified.err) throw verified.err + expect(verified).toStrictEqual({ + aud: "client-a", + subject: { + type: "user", + properties: { + userID: "123", + }, + }, + }) + }) + + test("rejects an explicit mismatched audience override", async () => { + const tokens = await issueTokens("client-a") + const client = createTestClient("client-b") + + expect( + await client.verify(subjects, tokens.access, { + audience: "client-b", + }), + ).toStrictEqual({ + err: expect.any(InvalidAccessTokenError), + }) + }) + + test("preserves the explicit audience when refreshing an expired token", async () => { + const tokens = await issueTokens("client-a") + const client = createTestClient("client-b") + + setSystemTime(Date.now() + 1000 * 61) + + const verified = await client.verify(subjects, tokens.access, { + audience: "client-a", + refresh: tokens.refresh, + }) + + if (verified.err) throw verified.err + expect(verified).toStrictEqual({ + aud: "client-a", + tokens: { + access: expect.stringMatching(/.+/), + refresh: expect.stringMatching(/.+/), + expiresIn: 60, + }, + subject: { + type: "user", + properties: { + userID: "123", + }, + }, + }) + }) +}) diff --git a/packages/openauth/test/scrap.test.ts b/packages/openauth/test/scrap.test.ts index eb782914..57e4584a 100644 --- a/packages/openauth/test/scrap.test.ts +++ b/packages/openauth/test/scrap.test.ts @@ -65,15 +65,20 @@ test("code flow", async () => { if (exchanged.err) throw exchanged.err expect(exchanged.tokens.access).toBeTruthy() expect(exchanged.tokens.refresh).toBeTruthy() - const verified = await client.verify(subjects, exchanged.tokens.access) + const verified = await client.verify(subjects, exchanged.tokens.access, { + audience: "123", + }) if (verified.err) throw verified.err expect(verified.subject.type).toBe("user") if (verified.subject.type !== "user") throw new Error("Invalid subject") expect(verified.subject.properties.userID).toBe("123") await new Promise((resolve) => setTimeout(resolve, 2000)) - const failed = await client.verify(subjects, exchanged.tokens.access) + const failed = await client.verify(subjects, exchanged.tokens.access, { + audience: "123", + }) expect(failed.err).toBeInstanceOf(Error) const next = await client.verify(subjects, exchanged.tokens.access, { + audience: "123", refresh: exchanged.tokens.refresh, }) if (next.err) throw next.err @@ -81,5 +86,7 @@ test("code flow", async () => { expect(next.tokens?.refresh).toBeDefined() expect(next.tokens?.access).not.toEqual(exchanged.tokens.access) expect(next.tokens?.refresh).not.toEqual(exchanged.tokens.refresh) - await client.verify(subjects, next.tokens!.access!) + await client.verify(subjects, next.tokens!.access!, { + audience: "123", + }) })