From a429541035d765437ee3301005ba38e6e449e007 Mon Sep 17 00:00:00 2001 From: Dustin Jenkins Date: Wed, 6 Dec 2023 09:28:00 -0800 Subject: [PATCH] Added Web Token library. --- .gitattributes | 9 + .github/workflows/gradle.yml | 21 +- .gitignore | 6 + cadc-web-test/build.gradle | 10 +- cadc-web-token/BFF.png | Bin 0 -> 103261 bytes cadc-web-token/README.md | 222 ++++++++ cadc-web-token/build.gradle | 52 ++ .../main/java/org/opencadc/token/Assets.java | 177 ++++++ .../main/java/org/opencadc/token/Client.java | 533 ++++++++++++++++++ .../org/opencadc/token/CookieDecrypt.java | 113 ++++ .../org/opencadc/token/CookieEncrypt.java | 146 +++++ .../org/opencadc/token/EncryptedCookie.java | 146 +++++ .../org/opencadc/token/RedisTokenStore.java | 147 +++++ .../java/org/opencadc/token/TokenStore.java | 98 ++++ .../java/org/opencadc/token/ClientTest.java | 172 ++++++ .../opencadc/token/CookieEncryptionTest.java | 28 + .../org/opencadc/token/TestTokenStore.java | 99 ++++ cadc-web-util/build.gradle | 2 +- settings.gradle | 2 +- 19 files changed, 1967 insertions(+), 16 deletions(-) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 cadc-web-token/BFF.png create mode 100644 cadc-web-token/README.md create mode 100644 cadc-web-token/build.gradle create mode 100644 cadc-web-token/src/main/java/org/opencadc/token/Assets.java create mode 100644 cadc-web-token/src/main/java/org/opencadc/token/Client.java create mode 100644 cadc-web-token/src/main/java/org/opencadc/token/CookieDecrypt.java create mode 100644 cadc-web-token/src/main/java/org/opencadc/token/CookieEncrypt.java create mode 100644 cadc-web-token/src/main/java/org/opencadc/token/EncryptedCookie.java create mode 100644 cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java create mode 100644 cadc-web-token/src/main/java/org/opencadc/token/TokenStore.java create mode 100644 cadc-web-token/src/test/java/org/opencadc/token/ClientTest.java create mode 100644 cadc-web-token/src/test/java/org/opencadc/token/CookieEncryptionTest.java create mode 100644 cadc-web-token/src/test/java/org/opencadc/token/TestTokenStore.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..097f9f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 622d3fe..c29cd28 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -6,12 +6,15 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 - with: - java-version: 1.8 - - name: Build cadc-web-util with Gradle - run: cd cadc-web-util && ../gradlew -i clean build test javadoc checkstyleMain - - name: Build cadc-web-test with Gradle - run: cd cadc-web-test && ../gradlew -i clean build test + - uses: actions/checkout@v3 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 11 + - name: Build cadc-web-util with Gradle + run: cd cadc-web-util && ../gradlew -i clean build test javadoc checkstyleMain + - name: Build cadc-web-token with Gradle + run: cd cadc-web-token && ../gradlew -i clean build test + - name: Build cadc-web-test with Gradle + run: cd cadc-web-test && ../gradlew -i clean build test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc1bfbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Ignore Gradle project-specific cache directory +.gradle +.idea + +# Ignore Gradle build output directory +*/build diff --git a/cadc-web-test/build.gradle b/cadc-web-test/build.gradle index 519d245..eb58931 100644 --- a/cadc-web-test/build.gradle +++ b/cadc-web-test/build.gradle @@ -9,7 +9,7 @@ repositories { mavenLocal() } -sourceCompatibility = 1.8 +sourceCompatibility = 11 group = 'org.opencadc' version = '2.1.4' @@ -17,10 +17,10 @@ description = 'OpenCADC Web UI test library' def git_url = 'https://github.com/opencadc/web' dependencies { - compile 'commons-io:commons-io:[2.0,)' - compile 'org.opencadc:cadc-util:[1.6,)' - compile 'junit:junit:[4.13,5.0)' - compile 'org.seleniumhq.selenium:selenium-java:[3.14,4.0)' + implementation 'commons-io:commons-io:[2.0,)' + implementation 'org.opencadc:cadc-util:[1.6,)' + implementation 'junit:junit:[4.13,5.0)' + implementation 'org.seleniumhq.selenium:selenium-java:[3.14,4.0)' } apply from: '../opencadc.gradle' diff --git a/cadc-web-token/BFF.png b/cadc-web-token/BFF.png new file mode 100644 index 0000000000000000000000000000000000000000..67cb86acb806ed3e93c611f88545743a67934696 GIT binary patch literal 103261 zcmeEu1zeQd*1jT2s5Gb`APqxzNJtE!G($^w4&B`hAfS{W4FZBlNeQTwNP~bN2!fIl zf*?x%-+>v$xaXYj-gECc_xpe6=Naa${qDW?+Ru7c>=M8>q2`sfCI0;WZ964o+rvc4iJf2nRb2rzjg2@WIW= z!pF_6b$GvlnTai;Avvgrg|)Q-4TmH*3mecBy_|uOg{>3R(VT`;3^|qZf#*@ z0{oFOF|vZfA2D_?aAmQ80=IK;u&}ehhahh50JQ;bl4NJ+Vqs(9=H=vO;o##3u6!G+ zBnLMOKMNOdPQ}2~z`^3k>)=h8I2o87N`Oa;%|J`pOp8s@*g-{3##zcl%k6OFE+!6+ z7J%3egou1O{9z||JCnn+#wIQnMnF$EXgI+%Y@)z5_&-i@V+#W_2Ll`6zycv?ac*TX zM=nmNgaPw4V-GD8c_(N0!`Ijv0Ao3LhawDA3-KNjrKO zd6SKsxQR7fqmgn{mvL9OmbK!Mx~k-8Z=fo64I;0`jCeVqVSiGCU*+cTb?N=-FE&$_ zb>I{8aB$_fRk+F}DQyXLRAOca)(c#)j!y2#$uTho<_d8Lb#O9=nn7(1tij*Tia9`? zZH*7`1kil3Z}-STp?1Jj0H`8nY2xJMj!--UXD29d%G}Au8gbPT04FOG)kq$zNj!UFuL!aJs4m}>0Y2}p<`zySD!}By8*&BE z3pjUV;(wfGGiw7!xESDi^s`cYUEJ`?fVs04hgw4&4yJoj~%(f$6?6H z!vnwaFX#|p37kxjcnepT-%SqVB3!J%oFh?}?|aZZ zJVGKN)YQ}wu-ym7==V(~@+Aj`^Q&F{?e1{`uE{@bW`8+*Jbww-0@T~VT?1ib4-Pcp z2Y|WZ6gxPSfD0V1*M~>&$v&{Rzoo-RsTy3_f2Gqu+w;TevNQOTXxuMw`Ij^f;Z^+a z$7KY~IKtzQ7wy8In>6`8K3~a=kI*) z!TeD~{fV0V45i=uHi#q0f&0z;ORva~kDCi{Cw}syO!tC+y$rkN;!8`yYDhe-D2g2!Q{nOI&;`ya@F=>Xh>%&K*@J-mee;CHnMBV)>Ww z%AYamM=$7mjQXEJ$I%$xFUO2n{lDv!BYc+s_2dsBk0Y2OVPfRqZU;AL2t)QSA;mvl z?!Qoke-NtsGZo~|v@HnPAt(+f(qJ5g0`0T1H2zXS+4B;DB+`PF%@$~mapTRJPU zDXLpABRYA+8o>2l<=-33KX4ll9L#S%rRH}o<*yygpY`TXh6u#cKME1Qlo}$+@xLA- z5b`(z5kSJm%GvIKOdMdv0Ryoz0XW9LLMncq*nbQj|A;I6ZK2|;`Ts4nKqA9Y;($E& zm!QQjO(>9PJDBGq3ZZP`>H4X^<*&%-R*pP&gyj{=ARf6LAMoB{l=HX2<2y+#8O zXZ@>;1~Tw?U^2d0kbe-B{25#Rd86@L#^Cpj2EzFN2d%~*@t(i=&_9IJxjA@P*!T}~ zF!0asL;Ku6qSN8{5pBSY!QU{*{>%B}{|VEOZZskW`c2P~IVJdo|Gu2l&wBq$c_3Ez zPjvauPZkyDp-r!%8A?Zdgv+I zs@ZWcOYt7ohJ6>``42GqpF#FVI(9g(KVaWM1F7l!Al4?FdB)gK=I;R*gD@BZ1UetjFl-u#zf%x^8WBg!UfWMl%=WpS`U zfHFP69XQPBeTzeb-RvwJ;01vHN^0c5132Ove9ch&0)D>t4gP02i=T2ZN0Zn8N{sTS zW$;H->DOw^#m3L_jhg}AzR&S-@*!Ncqai8IUxcLo+GNKswF{Z2`Nq3A{t^>*2n+vA z@xd>Lh4lA+-$Wr0;s;~-Pw2|O1=2(v91e0yNB~0=H~m4YdI*d^!y`Q62MC;p`}-mu zZloUnQE%e++DB461RVd9?H}0hLqYtpX*sA2`f7}R`2`u|TK z2MIAh0rH>km${*{OPAKmzqO47Oczt^JwW`4o(mt?=cCcWTA76%8K@RZkIAHRfy z0~{ZY@{-?-{{IJg2^_nAf5+5;aXr|!@_UB$r`;{Tgcqblj=&4By#)}_?|C`Et|h>L z7#wT|6IGNslNk7M?it&@c-e(Pqm!feBZMVpfL3iTaQLJkmvq3DfD0K z{m(TODkiq@I?e;*VGT92Ky1SQKRx(K4|tGCN2J|AsKwt}4SpHK{!1w6Pi)|)l8VDs z^#dknH!k+zX<__v0|mXW()E5|F%5*uMPdrn})vezW>XT{pv?jkWuPC@0S#QbTql) zSP92~qm1FdBrV2;Sd>S2AsoI`{=K!@KgSvl1^B0>#r`lQKt^SLU?=|s@cmoj?*CDa zz=d>({v?j@BM12>+yDD=gkM557lMT!Aqjt4TI_GNt@-CA`)he1ebXNS_|MOZv9a)S za{fnI2J&~S5FCR8LAw7-(qO;jxCmSJR~rNqHw&jDyLo^kP2^@C;QP0-Z1@3O`tUu) z|87SU8DRcDM-w1B|Dd*l6X_WKNk)VhDRjgV`zPD~`x=p7EXehP#zBXc$1zx?lI0S>hPAN=s-!L{#x;`8Wi99%{I zsOaGZ!~^i(eLeg+x?>F|V~-uXbWBD<6ym14n2H90JjV<1rOFS(JOxoC0C!fKDiN?7 zjdMRT#LBnQaO~zsJ5yxP#ZN(drPC0#JU@&+NmNM!M`xm7ri-r$=TwsaWkv5BvkX;G z=Z%+%-kXnA2UF)~d(uTRGl_QJlAwt1d=TC_o7Lbu7^BK_OAVF7tUX|C%Uy{YYERIq z!*ze`js5Lo`1&Zaei&^25Lz5E!jKGXh1JQ9%U;{KgtK$EV;JJ=rzZOYu2dFzv?X$i z$bO6u^4Cps(`n1WP!h!+1MlNkZ%pCsZGX|z>P&n#z;XPRV2k4GF{~j|=L$i*6tjb- zkJCOu?T0;SdMRAJy2zB);2WdH@}U}!Re+TacBDO0M2okmeT|N$l%}*x3UXBKa$|%wUBwUk#}y`)0W->(wZZRZ zd-icYZ3yXlpHZ~GLrIi+4}0i7MrjpXknqta`QiEZ#2O5HS%}^VXl#3eDp`X`;i}MBRfUaa8b5_G zY^1Ivp7;OI7G1qNRR^m!R~?D9Vxq28Tf{=nTn^5#qxabz!(74J@7SekvbbYe)iZOF zrW)q&9K(n}dphn$N|eu~ay58C(MEqwqNP6ZrQMquk*lYy5*@C@dJ)ZEG;2*8&wW;W zwK1SSV{0%asWkJ$B2qKv=#PWi?^e*YsNGXEfv~9QNtrd<=AAk|F>5xtKak91hU#1>3PCBKyIDj(HQGRW<7n626_eQ!_5k`Zgv%0ODT?%R;?>sa;XSV z$>`rt{&?oFZ1-kRw(}DH8KDojdNC+eY z%SikC&bWhK(Y|r^#TI+K0~(i4$WL;2^1GS-SbhK6s86|fI8HUW*GX($-B?c}s}Z~n zW^I|~%PxTkJvfH182ZFyU*&^aiQag;6~jPgt2Y5gbMbwBrKdeoH_=P-rSVvnI}g=T z_R3AvZefR(8Y!vihCW*blZb)1oCk|>To}zhbjC^)UCuX))R{okkGK}kBUi&ijFNms z5<=I=P(NyLOMu1c)sB2F)5Rk7As6`_KQz)_c~iWi^RC^- z507X@>uqzEn3eem8&Z)VR8O)f2Nm;1{izv1#*LE;w;0<@Vyo_`NF`9(qRD<{|EVpSAti*idIZ%@1)_AovT zOD1etZK5l2d?Al2h;aq{9&_$h9^=BR!T1RUVVmpq7~~>e67?a@N_9E>Ea~;Avo_t; zPBVBzEZU*dB#9L1O2YO%Cm)>FcszEpTU)*9QkDN_O)(>{sL!y}6xYdjv9}sSF^34* zJQnEV?rRV=d0+=VV^?5TShEQ4Fr;n;@hhB{0^JmkS}{Oq)+J^XCQ!~pvf8<*?g*If zlIP^xXKd>(3sD(Q&JCAZ&=nPG8coj-ilJ5%jbcuwH%;>O;+{HHQ{oZJ2_s^JdFOqi z6coJZTx8u`+t=ZmsH?+fIAs^_Z2huCi0rj5wa;?z_;GyG=yR;w<*i+Kl(|Zdo_9pN zb?cg0M@!B`krIbLei7kab9K76*irk=N5SZiX|pYSaYN3=4;WjqNuyr~=PBjei5hIT zjZ>W$aYA7cuJ)_@vWEFIruV#6VsTUZ7e;oIJ%XLh3G9o*?l}GmCt7ytu+p15?KzdQ zAQ*gDbN)n2T9+u)o_o7#LZ}ljPCP?ugaBM4%$S`9@l%Ni*K@}!4ZSOt28hZNd+@C6 zrW*;b*SislKf`JX86+t+p24$reSPZv!qC)~=4QFwmLuDBm-|xg@7Kg28VTji%lMCC zggig-y;PzF$#1)5DNc%o?GQ7x(xkk1Eymvjy5~DLP{dhg(h)*xJKFcEG75(?jaMj! zL4{UAO6slaw#bLAX{(14!3_;QSw@N;FE&>u?B9(U;0DKnDQh?8K|2sxUrhINH!l^w z6Mn)3x_Rx!iV<=KN#QeCPE@-P4lJ~(3lXJ#?)b%mpAzzpKR?!!&Oxp{1Z|1(d*9oW z89na1jw{wU)_Acp%G%&X2A1XU01k?10?L5V73?otitC0GmEK8wW&vRWG5O~&d!HfL zZq}8>BLvGv=x-#bPTiak5~--#CsnnCh_-+Tr(d&r=mhOk`A%vN;tlrZs+-9sUzYLl z{uIQ|S2tUtl{MAX!HaRjeq}?aNV}4ZappcwUvw*0RLhvnqIC=%l#JbUF&P%5L9=<> zR4MklgBLjxJmzuql_*jl_NpoX>wQo23wA=cZix4?C)nH1&~afosGMx}3hUqiJ&N7T zz=c??cg_ou`I-3>ZE6F|inTq1*IrH$oT>b@Az_IZjtO&6te|R;%*wjn;;?4k=DhOW z&UzG|`~8r_?KP7)+w6SQToUdg!)PTH$;?C*g)bYyFU}dP4Arl~U~=6lrRJ@au?_3Z zD>4B_ic6?b&L7+5+)~#YgE`NEVMG{_oKh&j+x-kc`km*UY9D4{Ud#SG^l2&F(?=;o^4X$~o56){~K|A7>S; zwaPCL%=67T<2>S7!3!Q-zp0aa*`64UqMTXw5#$Hde_ZIc9jIRq}dDB}@^mzFVU3uyMRMZk;= z-liPz6o5`h!;VE@$}oq@3sA`@iLwspT&#t$uH2fDm&m;rzdAE*wNvg8=w_clDBfX7 zLd;Vx{y?-os3ZeD^ij$q9K1N#K#lu3F6$wj-Mxi~4+@iZx@D5XLiW?BWya6%Fe!;L z%HiI6KhFDM^W9^mY}>PL)ip*Vcc$%#r-Pnm-(j;^cfyTNsbMle8HXmdb7j;r1=@>6Gh;Ha2FG3fTm^l>p&BMfzB646}!;_1N|T-F0AjCK3P5K zE#7@ow@t>lV!2mqj}wN~d7Dq&8k^AyHZNO78gKlPiHVubyN^o=dzE1k-!icvu!eD# zek8Kqt@K6u+}3v03VW+XQT}YqLmtvm{jDjBERAi)VY5gQ0o@!^C8~-0AdSh%*P)ck zqQS4JkI~;0i-NTj4&DnPC2@Vz-xQI>LRrdgJ5k5%65%nkFS{%rDsox>Rb#=_M8#|7 zDACdPX^%az!b`7qi+5yE@(+9_LsHCs$|qT3TJJL>#--IDZTkIK3en`kL`vuaB$UNO z7cK-pYH)4t5X{B9FvAX6kmN}d_JI{?mcs@Lm^91HjqlUHUNGssR_D1ZZ>_?3tyGz7 zZ)T9hYj6Dm4b#;p?xmcSwbuAqVnfniLBZYu7Nym*32@o(Ep3NIMiO|*!jkekcegjm z+icD|E`tq=ZeoUj!OG$GzF)fsSb8xJt1K%r8C}R6ac|I0E+>s)1g1_pV7PVkL_eh? z3CxqMfb@VkJbk5D(4 zAKyO1j!lriz&dtQ0kHXNBQ#&Sd(s%Z=9oT=oQL@t zPHQqvbc}=dl!X>PU^MkUbXC94f}I*w>GE7FcipMw_{!EexOnUoy@5d*lZd%`zqlt> z#Fm;Im%wJ{$7;GcMNy3EyfAK|&kEmc&ShKIw3XMJb}aRu@sd9eE9@-rN}ydG@pe`%eTaRwLeYC_Bj zp9-*#i^|-6PT4I%2c<~`Fdgy(z+r*8Iu64__Vo;Wy7<+&wb<{S#@jTmM=B% zBo~Kc+OYKo$q^AIXC(RCl;pQPiRe)|^pO0W#4j;}TLLd*e~@~MH78O;9uh@EicBfxXVh7AgVWN;7DiqMzL*GX9@-@r6VcaLnVef1I1TWqseqT@ zQbyC4<2rz)zg(kJTe&?UltC(B1-K_d-a?BL4IZBY@OCi|9}}w4DBD;6n>uWHS9HdRzJymGdTFbcg9(R9-!6#tp7-AivNcE>SUdt?8%eq@cy z4(Tf~dh4!+R1w3S z#3HBj6x_m-VO0+%Xa!~mJi;^~IYjeL0$33+r`i%h=0nAAQ$+1ohTxdP)OtIrrZO9y z*z7`=eKWihKf$g4*+O2!=bbde%e8Xor!380_cYG;JhdBnNS0f4HT;})=qD_VH-dWC zeVz7k=WvW*Y%!P20ZWwi7~HhG#bEsWCU|iR@>uxkVZub4sSjdj-m;|19KWjWu4ou zTxx3O$M4MtQ&E6SnNt`PT(iVOtl|rzo`0mv29M6<>)gH3-myQR^ulhZ*Z!eI%UVr` zD1+uX^6OW|z1*jLV(gx$@M80j4(F_m(ZgWP%PS&vB3mwtpZEP`_>j{7!EzThNEzc+P?6lK!O4%g? z`Tp7LhK+WR0;Q8GlJcVSWeJU+mVI4~-p4YtF<1v|bi9mMY84Lh^7H-}^0|I=C10&D zxg=I`Pye0}!|IvlEXw=pe$S90jTi?M3kFcP$DI>4qXKr#$8d#WxM7q|7JaJMv*{aS z$i(e7`nh8%2_KETdKnm;8;nC3F~ltPk*7=I+|~Gdnzacygd4;|;9CoF;Mb=Hp>4!q z5trRZ+RRF#1&RP4X}_}sq@f-& zYH2enxpP@q33uWSpsG8rC(PtipS~dz52A5A?YNpXks%X^>bN>LnZev8>~lsOnm3M) z>E%zF;?h`~%&lgtOMzu%0eM)eF_3nGbak~!iYc{Tmf-l(=2$2%>qJ;d@>FAk+b5JR zOYTHysN~fIG5Y~}2)Dr}w<6s+rnTvHf9uxNqr53VK<01MWobd?o@J}$trRME zWy5Etlm>-^6JNPlMvQcrVX)FyMT^-1?nrQv-OS<=XPX||<7$?UY3)h_hITdj!pi)N zDPrXcf}!j&{uY}7lr+=~mGbVS1~-8@wxpmnwD(;3*~hh>^FGa@{Id929%&|(Dd1K? zcW(ZgpoyRCZIP2|(;b&MIPemCbX#`{PU{<)1#Vn^IeDXp)X8@I*u%VZbmwo+i|HbZK*AtKQQ#n)tMvm2|m5%tnjcgD#r~x*_rvOI&fDzZRrd(0Avvzq;rHiCX@_Mj~!T;@S1bl3pz@o+-p~ zTdBo?RIV0OijwL=K3^m_PmPKOCMq-SNS?Fdn%P_ti5cy^vK|!~DfZ-TUe>ARy11Ip zpYOyGP3%@oGYzf~ft021G>Muy8F$~*90-m~;zH5!XqAx58Rm$~Phxj54XAOe(76!& z)STS#ElO+FE1wt1U6+F0d*5G5=+XrZ6s(*kFKrvBllL$Pd#k9pg+D!{1j5f{-tAj* zwfEk?aUzQkz#`EWDT3n{_$`9+3o?j4bK&ehI`bsSBHMnoGZ)qS?$S_yS!urXwJkv& zq#xRa6BCf%+bQcbiW!c%b<9S2Kq5z7DYb2AL%Y;+(lUO^rl5;UJRH{Su<9_=Nnr{4 z=IDr7yYjj!5lFCpZrVO`&J%5aAj#3rU{ zG+GVEd^O@HadLWCY58J!Q2a;rss53U4{JiqmMnPf zye5HFWkXa(%+n5+=O78@`;0 zGuEr?`}`w118Yps>0F(bN|}ANPV#}a7bz%IOd#cB(NGg)Faj3{Mo^dG?7IdE3y7?K zl!6%NNcUw~6BkrDF@o?=(luDfrfXhr)Ke+nnbUB2HKbbF_mrtZTtztBYs%cMMJT>N z4+0_3JykEp?#LN$h4BU4hgn85N-I|iH5ASp3~U9AUE9ZMWIUTo$`2eoqhSiUc0n0p zJkT=KS_gn>AZYY!4xoIwNhxB`{u#O=Yb z{G=rw^!?o(g&X&E9zJcmGyk%0`a+mlNXkNeMu*$yg=DYsv+Wh4_5I8o7tzv?(aGaF zw1Cf&sIEZVU?l-5`f$gu=UUfJ z{=-U`_~M-F<*7LsFRZ^sf~CQL2$XSM1W2z^Wd#fuUA}w*DuTvnaL>>-^yW#>T@|Mb zu37|{@G!sfEBwAU19_3#vL8ARW8GJ(fIx5W(#EZTO=5h)=!x;C#f6Wtf^W&ORQU)K zuyFuvt7YyYBfzY3H&Cc2{ zc4O^8h7HxuJuT^3z|BAh1F0G&`hljFdgNo$H{qcRmI!(NTze(aa_rqTqQo7A#?2NQ zqH_WpeEBO(V7hyWC2yCE2Blhskm&~L1mLOXEK%KjcL|j!QoPrrp>9M|p)ns`hHa4* za4O!Mn(Ihpot&DYe4HyiNjdlOv7WN*oa{vBZ4Rm`A67m=R)LyFt*3(fGOW>b?9+GSjvarn?TNEB%^d-IS9}uqxcPP)DFe3S z1vtnYMa_$n>vZorRHZ>IW#o9YA0|6U?le5selC6kLT&#PU`)8@U6Jha2`aUWe##~X zYMt+;%`WN({MJ{i65zRj3UnY#g8HB-?RhFlGvgxglzr6CkG^?Ym@=Ffg94U!unDG4 zys9k!+QWxPfxwcXtp;oGR9U|C0tmoh5c_fuf}#PDruZjm@2%c!zEXcK1k;FPR6hO1 zfiUjg=%P9?PDUKvt5q3xF;QEM+Yke#*gG3QJm8|G7xq{2Tmqv&v`fGAqj*K4E7?cG zstQ^uJ=KI5YP*GV@%*6^*%G2I=AR#AS&#I(JI+Y4rm(a1b~MGpa*EL+U^#ei_mEJN z73l_;1_0@@~pP!qJ?Fj}#7~~>EgfghDuaQc} zc085h%B=czY{9$=#<+pheWD9dgeNxj+ZM2Td*XE#mRsMavvg(~@0I0S>go+BHQNHg z;m>n`2r1c?yT6KX?&5LSbyQ#UC!t}kpFdGu?camyKEzK^Uv5qh6v*oNuzd3qH^pp@ zK=h*KdJK^DBj{4ct0APs2VmiIKF}D&m4oOZLaQu|>WD{DA^NABDP*0s|Y-^J~O=bDa*yA=ZNz}sRz@X71A0dq* zkTuT0Huqm6e#G+=!+0|F3I}+{e?PDiYA6lx$^uB5RDqkSC8Y6%@wOz|f;@zJklf>_6 zy1;iUK&ih&Py>?x1;@BOj$u3vh${xQz5ccWX)}(atUEu}C^fwLkf5|{raP4cNRcNx zRZ!Ml}F|1{2k^D>?Z2Ejl^@yg_Yx(-dkCageJt>qo!4eE@1AIw5MKRDC7Y9j8q)W36W!?@rln6F z(;CJk1l{RG4Mx@iWSI4&o#%I;K6CbLd|Lf*mY98>KP4EJ;W*o^QD%(6WAiG=b8}f{ zZ8H89EB|mva`~>zN}#wuzP*B ztKkb{1_3C?z~r^HjsYO(xqyHy0^kKL)%I_dx9+U&eqDg@40$PfzDght2{F9$!EsrH z^Co1anvOYB`x1tsnRC5yO4*VI!zBh&!>%oop>ahTr6>{SSS8KP^Q)<+aY**n!;grf zR{m0f-loaN_*YUBz&sNXV(dt^JZ$^&arW8v=1TlB2M>>=v~*a1PtK<&3tPT?!(XG6 zK$qIko1GW9bQpDNxyVF(n9vBfC!GApN7IBnX~WII9=D~gE6_@T2=be&dc$|GUYj?N z>r`*}wj$Y%>rmlrH$iXF$~6~^NJW#itk1ugev$b)Et$s#v_LN?DELk!vHjV^=b6cw znY$G#@dL9@0X>|!q$SZ3suHDLoghXkJr;UoIL|L-BxTt`q1Ybm4^#^iN(i>=qxP6b z6`zfclHk+I`XweNN~`3Zh;)3{7JCMp%ki4n#|&46mL+JeVDx8myBtcQ3!rKqZtj*u zR=oj_fc#GZl{xXs$78f^IMRKkDt;Jq$ByIS(kY}7klVi>x?lLtUJQu$t~LgrJ}=@U zsP)h?nBAx~XCx9oSqivd320?(kW`qMX~l;Ht_5EEDGBFV2l6A$vH4TueOO%;jR&bt z)m zJFz>vCGD`T;-JYZkKjBm5vp7SB!c{iNIlexG}yg%w^R$xNip193$xez+NK|dc__dq zmh9wis1gx|D@HJCJ$*><-2F(T9#ArKIOphElMM%A$KkyZr1#&@gRheDHY6UI_GEa( z+>uGeG8`OCj7peISTX%FjzMLhI^xlht^sY`0@||amrJ%GBDDN~dHQtI9aFg&en(na z!TG0oW`$29kKTV}6h4%clC4ib+O!e0)8aU$Q;um6O&*cvepNfGF@u7QjZ-C^V-rcV z4=PxC`QYw|RI+s-6A16h=2kzLg5F0$6135t^&lEkUR+;!ly>CJ7s(@HVDpayI9x1; zPjX!f7%a$@zBrDt@9l|aY}1#Cf7N-h==p}e_ID6q3Cu<&Z9ZfU@CAt8t>EHP3aqwn zmuY=IJ$Nq=Q$8@3TT1@n)^|F~cDxJ7C}v)`kzCT4CFVaRB_cvW-++o%Q1Y+@O6@bV zGN0+dtgfUIixvN}ERuF8$mToI<%aoc^s z?m`@g?Syt%lG+1*<){?uYGs8n1&X;>s`0|D@%LLQ%ePuv`!y{-1s&0t`A9$$=9$bx z2nkoby@N8sws7v15u0f7Wt2D*K2)2Je}KJnqo!bekdp(ik@9h}%K&lYFC zag_v(jZZpdsos|09#KAHCwT7sBdRgF(&|@bjqSO~OwwLq9UNfNVc`TG7p`W_A`;2U zj(_&z<+)kSq=Cx5IXCObBLck;3m0gSN9{R61(%he{RSaW^VtXj+El(=mi8qvQa-p>8q8i z-79_)!8rb5b6xQQCsN5}?`<^&9+}fiLI){#Raw_8%C;Azn?nMHINi4Tm$L9BX_)(i zeCAYq6$hrY-KyI^PorUl6nJNItwx|B-F|psS@8i##$QvQgm-M!T}M`X|o_r!C`wdTbj5+QZx@{B^8l^*G9 z)t|b`ZA(cybu6tqU99(EAxQXwdL_|l_W}vYwf2ei#ZI6i4)^>T`lM3&2bu-^hW$+; zA%(=*1S|~Wt~-P^>t_ry%)+Kx6X;b-#02ipC>$^V99}?s`rSF4bRl({8@K_K3*z~h zgB;8k6^CnKPUehmYwa)5e4dR#yj~nG0*T!O>P~6_@YH1EE&LDeK4g6&4K%B^0LRXe zyQH14`1!tu&~Th3ZSyH!xPD#nfX4&`3JYIkP2GNP@Y!ahNQ^VDv{c%agNr*JYo1T= z3jqOje<}dh~8|yjZ{KpjzY^}=RLw98 z&*G{PAP5k4P7-gnqs=A2Qg50Pd4Zb0urO%4s;ShxB368DUbf~( z$N|4)hcAIynO=q?YZ15e+gg+fwdN3X-+t2&rnM8MCg1fu>j7(bIa`6vtuuvQ9W8Xv z!0opTJMJ)?1|^MTI8j)gYDVoR^nV8RvI9K(oz0J-{dM$&umz?=63~wi*8okqRNSv? z_T3renvKC4Byd}setlWVZ1v0ZH9|%rQh`j!vu=ixv{hSg(oYvzX3ZGo1c3+#gmr`f za7R1cuDG>c2sj32E*26B?ujLjn;GqK!IM41zi^v^3?+>kzwOe&3eTir1vN-Xgv~XR ztaN||WI?J#Xs>dgNuo{H-!O_86MMLj5%5CaxGR=ZNM8G`<11eyq2hY51f!3hTY8k_L-%HHq=cKTW}ScZ7r>b?vGHkR0*phfGxL(Ye#zk9t^3KP@`Z z0bPWMVSu+*=W>s{{ zc{ol>_uAh@GHOTIm~BMW$X~(jwtVm$eRoZ5FPoNdAC=I;(Fo;2_>(0m^zu9&GCIk*(B7*UP((3q2DRfvqb|%QFy~6H_!Fy1DW-P(r#1UoMw z_IhMwP)S?Qop;tV>E}bB6TUVgWB1yR8TZ^H38lO{N!2Qz$BD9j`OQMPt}tVL(boAF z*6so+Gzx%+;2(Jv;4e;Ux_IBfh~^s@v17R55fPw@@TyA$1?6M1J=0}_j%cCi-mO2) z8I7grFtxehn_I84cpP8)1j_b<`-;TQ4js-GZ%$%AhdX?|g20p{nHaQ7_6M!KXkP+l;!sds#NBu0E>2RrndAYV5g|%f%xQ9q;jFuy$BfH zBiBaX39|Sv7l;@r`%@hV>0`7U71ZCtO}+KyZ2&al-8G%&f|N-w=M{dr$9aK>a6xfO z_=5kj2v}rEyPyW`(%+}oohU4YYpBE*88H?Oo;076)`dWLBk$$;4MF<_4X zf|22PfP2p?Kp-;P@vtkr>hU8pj7nm6L^93HlW3^{*FU@}Gqd;Q3vsd&fn2K;+^3>| zKtLSU#-yfSyIl*Z*3)}tD+6{^U9CX>Oy}J7!{Jn?^rV3+T=~ueJtY>uZ78mVOBhSB z(lS3xVgK$^3c!XtO*%5KzpqTTc`_w*X8!ujdvx8ALe`+oLAQ#hemZ+LPJh=jb`zU3*d-0D5$ zgDLv5_}&?l>myp$mkXiRcdqkNdjs`JGOXB_6Jfx3b11FJVDPOAG=Okb?dkZETEQ)z zdgK)JS;9g0&RoA6d;(Kj-G4vg9R0*y(itrB5L}@>$ilg`RVo;dbx^6dhtZZkUDT~L z@3~Mdoky7p+n>(AE~MK@6KY!AV6oGw=g?G&2u#cCGg&_`XeShj2ad2DoA`W&)_F|5 zLHNVvnY)JaJ#EtH5j#Vw%3YfS#9=_8le7x;%4ij|Elub$he>;!AeC2Q4!l5dz@dZ_ zP;c`XfW)3{l+9#_g2Bv+Lno_D%3;^|qoTmIw=xR~suL2j8s9Ida*@vkMT}NvSk}a) zhsF*hZW9zI?tFR|Npb{2gbyJ^Wm%?C-ZC5pWU>sG`kg_JEb0Xp@9dGGKDz62^G#+k zKBCqTUg?-%a;?ylWb>Ne6GVNjyc>DwZTAIo>E~Gik>1RApO3f&sq*ET%)BRp& zkzaf~pD$%$TH215GPq?KpRROefZsejGVQz5bOjQ z8Qb%8a?%u@mg)xY7-lOq8sBc6$Nr_)5?}*)#Dt87=2a|g-M7wlRM|8QxjVL~k5r0> z8rUUvUs|2?e$P|9G=AZMcXY@2`Ep|nGBjbShilVpCcRg<3RI*lWRu^wojl!_?QAtU zQE)9$u`=hR=^1;mz=c7u-qq(?mChvcx^+QP{{HzYH5|!<4@Doi*tBdodw!al2}Cou zGzttR;eA?cDp#GhdNca+y*#Qrt}Fh5_nAYSS7^z72XCbEIn12FV$q{D+Yka>y%@LI zy^`6kMQCFVxF_?uxe56i!mj*7tgfQ_2D=&QRA_V8_9M(#-z?AEV4c*no9h&ftL#rr zbOgY9|16*5!Wnou>f6NVOpS2EWb#Y65rDlT<;Mm}Bd&np9{JUb!Sl2&S(Je5U#eq0 z{Pr2B>GsR}XpW;>Ad$pySenf!`tgimVXv(qTcc;sU!=?H)xd}X@AP&zP`r9I+QfS4 znB(=mrxQ1X*gS{z(9y9ZN7QVZH(a8KC(9nN4lRxc24_nen!hXtv3|DNTx@Ayy3A&A z#kZnJk9>1?x6gBp05$#7l^NOFO2fsO)pV}Kcg_!&Qh8KC&pq(Q!vW^!S?dWj@b0sf zD3YgTZuYtCGG_+niEd4CS7@ti#IrrS8yIT0JMMk`^}`eKOw8<#O9HV08|qcX`D&Rc zH{9F}CKy~PP9?uAjIh6!h|wq7)u1M~Zu^ofO}#F@m6XHtM*fnU%K}i1+8w#Sw~gJB z!WHOzn}hNpqswVNS0ev&-Gw`X3KrKWn9g)6?_1p??y?Y@ke ztS=2?!z;+2;GVeo&Qr_9R4LPaiwVTl6UTgz^$LS~je=;v&Y%aZIs^ezQpP#`k!8jm zAso!wnffEuFCvVyC@IdAC1b*rtMwYrBwDCUe|fFCH;4W)3*3Bd6ZZk<+~-Yt``w-) zcC@*^Yd$lbw?(sPTyEuxd|4a~ETg$*7&^*MsH9ZtWO03Xi*>!sOku#s&_bF+NtF9y zA)Oq~vSRGR0&k;9)-OUPgO=~OJELNSJm_`OMI3^{1luH9Zk+ts*AQDjhwnMIB=x%2 ztR3QJGv@bdC>|V_EBau3SOAJ8z-56)w@|gY65*6j-q+XuJ`cglsZ!~Mw%hY5^S(;{+Vus>eebnLvuLv|-ELE}(Hzt5PikwqXB2LeL$_3%oVa)%j6{CS zW4-i7=9o{R(#efQn^mc*iC_hFR$aokXG!+Y4KZtD6F-j2Jt-+m?0@yKDxSA+uyoTuG5qe)JM7+_PPn9$LsHn)nO8DV=J-qK*4N8Vr=z5w%92ba zPO7zoT}R&%S$ozvA7KYnpU=&|e_AYa;!HZ*MaeHwAC|i=?+ED=oQif3>#F5$NrLJ< zZaGp(N(@s7bSboRwegHbV(7{XlFfF<@LYIv=u0b_3Wek23<#FLKjNPp)-%ydbtzfS+ z;s92~VLqHM6wW=X1esdSen#mX-tgvDz8gMXfMsg!8y)j}x7)VY3yFwd7DO!&f|~go z1hNdDPEdgd{Im=|4H6Q<0{j+v=0%R->OG!n9Ac)tXScX>@_LQ8cIWEaeQk7~k7^B< z6J#M?4GYW)^Dv?1JXSr$5?ycHvznhbr6V=#P#G&%uFU10ZYy{|zM=&uWp@rdxp-lO zC)e+;b!$#W3kD+>@zHO(7nLzUpQ!U*DNmHN!fIF-{e&gf5@}GcMwJt3Wzhss?U$-= zVBxP)yv%3+4i%_Om3*Nl*K&FRsQ1iPI_)_C9XpoxOb9r!7S++xGe^AfrQXUDQc~;( zY|5bkNORRFWjoGA{t=OHO1X1nF}JEwC_~;EX(?FgJh)Lo0h~oWL-3Y={DHoeEs!w zpx9faVgCtGS&A+qr9euf+zNC%04Oql`z{`zjoB791d6?*!^ju}+KKA*#K%M+jGgkgO0T*e=3%LBhB%5+aLZN$p0 z<}Mt(Sh%OduwdGj7fs&OJ6gaSfTE}W>~3mFv1wF%iaLEbtO`2bSZbs2<$Bvt=Ic;4 z`(f%uyHS?rxVt0~m~!|go&Z6S4hHbDph$v4qGdA-9XHkOt+%EL5p6su;=~f3l}tgw zu-%(ur+ShDL27Cxg_`0y*!Ra8@bA(F2jyoF5s}@X%Bu&o$bI!>dh#NpueII)E+I@Q zx_PmX5hQ6z|$U)>3%|V3Yi6Rq!c0Tni?7JlS;2<89o|i zulvA9^H`+Vep@~@q$y8 z=0qF3rz(ESEsJR(^42S$^zKx-X$MHxPDW5sD7Yq=|Hd7mNTATZ4cuZuu0Q~~jt4`a>Z64TlVe^FzD$j!miPAHQn8%t(TI%dPPsC#1?92bY~J=)3DbJ>4hNEgGb&2vY<1Cwg&hD7w9ui;{3sbMFX4e3b* zkJxk}11uNoG5_+ao=d(@ccyBKsy#+Ra_O}*tplZC|_f1A@y=n@8k4pqKzez z+GrA1Sgl^V5Y@tI0&QiW{`nKbE+~`P#AIHfqcsYbn|O+5ao@~dF)pNgK7;czJ%<=B zo@JQ;?g&2_L4l$aq-}=ht}83ka5aV*kB!9rcJ_JNg^F|Pb(275qMsWPwB?}Xrr}PG zj>h)cUg8}9DCfItJhnTGq|&cfRkR^aORov}_x7jcQT&vn=wHQ9nRSMs5ERfGYM~ZZ z72)kSASxgC=>Fxb!H)t34aUhbFc>$PFPE-ZF~VF|B@En3vbBGT8s1V(7l5$cfM)57(3+q#cw z8uo5UyM0`U3KLkrIH1A;x}oEMed$Wi!p^r5uObSusde8l z+_zkAuUUIfn(P#$Y0rt7bW}uR$zY#j!YFV>1B{!SC+99#JgAL!y}1QcJsfU;^@EpI zQKYsgYeP`oYu}D*nGyB?nV+z^ASId856$8Ul=`fnToUvl3p291(^4!@=(-%x(XdN7 z_`J)Vds$>v>NVMoBz9xAT7JGaRuF1|$cw?sEuAH<&kYIFZ-PL@6ZMH1-mgjvx_Td% z_`D*hs4N#BAFGLPjSI&F?TnYZ;X2H6JlQ!*^2TF^NpMdyx?N3;Uh$d1=Z=4P}8(Ulj4Ve#XSeh^iT!Ex8b#{wj;oCDX-PQr5vj7Fi3h-haK= zkpD}fb$cN>w?2VO`Y=tut+Cvv!Li*}3|z(IsJ=kO?un31by4~G%N422-lTbL{)X9! z?pMRhcnT?f{~vpQ6%<$3g^i+tkPsjtIKe`2Xxs^I4G9EkG8u$CyK(@vQD(%QTIH;$?GzMkK4*8WDFj z$fLHZY=*`lQogU6qYg@i3YghNQUSEW77&~14Gz>hon2s5^!4LS@`L$_yNC}+>%qu9L@%-I$dLrC4@Qh95`u@ zLcN<_;%XPzw$ z0|C2wl7HjLBAN#Ub;#n}IL@AfKR?JD_i&zTz(P&~aMT+e;A;<#D~o0z6W`(#sUVc6W!D zyPCC+CidrXHP%5nLham4Ht{zDzK$c!SQb!{{>MgFvn!l!CO**1*uJ5=fnZ%bqJk z){`)MCkz9ooy%L;6N~B75|u(hznh~TV%Iw!@Xb7*Dh+=L7%c;V$zd2*K{sk-N)o3; zbQySp16EMx?NI}ryOsRBurbUoGd7qInBIig4&1(05i2|tm)vRJg;uj7!FZEYG&M829MOEWyzVFS3Dp z*etUEeH|jDDQch%qwog!8?^vqMGP2gVOwqFA>pG8(R3Nv=9JI3nXN}Pb#{*WY%{Px zYD7(a{O%F1{KnvfpKh_HUhPJSc7TxY)2Fmr4Lo0HlLFIGJg~}1@9y|EDGzV2j>*hp zbab>$HREAvFr?%%vJ8%Fsk|&hx9k%N#FTW4ZY;eCIx{b3oeXO)#IE~?*rdr5e{)Sx ze-61Nlp$P*&h&phU|j5fdcbt7CF6}sic9F=|Azx9xQ1V?q%-Z4P=b>P0JMsPQgcgoj8HVF0Id4w zK!El`V}FgUg?8xStJ~=7wM}dg3@!ExzLCy)>K1$n(mu|!P9SXLci5Uz8)cHOU$I37 zCj9X^UyPdI(>QNMTOLS-2e((m_&YyV%T#|XMV65qEH1S~Zd{faxqL~aV(EV|D44&N zhHvDYp0m!;gL3bFpAqw|4bu(j4MYSy1>$;Y(v5j1Sz42sWd?-A3X$wGv))I z?B2%*+kFU(*E_lfAE$Ot_CnhX@%0uwR(f8XnykQZNYcNZy437vg`9rHyctr!XulP_>fNk`;ICv&11#co?y0VCA?Dt?oa=i=s^>s7) zGiBG$97hiDnZzzGpwJukS4ldW=O4sDdv8)l2l-t0;HVNWdsr<^9u-Jsf6{9xFvdT! zv1TCEZy5|V3HtC+K8$C8oSag;-H70?vZTI#yJ6S-{Oeq~&fRTX9SR#(!ycUMO4ukC zE?qKzHiLl2Ibv$7V7ho-M3<#WnqEXdYns1=W-jXp{>`I@M`XgZFZ}ESp>wp2w1Yx` zE#V<6lf;jRRv3v$A>@ zblNm!;#=w)2g!cnHa+CF2C>?wTe~ly_c)9yN*1aGlNlKqhisd;(D=kW%}jTWTp(FP z`i_+?MN8uQ%0=6tK-)>7a@}B%QI`0ssYhRRaAaU>a<3;u4hcEd%?P zb_(b<0IR^-kTV#wRuBIwZ_Ez9T+w{z!=ulSsxY?CKwhY#d0(eMDnd)}HLq@?pWWQM#7oh3fIJ$>8fsz^N9-;ZujAx(dE>~hvj zxkSVl{smDX$-5}}f z9(^>Rv3v}M2pv(dDQua=SWL<17}Db~Xvp%uuAniMUEI670h$_;Un?V+b(%doBX$IN zNO6+Z1tT7_Yev`T8bZhd42S%&qjFAJPcz4x56#=Ac!K!dZk0wW+oJPxf?59=JrG8P zCl1bl=!#j`WaWq<(S(fFdU= zO(WIMNr;JA?6#GZO0-Ed71Qz9_M;F0$#y*o{H@e%9?`>FTtRN|DEW3P@)-VmzZhE0x9h9ZLBm*B zxbF?E?>l5T_9saFREvcb6M2`<-|lF!=X>!fTCa=x6|wW#-;*DeOd4v zFo32-QrGTusQu+>P6hks@8=ggx$PR@e9fBA;dMA9 z9HyMdX7rK>Diy1j4Wjg7gox_kSLjySJq z`$Jxh=20Y!24G@z_NR7^D);j=jjOqDp9$T({M5Dk_He2O*-$!Wl!<|}Na)B~a&U|u z)EG>!9P$>T5ey4T5Nn?aCdbH?O;-3JYpnV^fs*rUDbB+S5HkTwVYM= zsn61N)S!j|m%x#_SSw9x*;ZLuMI2mU#4gim504OAp!K&^epn5LzvGB*$A2 zl?Ds@ab-R76shB=Y8oc~zKkzQd;d}nQ?@8CB(C|9x&6T}SV{;BJ1atCmSST|ir_sf)PJmd~AWm{$&deszJP9_CDqc6@=a~zN4dko*raUm8w>%$B zuc{>eH~mLdU5k`;(+-T+H=| zEvEtVB2#Apj6-@=QUdOTaf?0_KGmx6jepk0D!Br>K}owQ&!5S0Tle~Qoe>r`{lE0( za`R%BVEcaRaepMuQbcG82C>&Z4Ww)Gqh_v&$c-*VsqGRXoC+snQ2i)dXmv#r$8z%B z=SXr~oU63Q>ze0#o$FKVLqD8sb)_Ln@~fGSE7at}OLflKwX)}ybr%@9yD2xSKNflT z$q}pS?U2Fh4P2k)3#i2sL*)Q?+T{-t@Tl?F33boQuix_Aw!yjTl_p**ouPqS0H7Ej zK$PP?6nq|02Asf}`Q*d+Gk`xS(|slGM7QGwfgdo2ig`qZ2o&K1O7;bi&%IOJ?ct;~ z0HY8jQ})FNEgrBtv|>P+6izc_Jo~2#1ye4;t2^CgsMby9*F(+7Y}TF6clwev!KBj( z@Dd$etcJrhtIDyA;$d-(u6SHPihNwAUX=ez!0oxz$GE^IC6~j29uMo|O*)%vhZl+4 z*4Hz1zbtj`_OE^cOd2VdSD7Dvu$2~nX6#S>UyGE+EV4GNo=UJ%+V8naMY8V8_B0!%~(yyY~}ByFPt>bB%}0n_1)#Q>8M zfB%92a^@{-`1?F=fR{pt>>OaN@md97WdIRRLbIGDL`2Nv)P=Uc;__JGj2JOcB3M^t zMK!!|T`PDYqtno;l9(UKC6+;=;Ay_!X?U&wHD0iJy+coM7!SP3o+tYqg82Q=bNYkCG_*YJsax@ zks~X!WEXoZ=0<=_N~}h3js=(?x0(Lhh+4gDU5oBvFML{K^^w(wKYh-$eN) zM4KOk{{-7l=39LtU;o_O0tJ5tY+MfQi>A4-x6*hPYBk^nrPM<~rVNl$y)ftVW(P~= zjyZts=36SVesiZ_TztVkI<+)ahD-hF_P5TLuf5h~WvD?|6?7|lYW-NrBz!AB`ybct zxZnd+X5u$Yd)`6@E2CAB%bpEwyL|u=1RwVvywwBAmcX^pYF5VS-fJ4eLzYi8aG(St+#h%*e29FHh0D=#;m=&V(?Dodtf@i}0j=rf z$+~~&R=8_PU7`L7)V?k9l^{n27CT&}AP6FIEOVre%iX=e??jH)(_gaB+;8x9F80MP ztLUZpn3XLiUW3u_S_16MS2Of1Qs+fIgZ9U;+5ONl&vC%*ffFS|Fe?uZm^Mi?L$uu2 z91;WCMkyQBM3Bc<@ha4{B{oYlwpe-g8pzk>F-9F+eE{Q%cZRKIyFU6`IL`#E3ijzm z#RmvRh-B5)`}k-vJm+(mwxie#N6FoYcNvj_-&?#Wn=6_%O_( z*P=iG0vU2hA|=Sb*XBTfaAX7<@=HCYYA8pa2=bwb7Bt`NmYODjrGrCeHj`Hrhh61K zgJ7ao6CDA&yrm-sD&+0GAY4+LDnC6)Iu~dkXv2Gk{3$#>kuC?k(!Ifj?B&5sCL8wj z=a}l`zIaq{L;0_gg?eU%WTQJyrBNk$m*d;F>MsbOR}>PTM$*N2-0u+uwSEKvM0No? zk^?ENq!bd+(_EnRpt~E9&C_hip05gfQ`{MSKU(9)hdH$=;Dl;lOVaAY_<$;sMhgOn zn7%3Wkwht!b-6;pX&!?k*X+tf)DZE5w_jl*?qg z6eH0wstggS7Zhl?!1#>su~!FX9*A16`03R2Nz7Cr#L{IsRe%X*siARear$$p)?>r# zwrM~&wOyL?ZnwQ+qxLJ;x|nC-WP!EUwyU{;)+( zmJI$CH{ZyTC5`Xx?Is?2gnh(Ii4+>zzKaJIO|#jX{>Z9JTyEUwYid6JjLcep;ByqS zVS42acD6{g`H0G>5CC<+?(9#-=Oh5Qu{M)SOc6@n1)%t;49;FcGg=p!b5?fzF;Vot)pm>z2Cg9qXBmkavdA9oJ{^F!`Vqd3E_<1y0t@ z1Cg!-&VqNj86~&APA{TkLKKSOJY;IJ&T8B>ES(*0M5_}9c{-G!cbk+(>|FS4qj_q^ z;oCSGO11lZ32BYM;84JHbdg`pVogGGJDmc~gcxKs`&4c0{0_j&$;9meQ-WbCaEunN z(+faS_yEuuR`JXQ%a*NDkG;eg?K+Z>@eR)_63LK=RCyWB7y8a;q9@L*K8Pmca!E(E^O2BGDD4^1P56 znRm~KX+E^Xn@_A(*VUMzY`n)_3&)yza<`EGt4zkcFp-yvlcl@U(f*!zQ*JjVSIHE> z&3mbp;apK*Q2%;g3SqFJ6N$<9n+jdB}iyBVN$jsEWRCz){6DpqiE3X1u9qLX%2R=#oB5nu(Md>)ZFaAtsQo^Tb zC}Tu;!ZY6axDpC^Ku;LHHOdwMI4)(ik5v{=pxRjyXwD}Rqu@5Xa?WJ%5f2MShto+* zg9*go%7f7FcS#8Za){>W%b>>A3SfRVvws>-cf<_mYd5;Q1RycPuBtRs#2(_{fX%jg zzn4lenXu$W44ecL0Pj+b$E4{G3)XO{KN-dmm3%I95Z zhRGwhv)u19u;gZk*Ag99Z3|ny{BFN;*PTWlQ-eP&pDIv;V2=_}f1RD30kDnd{6{Y2 zsP&nd&6(h2ye{J?xrQiwkixT>hbaW8FKvzq9bxR44?w2rQ|wlU>cwg|$}!|44qnRe z!LEwOf#}B3$bCCExnPj$&QK4-C5jDPK8Z$CL9vJWOqU~`RPl|cX}7l3aH-f%RZRzj zeN>MAPzDSGI6WwbH>tTvpFi}0$Zg&EmAeL4!S9f5dRh|bHe+oziI?0jk>%R}|F8>? zY}D2e_IlB@$?vyVxFoz^J!@*X_ZON&0XSp2J0AG43bEVYsB{7J3`9Wp+IRaC|&;&O3QZs#)k>X1=2(!+3`ACfytB+t;nei=1ar zsOQH=*%aRt#Dy6=p*Td~VkqLU%O>q`VieLd3}=-0@1BKWbh-5}GEG^$rtMS~-s%5h~6Bqi}1%2^MR-nv!&kCkg6miYh zZA~onMRN~QYj+YeO+m)3BprF2t9|FJ`#X$P%HY+x`R;TznpNX17a^yWzglw~DS_o8 z6StW(ox)s)9cJzI5sL1YP6P3*$h|d;ijtc-Tt*5^Y++GCS2!n-i+7Zb5WcD-1O4h*XGAdW1#AUEs+ITbQ6`peE7hW7-->3!tdW?9B(UCionin)NiR7qQ_AvT7}%}oBuZ+5 zlm5@x1Mg<4C6_?!iNaNCgpx8J7PpsLpUJKxig6FPg7}t{+iyn;gD`L)N7Lpd>8d5# zFy#U~Ub}5?U>doA3_B2thR;M>2B`Ln=Ony1HJ|5NV35qkot;22KXig#ypOem^a z!EQlVUx%-v|8Rx7lSGrUL+`$kD=@L0TRZvD&iFeACfS^NV-}LnGc@T0VpSpn%)F4? z=+@g=)Q zloAj3@zdu&g9zBI&$g8vU4)n#)f%0vwrdQ!vA%9a+}6y-IQE2teGFc$`l}Q*y@-vy zFNiH!PV8QIv+2k(e&5*P=vqW&p;Z-Xz>>9UX0u2hRq}COmqlKQC#b-L79~uW5Jg@o z3p37ljuR92smu5EJ`oePg%FpRQ^gyGRj$umd(YY=B=f%H`r8`!QPa&AKkhjj{v2tl z2O(S7j-kJ}Ty989dsAsP*7NaRslrI6t)pYPE1U*VsT@~ZTRRSzNtXz9*W8axS?>bo zXX!6^-o?i)y@sB$I8L+sqRnQx8ZWD2E2&{?Y$H(zC3*giuYmEf5a&UZcp_>{=z;rF ze+Jy2PfWENCoZnub+(Kb&wZm)QS~#_2YZsRfdRQ0+`7%pC1bHS_|xqYl8Km()m9#t z93xyBQ6H835fZs9MFa5|S-z~k+nxO}LJN|L2Kjh;K8j9e;pkS!p;JHtCX-wINOK<6 z`i8(hQA=I##T0z?_o!D=UcPVofdM{4|{ z(&9bX=l58y9O>_5KKviH8y&U-0=ZmfHC79+&BqHNQ&5YCvN9=-$-I(-)og z0)S!_Z`c;cA!+(N0{gi0TVg}VdV`yZGMRenn>LP$ngTSIdXyIziW}WwJf&0-$+ga_ zrBkS=W5t>0at3|z*Bxr+3_`%j)9cqESX5Fl=_5k!&xPXc!`aefO(;`I0w#lRsvz~i zpnI+T9-*k1*w|3^mSspv-n)akD`FH%Dq+a4xHkw`)+)fATptzoiTmhw8{dEQ52FhF z{mc=E4)x)jBux6dOzZWpqGT#oU2e<d>?x)etWkgcVqgwUdkpybu;Lz6sEVk<( zB2%T#MM4rRrz`W0j`^neSnYQsdmnCZZ%Zwv*p%{5FJYsnTu|s~#ENadgl9&}6o1cI z*dvrzfIe)};8`$%47Mih@3PDBUg{qthJ~RWuL=etA|PCotv-sG<#!U*GPz)3>W?x$ z-vz9TR!z#jDkdhT*W%)*Dv^4CUk8N*zOkBOb3=y#|(&|x^q=g@kk7-PM73+fPJX+>zK5l>K zF?|U|rLkj(jWzcu{T^|#D?6EbPP&L*bM`d)tsJ0mhg{9QRWM;)yjWt z`Nf1VIK3h&D97ghE}NjHT=|}zidM4`qEzdQWPSG(SSsiV)*A$4KnUri1JkNXx1CG3 z`MA%tSr&q%WSNa=wUq~F=BUlV_sA;K3rJAZn;TEO z&F{di2;K_Q1GiNu2W;#0hY`LECUKuvr1qO>J?d=|;&IKiHI|_*%K2Fi!LnzwW37Ie zNxyx*eNMSjvTrGnQqEPklE6E;y7ux94CO>0O5f(|iiwUd31V5R2$6ilbT6nva{bGs zR%3kk)zCFjr^YizY6QS982k|&!&eFrQup6>@k@OKy1OAc}uomPu^R1 zQwk&Yzw^ux71ADyhOvA?N#I6K?{+0RP#c!ptFZ3fODFxtRZ7XlD;3QLPU17?(m1jw zkuRPg;wo)9%DG(FFR>P6=rVDPUJjPe|77^*#=V|nJp18}3*B z7y`%wYw#rk6!q>>G&V!@P;(l`e`R5Sjbbf^kNbAUPFPS#=uUogFygR+EDUW=FEg{x z$mx$|vfbO2k=lGfnevyRdEl_8+gu8KdOu*k$%rn4cui4C;YjycLW_-XoIee1JIX9i zNX!%y1yK7eKyF~(G}BZ6&p=C?n~mok2_I49tk35@^`A8=jKQUw`jde!=Wh&b)8biw zK(Et8n;pU@nv(>dkLBTVXOt}7?5)TY7AWVxa6OJ5%4_EcL@JbCRJb(VMNuZWxg*;)1SrP4pR!1_pj{-vcvCw@P}}@vJ?nNx!;Z^6D|@r`8tBk!LR*NV$Rxmk zrug++3ogC%X}i(;fWo)7>NZ>(Bxc=Sq}IpV2rG!G-OIim2+S=MlI|N?9I6tCk23b> zTB;+Z4;PmrTTy%P-O;sDToPUXT+$S-ADz*_aPd>6zs*XQ(;u_47v5Xo=?*jc84*{S z_)NQz5YlaEti9_y%xpLCbF=tjvuXzaaHan}HQGS30IAt!mA7&6w_;>(dV3T^OjhpF zgWad%3)})S2Sraamy$ats9srz1Ikh?0#_Dy`&SUMuHwCGX+y@>9rN5|aa0;ycBx=5 z&qsG*GJaItaw+oo(*i+(>{>eivnATR5^EG5$!dN@BE2&4M*ZJV1<&N`FhW5_-aPnT z>=Q3n7Nk8^hBE!A(ahD0*@4#_tY`+VnE{WfnF6;2qCeBceLmIO9(|iOwz>Ij$_}L> zHj(+xI9A3wj~740DqW*P9R4n=Lv<=c`{&JRMS&4}U8^n@a5t zD2z`X3hv|ZMq(htc-TbP7pe{PkK3umBY@W1K#W3_8OI;((Ki}(Tknb?0?&&MhClEA z7amCZAk{x+s2ngucDoia79lp>Q>~L9qmye1z7AV3f)hCuzkTcDMH(l)A-U;srG;b1J8!Mc`SbUnsCO8CL9!~_C9B4$Pxz8u(PXxEi8Jx0T)CW6$YtH!9(L)z_3 zs`}@lBz9L3bso$6MJyF%w>k$tSR}lLj_By0q&{6t$xISfK0gXw4)8@vO7Jw8%UaEg z(Xq|droop)q+FZ^RPl7h!w(Pl8|&_%NOYBdFw0yRtrfcHDk20kgTnxHPI2kRT>?ZTX-o;v$5)&0!sUW{Ey_O4BaBHFTL$U2gg%KB? z)da$tb7OjHR~i){!T){IY~e20CQLB^^_R1$;+qf;q28cUjs02RWFlPM-tLMD-C8_b z6|JA#G^w~s-J2m5>^qH?DMO=_mHK-PVMNn6!jSN%9|;}ocp+pBk$89SEOrQxZ>`vG~0$J@l|)^7NL{p<6S;LouQl%)e9S>4XP zvV+W@2|UCNF=_rly`8D53Baf1vpMV1t@!azATmO2Z;b^vpnrX=x&Fixun{Qg<7IXa z2Jx;4uu+=;6vxNo=Dcf7Ss2@@&3jJ#aC^*Oib>;Gk2}geub%qki~N6}< zH_HhfkoghIc@xSN!&G^?X8k!WhWvc~>mN?X-gSffx=C9$*=ps+s#7Qjb!UaVq{tM1 z-^m*URZ6&jEvfg0Vaz%(Y0KX*d`^99DbXzc-8W2V++@RH-}CnU^l=nZ=6w6sq7xw<}`GT2Ckat(WrR8 zN{u+~e)w0clwrIGQ0OSZRKA}y6xEC9AGeO?RlO%)E2SLhYJGM{hE$@ZKR(gNp4ubp zsKyfljx59e_esJ$!{K)7+b=_~Pwk@n$2cs!nvmD5C;oDM>a=tGYiL{j|4Uy|`gHQj zr^AxxdZmR@a{i;Eiw1wrt$ZvV?o)20zQpeUr$JxCGT@7&#eS@vvs|fJe2oG*KzSU- zcjK4$c+)gXxKCGf)kXh(5?LwuK?%u*T@K*Kay?nTyr}rizY@b&Q%B33szY~H0XWy~ z`+!NlAtSfHlw6_z^JeE+rT4#^N}LFPkXuPeco%;7FOluZskl|9Li2IWv*%L{-M?M_ z<0{?xrKSI)*xf*IzuEZ<2cY=E^x&4zigIup5W;iHli%Ytdk`N_dds*yAp7r`6h<5; z6Nc!Lgp9xP$ylbt8MrOOAd|++?y4OPoRh!K-XA}F-1(2v1An}u0K~+BI8w?72BTV+ z{NsPNqN!Z$qmvJdHRQx5tWEya@;6>le?llhT9Cp~I}neu)J*Ig4s7H(KlPJb48B5J z+}HRZq@DF&A$iLbd*VTu-D*xb9QURR#ln zCWMj?@oe#@8L-kvg!TXEcbI1fupGzJB;O@E5Q8Te_#LG^5HEYag!Sl26SC^YD3W#r$@OM3!*#|X=@`d|cNSd{obFKlfD zVtbmDL9Ty$$?>nMn!uenTK*PMWIQTbEkx<#qk#WB)=&5yTRcyShXx_QuPh`e^RIrX z1C@%wZ)VpW201N=pZ_23*Eok<|0f-Yts_(}^6!Hl=7|R^TbY*7#2TMh>PZClc@j41 z^RLobd;kLLj-T;)|L>#z|6HnavPy+I&98wpQe>gYjn(DcA`)ePuHFxL3C;5P-psQX zFMM)yjXRz%{zu(}5sl#I0vqSI(#OTXTtkqyU zfUGWXzR^X4`3;zrQl(Fd2MA>U!vfg*9Af zx08eP-9MO+)9llDp_|36%& zY9!TuZ#ugtIqSVI86_ps{{H@Cqe~6eGjbMjlXqxnXkuQ7PgtB!O&TudT@KIBB}%j# zS*+&y&rTRMs$mNGDA?2=KN0+A+dX%H9UBZi0Bp}8*epZTH|)g}(791Hl+~*H3dxij z7xDgXKyQ%hlYVzPB4;w+@HU1?3kt9Z(vgwnf~(9Y(1C@OrqH;#xzQN(|04@)tv}&| z0C0?(O2w6a$FV+!7u^6>Mfsc$1f1J#0obfYzh6ZMymKxrJl&coAzfZxE;Sni6>8MT z-T{-j+)=e3fvVh-m@>3~4O1=CpY$*V&=8$0AYs41IoOTm+)) z#ms<->wAZtE0@ak8lPV5V6I+uudEg*Ehf-8?9Ybjn=H7V$eb;Zvphv7_TStz$)qYb z)sV`W{IBOok%cce^!IXfFR)2TLxDm|TU%QiK!9jO;13j)bb-^^6~jn$dw17&uJiE| z`F|zb3#1T(p}l`3D*=c>vGhM1`JWmk-0evJpS{EX>voX-zu|Tip1vj|gz`HbzpDrLWw9@W}zpo5fPp4yGhx|@UPhlhtONAhMF z(vYK6W@?!P@1ab|){zlKl)zu#k%`)W zjfngB2Us;-NuZ#1D||@dC!mlF84YzXMfvxX@xY^sJ05@!Oi1Tlb<4zrYQSUDtK+rS z!=+YMprX0j6W!aUKQuHHC+NnTBby98JhVPzr%SGLJR+~axel=WH{e`cg*%0nPf)0M zQyh;Cc}Ea|{xS(OGqbmkPyDSGz)%b@K{7@*>&J)+iOBrB5t{Iln)~esc!(L{6(%zd z)EP~W!~BB)f zy>AW`yzYu9{wwqU-{POZz{)D#@xRi6l@VxZc)RCO-(rHYI~s8af&>L+{|$`@;JG1! zql$YJ5X%a$^`ri%C(q{6Z9%fDc|!E>?uU8q0Xi_5ay4R<1#?6tn9?A-P~m6P_~k-( zirx0FqQMHnut=w; zE@uB585hgIP1b!bJWvg&zy;uQos(tyjQ>v|adJMa7MsXN8q*ll>4JKBS$~A9F7mXA zlqfJyG3SkR48tqlue3nKu}(=ZJ;PRRXeTzG^ki{m3)z7?v{In3bv~A2U`b-s@LdoM zCI4``56AlhB!HZxP!R2j05rp7KEjzh_8e}ZJxBgnLg`cx=vy&Khu7d9kIRX-dto+J z9oeF48h;A?{Ij)&g3S(U>E{6ffgLFoiPo#jLbcc~GZB)Len<@DizxyxK{ul!md)#3 z@r*U#fzrS7I^hl=c`yh{QvXc-kDj25!gnx)4HI`FlS%jJe#*a= zD2EiyG+-aaas5r%CGK~T* zZ5v;R(mP+h`30RfYy=|V%(2}3b7*7$-Q0CqQp{wM@NDaif2}Ra66?h8_5Q>Ww8ov_ zLBkHcjV;beyoTZ5>&E4E>0y$rwe@R0qa79DKwqXyJBhC>yw?2&@ijlkg6;TZGd5wA zY2vu)Y5cH6WeZfRYA78BA132szzMO$CjRK^U8LH^uab3h`A*sEkl8PeRxBe=NM6k zppD7E%(FjFsQBjzgRpSRJw(O9%YWYDN;m#aC}tRI4wJ`Kmrt~ql(kG6Fm-5+l~?sm zZS9Bb61L@rHez$-B;gB#Z$_me3&CZshJzLR)->u=v467VYasDV zC)J^a9POl(*qpDw&s2^`hpAR)x))|q)lt1+mJeXZbAN((Xjb&Au9W)+19*;&L_SW^ zW4lGD->hw{%XZSvIlB8euE<)&%2o@w>vL)#m9=`wA1|bMde`M$f+!jHakTPQn!#x} zH1w-wK$NKE+ate#U@>*q^uR{kolwTMrjwbV>Z?`yK=o|(odlX-TXaMh(eWjq!n~qq z7G?5n99P>uq}>MhKT?;-iBx?_P08fev#}pcjWBnrPsTVT;8jF}Pj=fM**D`%=XGt< zDXtkY;h)I)avbu99@U#y!xypsxAp0K4W|MMvPm8cYzjxD_ZA zJk7Kr45)vp?vZ&`N&H7qG(+H4Ml;1!QgCxfwW2qi&Q;- zwP&GJ{g^Pu(#oNprqI2!&6kPu>GT{m;UX{6{Vcb@kRH442bYmn7EM1S)jtKeY4|A* zu~e7Lfu{iH+<;T~mG%Y!Ue*gjh1h%uRL zF%yqtK~eqeNM1~Y0oU-ge)}hx26m9?DJ|&h239A#e@4rZt3+TG4e(C_`u%^(j=zDX z8}R+<3YuS)cSeh@mSig@ykJ143G^+1`vAWG|C|382PR~;a#J&$&$dmb$_?v&eE4`5 z(p{lt_WZ-eN7pc}F_=mb;r4hD z+Lg~x1~`xdI~(?nolWq64z@cT9v&~!{Yf$j|0kCW9~j0f%bDtSAj!gs1+GnJGt}T@ z8~7HeSp-2zI028Fj-Z789=@CJ{}g&C|F_@b^pG-_i_gF)e5bcGFjc_<&`SrlJpuce zzH4u9Cj;Eic4$R4kh?Ae@ss0vyzy?6I25(M-t#GNJZc~P36Q3YGBT8l#%pn$yA9UX z=%Xc*eeDK496byM_H#%`=ZD{ckNculZb`|M*g$X^@At_1bK|(CPB?w8Nx} zQRvjGJhzZxfi1jz0Vp&mqV$|2Ubmw>2H*00_Iq?Gw5G9jFxMTPWLJoODh)g0Er$#+h#&T#X{!-*i z!CyTj^z!)@bTXGknhmI#PlH;#PLOv&DQ8Te=`r(AGtL6t7S+_OnU{1>sFI-MR!USx z%k(DCYk1J~5ndP4Q%Z2C%!ARYwBQTfA7$D=6mkyNI(77Zy?kgzPIdL1SlCa$`ZmJu zH4g?mj~nzESK{F68CMwfl^XWO;K<#{dR7C?Io(i-zcc(UBFaj0IM6LV@ zA>P6TF7BfR_W9%w(3+iWsKW?N#I9r=Kk_u)pPKJpxs-tLO$Q4Ru3zr>Y3~2P`Qd-~ z%$0Q?%|g58$^MVIp?rP%hU*S`P++O`{Q;Z_hLEEmlr;CjJEtgNF*UjVX#en@I$P|x z&n6`4?NEW0oGY(0TZ+dIcCNx%|K{p@pW*v#R+CZTF^`)Z60_nHvJFVO)RX6zivpuo z3Jbr#=gKCv80!mlrZk_!++{Sl!2=H3lIO%zM=bYb>-`CVYaWI2EAOTzX1>ph2HMNX za!i-IqcuHTBfmrOFU;(v*BY$%U~Z=}%~`&0yoqD^LjKuju7yUivtT&a<0VOJ?g$NC zy~2v|-k~%IgjKxSkx9R=l1R%M|YK!Cj z+Qt}vKyT`J(Z^7q6TIcwcw5-VY((v?6X<)3T$~Z$y^7#?&+=L#TYQh(TjaWPN6d%a z$zZE>_ZI`MDP|-Q!Nj*6pDNv&7>}LST8Djl4@ECFj&_n3l#+TkcD^4`C^fuv*2mx~ znky7sIlf9bUs2+eFys3Cu?iu=vd;aij&Ojnp6LGeY+$uFHNpTC;U0qOjNjJnA*a_7 z%WiYJ2*J6aFXjn+xi8-N=~D@U$!s~R^ZB0X>Bxio?Guf0%Z@@FBQnW;Vro`15Xe&! z8T!g21$fUhB_-w0!)7_O{mOIc10K_4n!LH=h2r7WUNkPiuie2buA$7kOdzE8&V$g- zYntnj>&}@u0emCbc%O`Rs9twR`}W2Fw;RGlv1Y3a#e8dH{RNA0 z2eBjmkqQ-qPDtvvs|wO)&v8p7BX?e;unKb37mDee5}#zFMH z87{HIqff3EV;7tAH-lp+KEyth5kom0l*c=4v9c{j=`+4t*Zr>xd1~S$!AN%w=ddyk_5VXf= z?jO^7&Y!PY1BzM(tNn6R4wWqVrhT~;N*dlQ?9Krj#9*AM`58CR@oF!+dFN0MIq34z zt>Na{WwP2P5lZ58)NFr6=$>>>pgvEuy`xq_XQQbt4=Eqg*&eOxwQ2ZmM@4>F-&=Fwa>ywEpSB0mZinJ zPnhjGrq^~x6AVKPE|e&-aJ$_?WN%#%wKTpeCh2TXy_hJ`>`aQcjh@6l8oBYA=dlGL z&4gGeC(cogOv4oPk(8dxuO+_M0EXJb9tG`Oe%BJhPU2rd66X(Cm;zk!>7R~k)1VJ5 z41d!~=mBr%UM=uvRU(UJ3ozZajeizgTDj*nGt}H!e|dC4ia|nBq)pkBauR)%=kD*c$UgNCG#2D#$5=2*%ofvi=tjweLe3X9__+eG>uczcm zAT=9db7FfaR}w-Ncxx~=Lv1b-3S()gCDg7q6z+~1V_pzO=@L9R zdq39v6hg*&XMf?KM7>+TVAM(x>Q_FX04dWwczTILpPzDd9!Ov&c6>dzv#8NBVUYYZ zk;dbOj@Nz<=1wi@G+SyTrSx@w9OVT)mqIFYYi}tT^_roMxUdu}z>jmQlXSEv786y>Su!|o-rmAwnvQ$Fm2yf6k)GcLk&(r5KEN4ZA%L*7yS1})cNv68{ zYj9iTSsgv_mW_Ng3V)SLrbwo5=tf;|Jv85Zk>L=_Jt|?;jr7%G62%7GD$g_I7Sy z68DsQJEp)70PyBnwY;$0@hx&J*?4r@;40XF=0XqAH977(#IF$KzIV`r=+aU+5jLyN zJGTFkT(|lN1*j?h_J#Z9y5z0fG4tx#Z*yWf?fjGVzV9_po1!y3Uh43B z+x1Qq1W)^NxoH_li5O1bldPe$ zVaWK@CHehm-IH56M;5RZ2H#~Au0IxC+UDcPOZ~tCULiJ~ZIIcpI&oA|;63LTnNmu^ zeXC!n(ZmFXgwJlglL`mn6vL1r3@Bd6I6jM|@(PT0EKtrlk9@vb%Urs6^Q42BKEk~e z=!f4IbnHJ?P@0^3!r_VZ`%hp@3pkKfQd_!-aJH}~y&fveCoT?q>;QgM6L@#$&z!2n z!<$>7H_YbU$zbLTy|v}&Nhb@x@bVToMpgk1hUD zm`3YmkO5Yfg}8+jqaqpeE!ZtEpF$^+D-VVAzTcWkHfyzIaP`&X!uq}MPdB2W3<+uF z^@v&(mzC|{SI6t}w^r?l(RX2QfoZCA*-o`O>%8lndXbT*X0^bZYdPJ&c$ZWqj=O-E zG;2MbPS({7?Cb6KSU%74jKUy~$0rrZsNP~(Nivg&EDy6fA;jRzcUxz#>R{F=#8702 zBJK6<=<;B@J}r1qH~eizg^P<-5)D;RIr>EF0S$qwl$n^6Uu-1ryRq(gunz&R494b) zpt?D!$)*OZIzBn`5;;ifCRTKyzj5?6Qfm`N>!agg%H?XiJubOm)9)}^owA0WRf2@{ zLhD(4{b*QNd4~)#BAopv6ZY=*M{r2p_i01(mBxz&aI@~+2l4j%xf{hwNLv|k1%sZr zctHC5nEi{?hj$Pq%8R$ohl>dLmnzDsWxjnx3GE5G1LJ;=g%p~m=S>z951ybx?+4TJ?ka6zw}#mrD&>uo1lZ<-x<^&|&7jYBpg)mFsDHfK z&Ev%It`P?+Odx1qJMCy`ax7z|X^~V%nywcvm0x{VuVYN)@wv-@lF1{&D!Tx9UA=5D zU%_x*l}CxlKK~CHaj@C#b1KF0=ORUN9uXRq+*Yzz=piaa>LT|?vAsvn`v@~c^}w!h zyuaB4w6bkYu3VLd>Oso>m;8O@!>(X7D+YvKjlQ+!mi!gHkm^*N8!!Hs*Lyov+?cpu zZjXgT>VsN3!osCBu?#=fq&F}h8H=MMz8W-7!@>>L;80+0d*8eki}kDP@Le>gG+oY` zE%i~Nl2X@giQTT(?|624#VqeGd2PckC5D;u(T~+MYvScPZSsOqJc?v4ISS=vof-o2 z-MQt{+|iffZ*}Pha+K~*D+j_a$pCONH9`7!7j!+1Mf)?Lmu0p)|AWY_t|^aAsl0@W zbtNm6F6G{!Z-MQci2jHgz#ZINd9lyrqTh2#H z_pfh<7Fd)S&jXWmM*Phh+&7k%VR@{?j|w8tv)A(w@?PZ_JIrF>)F4Q&Hse7f?dG05ZXgHy0B)DdlTka&td1AkIWdaVJEAY9W@x6}LAX-G~B1 z_pIf{QY3(%NMMJSlN=K#FFBCAl@|!#7a@8m!L0v&>xBT=?WqDEOqI)u+yp>x*bX(y z#PNiZ#U9MI4fmORCe2v%AAIzt@D(Rp(459<^|NXSMIu$lcR%E)Jc7d&6avyYR=1}*(LoXS zR|iEH95mpnvtZXx;CFqan=cYuzGSRPK|$H?`C22*I6`h&N7msAh6?!Iqm>>Qo;figRcw?4_U2NAERKOM+5aoo|I zh0Yh8RoZ)|HdTmdcp4go_S=8!s5mP59I8*kX0d3*7PqMJ$!fh*zso11k|MMHXO_Kn zH-2ZHe#Y|2L(8Y-Y|HhbdcX_V3!SfsU-iw^_<3(Ara<>mhYhm_WMtubF@r|O(#zdj-t%E=m&9_@ziM+p*@HjbXyLX<*|jd0TtlfaI? z|CQMeZhd#i11sdar19;&ndn~mz!HKkwXum|R8)8Gs#a(u#DYesxodCF^Cc z5DVvzwM1m}(S)ops5xWQHYG3B*}o5FXZWmYfr=Fs>sT{hxnDOoz9n8Xp^sjB3v*U@ zNTK!YYEXlF-xtVgjcEwiioSi#;~Idl!w9;!#}tpdW=50q+sPR`*w3Nezg6!rn7#x_ zhiWg9NtwwVW_As`k_O1+u*kT&|7q< zm1c-yWT3pd!lV1=c|=BQ=g|_^r-WMDR8A?e^`)rW2;7J#0uc8m>_WEGHM5w~#&&g* zOlWx98^@Rc0mTl+6-;d>OsH6CD!1t9SiQ z+TH{dPU!Byqq{P-e%iDl#z%)Ac}xXML{0-Ku;)zZpqYuQh>hE1CVTsAp5VINzVv|0#h_!{h}~B%z(aZF`VT&DV|E1nLwCR zrPd*H7JGC zt{vb|LSK-9eTgs~q$_|dga;(!ES4I40kK%y%nXh{GSTOUb*TX?d5Do1ySW}fcQUCH zPdDh9D$2*)=J;iT%-=?uQL9P^cvKyK>T`Ie6ukzQgp5o}fdogIF5++$tOr&xm3i(d^(0^Q^5cDEYu2JN?JJPaJ~)py6Td@v=< z$2)rFM?~v055J0*63R)_Mt?=M<_*bWn_~p!=}`>-1vFoU?DVxsneEm}hkLhB=)pk= z3Aiz|%F^^;YHHYup0S96{O^^N*^5`bk!0OM5;g4u0}@23p8-ZEsSQB;@b)28)^Ku> zU}*bZ)VK74LVk$%Cpv~`Sku~VpUh-Df#qRX+7$u_;AFQFoGBsz0aob$s{o6K!%XC$ z8hAodv*%|3=3&^CDg&go+D8AI6&Xgu!o(V06De)~{0`7gasMyvloA$HgW8Qt&!0tdiX)E0Qx z7x)SV#8AueYXr>z02c)q;D!pXrFypy`Mf_Agw8KDI`jbe5Gnvh9L`lP44N2#&HOm0 z0uu^EvCz1(f%H3wmH)Ax7QmB!B9|#ZzW9HvhaZ3q$Oj$_jKOfS@wun|$Iblj94uWw z?@|s^aJjC}e&mh+kJJg$SJ78I30ORFk`g9h8DNn#+@k_&FXFfA9@aqIJDH3D_|g%s z?#l)R0)bEo35(f`;_1GRABHiS8?X|^X0~I;&?k9kwttpBjC}`;)DH$O0e~@oh5?lo zePTR4l3jn`^-(I~oiiySMGgQDr6%i5PDZ_Io zc;RHm@vB%YOplcX4R516v@!=yDKS{2Ulp1R#>y7>tFU9f`Lh24p!4pMLGTOAJ0bIE z5ja5WZDv#UU&z9_`G3+d5(@r&bjeHPD9ATbsriZ@d!&7HbtY`y*!VXG>2;7d|DE@s zp4UVH0UVHznqjXR^aFk}gH2N4Km=yR-%2saPyWA;CkVc|$5SC7=--rKq}?8WtX#bc zS#xEc8z<=JX1jfIy?dvT+=VAYzCvNn68)p=)3?s99{i(S*(-DX0h2~Go+D(>53`}) z)PdX~Cy+aI|4;5vkKN^q)jhyFWTz3OO@92f`57y$?uCS+;ylr=la<3a|BKU=cKiC% z3f@!n$P$6nyN11!=x@Qc650oSLa?niLq=Larde*dv2f|=>7VpxRhC5}GIero_R>-2 z+hLhilTD68&gT9)*%R&-$D%f)9j!Wy(umARl%pWbgNa2S7QD5@Mh6mGRB(A$nwdy7c*)mCy zr>MLi4Wy?xd$D!cPRmB&Y07UjOTMb$T&Ey#1!~!QnCl``Vm15>RUn9Dksu9rD*+Pc zm*Abr1N-SNKx0{Cs9~c4xPe!V69GZ#rv4FiP~s8s3=rtpVAe@SH&p|NcOndje-jV< zr4C0W_M+SJ|4p6~&0yrpT z(bf4mzshX%VRHaH8ey$PqWf7loo_HyAO>ADFkER{aXFp8M7CF`*Zvm%li2`*BL9w} z`JHUDBUudXp~?fgu5Q+!heJxj`b(6}mr7t=!qzo9rANsSOE)dUKV%n&m;PUM%mG@&|Bz@#vNXO? z|B`58Fu=;RUG>RJ020_*E2E$PVYx(^-_9wMKG4z`kDp|P^HgNhK16sjyvFa^k^1oY z{{O$rF(!7c)Eg*E!+M+}%B;$h{vI$5oIl?&W8K@m`=7foMUUX3-8>G8nFqY)$t7$v z@>Nmb@|Oqn2EZ2rRpEaSwmlGu3+?7|P~Hz(M@FHBKj)-Guc@bBfEgJr!!W}82Vql5 z@>~piy4y*k;yR>CbJexa^R3?5aU}mokDzJ@=n;xLBD4O%$~;S9dvhGRg0MQ8U2Y@~ z0VGY?BJ-0mz(GU`YqK3n%jpQrCI|gP=9!6+)SE%y$j^?yPab#`u@y^AI&T2U{%{Hr z&_ALH4K&f;CECEp9A|vmdqwV$jo6$4yh1pg1L>bzob~>;Zsz-##S3J8T@u&{tKH8=Xz$Qq}jms+OMD!BroMDlZMK~50T%*p~!Pz zQaFfg98OsGrt|Ejx~q`+{WJMtq|@Pml>X~KZK-z|rpjbS%gLDjbTbc9n3fB}zbCBe z&__ew=&3SSDG>k%YZGb?)PH9q?Qb6k?|S=vH8!4nu@o0n(^;;N+gu>jp^)b%bH;Py zqD}uw3xcV{T=wq={F48*?y< zRv(>wiRpF)FI}n{*r)@@xqAC#h49(OrtXbG4$!nExpGHnz=RW)RsSJ)fZ_afUMu_3 zXd8$JS61;uu2g_Z-_AxNPX6)f0ampdT_;Z_@oBM4LOI6i4r`p^Z6m6-(f8AZq4Gn&)$}EMx25Xp3~Rq@0irlX4#>7)YPk`i zJ?K{my0)ZYO!`wAfJ(Na7`^@X#gSYc_LZfe&Kyo*b;&hLs-ceRexNEPu3dKy9>pR5{@`l@9|KBjva}^Kh#aLkVPeKf0&$ccEX##L|Wbm*CSf`5(scDk#5XsZ>H zNSbXEBTRdvH-F}qFb@xi&B!{_{*gX;BEdm1CWK!UDJ2Z4Fr_y&kaF2f7qeA;aK!u= z9$logy4@FTa1pmVi}(E1d47^udfO2gQ`k=Br3!#Y6PvjD{qtTHOOiZWgaFG>Z$+~* zGI*vaJSr4l&8fQz$-!bl$%oQrPO}ZadQ!*InV!QzK0|y*k=hJuQ|0S1yioFBWertmt5r(EyN6KQ*G3Tte#wp_K z5^$kPRDYdT8V()OKAqE2PvsOb9OcT*V`xj7j;NF|1NLx=xhnWS^YYgTj+}gTC2_*) z#{*j>D|U(Id!UV{bwG96@*m=OltcE)YSAo8lQifg1Ym<`jHry(0t1Bu{olO(uYsD4 z`ny!53`p^N7238}&TEaJ)9_i%jHUf{8mcTAhoLN32&e!BubrIcaJw%vHw?@uJ1`|J zPkOWmz?2C6zWDFQ)N%hxfsortg3bx4S5JioZFdITRCcR(H-^lh_ z#`Skc#ggragUqo4U_g1py^79tJ^(B2LJ$uc27$II*NM9+4;CbQH^Dp(b9R&@Z|5rN z+L)E!-a6j5EzyT7RBA7BRlCq@CI>wf&#fg+3{b2u>oVEz;x^c`dTkGVN}9{c4@bx3 zINz%DR-8^c*L19#{|ku8E9TO-oLd9e%@@8~eh$k<0G>&=?fN%c6FD^7BUI$%m9R>k zZJg`f^$Ah3L-D^Ov~NaYjeM+^6^~5(tEmhZ)spgULU)u&-i%;5wA9Ts#!C<1q2WxI zu`*9I>QYpbE*Puwdo}!exG(n@ii7-PX(s5Vrf}L5KHyH|roGF<#5zYhYVhz77_IIo zeNNoDrzZh8YWR7GYu6exPKXOa@(_dYJ5cTd~t2dnpX^K-G@?@iTL^eupr% zX6vuz_y~rF;g@QR9S1XZmubK#RsZ5rBi4Dn3l~nDh#Nc6_y--kA~I>H9F{2;Z-Pds7*Lkq z<=O*4HaRWzdA$pnRw;jB%`6@;C(Om|HKx5WZ~?)s;im})u&sLc;Y}(aMil_c{#j>f zfp=3AN+yyt^nF9~#J#S`93gDzae8o2T@xD6v|HeCpwZm#V<{rru@625^Kjr5if&Lk zJ5U@wgul_d{{-88Il+rAO##L}Ni$bE-%QDa>c=U89#Qwb4gLic0tM17-RRuz z1cycAyRX`uCJ?E+$Q9FgXmU8Q(E@HzIx$sV03t3E2p(N?ITC|$n~Kv~a6!cu7QgA~?IJVBq%DU@MduuO()OX#a;1coAqkx^2(Tc3Cp{+42~cUsC$O5;%n+ z{ayHTH_^Rwc2cXozDHkdo&o|x&#kwplWOs98ju;6@Eva3?R~!dTs*)!%!08pdFiay z@JLGBRV!i>XEiNVo-o%{{>Qq`sW00d6j|pFj&SQ?aD55f0yZ_8N?pmMb1QrR-aamq zl6Hzh6l$)Jw$1e0YJES;918W!Q$0?uN1Dgqtm^U;U!+{dp}X0wj;o{Cz)FkcP1&?{ zNZkzC3l|Q5Y;=eJpCzeTpa9nDd#2CV zp^2r%|1s$p3l*;>y&o{?aT;ctt?pleaI1HV+k@`);T$E~Vxt2Jz&dsT?l>AgK0M%d zHqIIqiQ!EclaZ->Y__-esZNSr~*7OC~pLCx&-F8hm|8tCX>{!HJku{ zpeES7=6B4e6rOSC(7nqNN;erG)-!8oAkdy0?ST*XlTqm2t1UK7yrDMJ=^M@@&6NC% z=ri0LY9G{Z%^?G! zw&qkxmdYOw1&Z2>2lGM=>>~srnLs^1c(6BTb?YjW6Es&vtOXl^^Y;F>Zmh{zU zKxDK!w?QM42gj0$AlvSazD;w#r-8?&%SXt)>98n#!dUAGPn=UIG-ke?F3m#VBI*i2 zcsXpJD^VL998%5zZO+aeYHG4+^uGo|%x0Nl%Xb&+neQ+6vOZ&qgcAz^9@yDx+k4S% z-M%OaSQHfHclwKUR@52|RTUSD^)|PF)Uw?1Oc(eT2Ee7;V`vm?F`_7|0z6BE{>>|70IB-j$pVZxMlB0UFyad448Ujrb>_NC z)M_Dffn{Zk$V8kzW^)zBf*ln)mjCO3M%)`kp9FR|KfkFpBf0)eii%B`eO&2?i(`0x z1Bg|XALs0f~Ji`S76=tKk8di*Ep~u zywqYs7v)fugW?6^On%M36mt`dUAYk@hc6-0-<@h;9_nI3AK_w1{kD7hl>15C%u%i^+|U|ABPtaG&YzY+lH z>z_KCrAO=WbITiVON52k?Bn_B0mpPt{a${4{#Bq4&u4QGs-SkjXaqJkw%iu0Dy&44DqTL6s!G=vwRV{v(CbwE+Z)1!GY{r*pv->KAIMxt+ z&q|`rD2IlD;cdOuUw{P-4P9`KDsZO&^pHR!AP6QCNnQL%HU|&r^{(V887;1j+7Pxk zfErl_3OI<=WzvrQ$*e_R2dx`8K6drc>R4TD!c|ts{&P|_8>%360R(=Ox=IzuAVMHb z{6L3zl$Ykt58zehwtMm{rqhyea2)i7z={Vn13CSgy*g0NCcr_^Q9$NX$yE@LlcpRz zBk}_7RiG|VST)hJS-uDY9@VD5(5HpA50-OwmYC0uWl` zc{~-MRS8|P!=Ve_)U=34i#byG;tA-G;OvHf-ixyarFn{4r{0unUPAA+A?ya z5}(XGhf=uMQ(cZZ6hY%HZZ1GcLg62@h!0fRlH%9evki|l;LQ+pOa}%H7T{dqCgP}E zsQ)AUXI&>$DbZK2^eGwfBqUy&n=8c!I_^htV|Y3axidhdo(0JGD9Zt1vH%6!ChR;s z7Wi8QwtycZUk1M~kSj=MGY-f7)Lu8HrjVt8m;A|G=H9bePxW9*0mum@Y;Zxn2JnAk zgjRii>kjE^2xmu=x&|vl^D)v5qk_X_G@Z{RtEQ{v<;2mUDAY$b$+gbACe z6T)weSlT@THSYM;UAZgN90Pp5>do&5%X}tz&F^uv2%!F?kUQ(@(OTa*aesxMdlLt$ zd{!^4w6xo95dHx4&t`9;0P{-o)wtSh_0z{!l+^ZUOtI3b3g2pluf@l{O!N;zdaA2m(@y@Ja8y>&hu(y&3suGn8(R()4!Vc4<=qzU z?f@D%BF@&2=tKA22Pn70vJwBUyYl(DSmVW^-06mRknoB0zUh>|aPVqcXR79Cm(x}y z$iQuM2Pwp(_C?|0@=5=l*GO8=biEDBcTIK45TGKFNXg4bO3#wmV;>(6iE&aM~hWvUD11>i+s(2NYA5M*70d_GZ7k>NoW^TY^J?A<(=zHk$Zr z{ps#`()jjzW@WV4p1wHo$NfVO{_`#KPYlf`ZSvt#tzZx~vCGq!TxF8S!|zGmRjkuV z^Sv^^0jugn?Ozyb{tsRR$uJ8MXc+pja!ME;gBo-@h!S z*@VDOGiVvQx?ZIfSvYK*S%sV6F8`&P67v;JeAr)4BpI=26JGbGQA zG>+Hm{O~*7@}IHWzwLGhKaaS5dA3I1)>H5pe^>{KSj^U1rh(TkZf@8YJam}VCU7S{H1acO zc2=gO_H4F!Gi_cGQ*JK@g$R!24n_W+l{~>m%FHyWx89vBF0e|VWw+2?o39QCs}DB6 z*v_=ynjAo(2$s#4(}Peee9%G$YVu;GN|k+=abx(J+0K-ZsymOb4FoFrBK5uirL}^3 z7^-~p6PWdobEO{P0ZCCip#IQa;x&~k*8k)mM%t&^t16iPeV!UjNAV-c-JwiV?tu!3 zOeRUGGzih&PUh=e-zaRqgp5r94Oj75|Cq1v5Neep=C~3y(ur4KSHvZ8IjA5mD`oN{ z{@2&#LF4;C21GR#aJIJS)Ms@YQ=0Xhbya0DGgl;*PN!hVBp0iICLLpc3@t;B9*}ul zA4(t^j*M*BsdoEo!8!t;PN$URkJ3_+@zAh_u_rLG_5w+GHf({|YZMMvJcD|7CUo#0 zov9Ctq4mM{Qu}L9Rwra>$~;%!{+G;#lcAii^207~aoYC}ov2I|#^nHUNiCcI#?O@n zx6ENb@D~}(dP4x~&&9g~OPq24*L zOg87;?;4As)RU&LLk{AZ`7*U~VuT&~&U)q@*>Z8{rtuxDNy~F`#TM%`aCNF%<8?-z zmQ~YOEBm~yMOLbmd9w_{SyM@5vlM19j4!T1a#rLH$oKXaj%-CDZ z1-`0%l!#Q$WS)z_EtI;T_5hEWCvaDV5Zq4l=lH~+9+<^40MzQCkT>pSU` zwWBTH!1v1nl{}Vf8x9Y8`f2PTOPd5UjN5}&a3X;{-4>I> zmZb*%&)~1T_$B=q68D&j9=uHsjJCu~uqXB&cxpGtUB_f1HvCX;j|%w`Utx zD(JWY8vZC*^4~pNIb5j=p`lbT{f5zj_W`>;DJ41OjZl{!3e^zg(mjJ6L}s}W`fRx> zzbo{}W=UEIDf6=2kx>R^xjiA*VYtD3eYo~%bYU1w29VEStgfig;Apqr1-D%t9EqBm8HDBZg4ddGR z=kdB50ux4|vf&iD%paz8JA=P4XyXXy#uNJ~QkMmB$NTVX`f@UX`4E+L}$TVBM8 z-}bj;&cayXUK}A(*uLBy;UqV@e%C<0bFIvEY;m%Aw7LCruih(h^+Vn!eLJ|oJz&E8Ab)(li^ze zUsG}Bx28ex9Am;^s4>COh=xgdb0i4Fq9>vAamNL94mZU3N@hO8fD9_zk`f#Y2^?8* z`Lyc&Hi6lOAy+EFPfPhJv&A>Q=Ic+c`x{3DUFEjT+**IwXDRlUe=$4( zr)SPLoEowRW$I?8Q`#&Oi>U&qoitvvnB4lK2e0vG-3;em7XR7zMZ3*GN6d;qZ)Td#Y_M zpx=NQ3amst*wU7iM^m%6{F|k7iW!e|aC(0$H|Khg*(JztudfF*VpC}}WaJ)6;p@KK z`($y3PIqTW090lMt=FtLd3b7c2zxQn;WMk!3H1#sHrRIc_eKA_8`e0pKC)lOt$1q1 zV5i{L@;F6Rt}*wOT5@hDTdmSf?2n^$I0l>Dt;b2>R{jK7ffHF@^|4@Fw~`XY(qzZB zevMk<1{PEJduMU)CQ=-WEQh*7a@v%lO9Oa1O`*J2qa=T=8WI1xelArjlDqxa?V zF&0Pnk6eN_h{9PRlTFdPKfgx-bK*!(cVgc~A~mRYGg1^9Z9UO+TK70$t@r8Wl(~;b z;AF8q!)9m9ev?`!SU;MD?RX&^a4Zor3%-&A7kzT7crjrT^-2CvQtsTZucIXegY0+V z{c!rlPJU}G>CYruaE)2l!-ZkwXCZjBXL2lgl1!;=FWAE^j~Wu8^G#>-Dvw6{NFJGS z<_eQ@^2^&S-w3O#<0Y@beBPB0Ddybedn?q zjGXoR(KO`Cf6nupsn^5Jq;Zj%a}J&R+2jp&O;^9H)-2GYNl#YVG4he0&u;Nmb56(D$k-$+_2h8 z^Z-fqE7~brAGvn(q4H_}T?Nx@6m7UUD&%Cikr$qp`Qu0lbe9Ah#8R5v_)fgo{qRZ@ zHIujm)UIIk6f^}+Hz_GxAK(q`y$n61s}}1TpOYxJ-{Zt`iaLJEQj*Z`jUZ0CHb+l| zKW7l#IQ>DjITWw_7~5WJ5Rw-7VF;f@q6YR{aHI9;1uik(LLDqBRA5c<$z{%L>Wb}Z z9uzOdK&OgxH)FK-K`$a`w#G$|TS4d99purzv#c>^?Np@bFBMxDX>mcp0%^LOi0=(_ z18J($KjX zI2Ytyv0Z&*5=$Gdfv|%hBh|9GKGJ6HOHnbZI;F*auVsH-U3w{jH7>1EFOan zx7at4H`C}>KqdPU8;rvkWZ(>xgks^W4=9i_Jz$K47oVj%iM+qv+zB5yUoa*&QRtg? z%acYJd8-(f89|1xU$mUYlOfrNOcMTK&UOhNOSV>CD285Mkm?YMLJ|ZO>bJMqhyGA3PzUKy`>Jue~J0FL~k9yz5A6A0qoMX%yyvEDn1LgF`O<8Bmu(1aGZ4cv9={Coe*-)E`sIZ^)lPT+JQdj_O59Ck)!RE7aMli3LG+yQh-yv-#!gRF4YR4_fcj)&1q%L`Y3*it)Ehmb4mz z2}6n)lz;B;8hUWBo3NjujrVh#EKHxZd-W3*U=88WkpCo93Y$I;5AaPRm8 z&SOU7J^FurvDyl{Wo#am!yA5*h!=#;Z-X1d9a`IJRrt=|XUj2DG=0!<&z zy@yfb0(1qMgK5p~-muCUWdm`0nkA1RI9L4JL6_4Rg!Y-x8Qa1GMDy;E=XVdi0LC>II^~zBax2EfxkJ2J6|7tr~(=& zAUPp);_E#aE7f_g5>u8B8lFeEvRskgwA?+1b&*wjbMv9qx3gjKayfh)9=%Xql=EzO4=u`QGdZJM0o6DV zHY8H4-I|Lw=-7&+-7ZNEp3m7g@{t|`4lP8UX=NW9Zcf5HY7hB;vlx$7*$Lvw!i$+G zm2mVcg8ef#`~?fkn!UFNQW`Agv-!u3cgBdQmB5*Y8KtLG=jo`^5BA4Bn`_aZu1`LB zWsB8+lwhM1Ug}2Ma-jL(F%-r6>Y1Cc7nejsI{orMrAIuJBU6nRhG)AkYl8a++2#Fo z?J47UDjcHl^^P{%Rl*%bISVyITsgKkK7rNbl?LGHSL8Pw;tva=86@mh(KD zGjQtAQg}`rlHB^uk-KK__b*o@0#hI9$fg%U>vk#+k1_Ud_qq~R>nzmd1%d}P`I@;^ zyENs-FK-d|CgHw+{((7iyzcSb=0g$YtYuv#=9@i{Q<9{p6=&zmJ2s;OKL84cjzrv( z_!IL`Zu88Irod=+w<9z7y_Q($=xD6i04XIK1WpCI6~A9;#c?9ma&^@=;E&Cyn90Y( zxClIJe$74rm?Pw1^aKRLzN7*ovSX?%Dr_aqP7qOF%>8WQeKIsD5Y}dO7SopkRSedQ z18$2Zl6Fm;;b11pD>S@!LO2Et5#-ml_Ixuo{9twYwGIp9h_~cq>oB*> zCM{Jz0{v6}PEd^qN<%jXOdxPLsakNXbocg=$(4$8B=cCqOBm31dCI+K`Z@U~vbEfL z4ID|WS#R*8$?={al8(>rMhHY@=-VsZ^UCbAzFk#LQDTwvq7lyP^(Xg<#&P2j?$BXC z?fkua>eXUu70zVqinuXZl5liK*5W`c8dNfZ6hxAIhfvU(UKh`{xck?6JRRG$BV-RK z=!nZ_U^a#yGheyx*f-7BD%&^4y1nJ2jw(c3>b)L~r6nDTzgMcVoc-o=E{oi`fb#M5 z#`BrlB<-Vuaw)nF>E*eQ4(O618cdFKGLIFW$_ujvk@cc!j(GWGi`%RB$#hp1!l?EE zW;nSkW{Z}o!1Cjn|2exssYdV?234kJSCHfReop?kYVAqUOS3zF*bx)?<_~@jM|2Ia zGO9I%z~B8^2;2}&Qmx#}X=s_xUN5PwCHc}G&7?yZMJ44|P^1LsW#e+gFp2<_NusLQ z`tWqWv0k#^_~(VU+#wR1UMH5+TQnf2Y>xJ2iQ<^I!GyvjmeG(!p3m781qmxQ!!`A5 zG*Om$5nJ!dGRqGkr1AmNnbN?pg?VpooDM&v;Lr6;aZ(3SU%}bB!+xO&4Z1mBWZ-MF zyxx*+58({7e6=R$2xE z5Caod#EWDQo?C?YabJ!T20}2wN}@5wG2JkXmS2v(hODM*he9d3nJUZ*6j_ET-7K*Xa*p> zq4`H~*_`;#M)vGA8))0KxKSxqFTo}Ol0Qjoa_f) zjqJxk3@ezrtaUx8e9}gvs*n;f&{>j_MYoqRLMLlfViR&!ZU{Q5BF{%xCWF5!iYgdG z8J%0LIMP^frJMBWO>oBM4bri(K(vJMT;g)?q48o;Q9h0vob?bo{nw)HH?+}g(=Qd_ z#;lqJ2?v&fMZqc5)3qgk+QO$T;qKg~cs^dxV#}xNGS@*8&xoC(k8tFhH7ZrAC z=wFc)Ch|OfWBW20s-pwmR^Px!xUwQ;{n~A?)?52(k0*C_ee*pEVR+{8mOO(I&Ghr% zJn@X{8#^OLT)Hv&w}Rg5eLa`E8T%ia91nOiA+$*FNuaX#tOOF#;Mf3x6E>YFaB<)^ z5F&x1rIHdn*YEQ&^jdGqsovx4O8=_O{FP=>I9TLYpzX)_f%jfJdWi-*z z`IRByzfXbFU1O}#FO-wT7W>ZPt8VtG@Mt<<<#N&H$fNi)p}+)^p$FM~IiV?Wi~@mP zXcRS5{(|7iU_#NEz^YsZ1!-=^GMO~=)PO(Jou$-$$4nDp~y#kko8 zYbfL^bmmvQd_)D(LWQrn#nxo@Q}LY*b~`);n?iNIiB}ujEhRu6x3dACv@dr)7Lwgh z=PisP`0_%Pyw8kFiFJ<}v~Um^`Fh&jJn%)p@d7Ujvh7MheBoP9xJ@PL_4vW$9Qo}N zHZwcz2z=$Y4Z=pd%G?b%vQ04xpmHJSAzTmDjj0P|(cdfuuqmIbM7((Dc1?K|`J?!Y zCU>H7ww96{di9b-YCifp%Px0{l!a$Dm0Ukvnz__Dtwx747JoKGZwBYX2eMr570Fm2 zaR{`wAKlSrh@`qYK}gm7O8F|}XUy9ZVAb@O(FJB8X*hTVvV}_`LL=!j#9p#cb?CLArXmam34wHQ*y0 z$bOt-wi5_>bHCAQ^@Cf;=BttPpc~J{x=!f-c9Kqd{f#`y`gbuMv!}IPCiCT7{-?CO z-CmI!>apA@J01D6r0CEP$gSJ8zz=`0(M$F`YG63!T9Vd+<3zj=qy2k{U$!4U4T8Z)}g3;D+N%H;#uIXFcrGmUC6_ z@jc~c5n|sp+dPHkZz@!3A|WO*N%nsGEG)uq!Xy0S3k_dv3rHdD*N36~qLzRIQz+~u z7)XN*P62gN5t~x6$}bL^?bV4q8RyzR)zv~XrLADny=GEB4D=ref|-ZE3KcWg3j)@r zT^kcg(t{x}3Ux~a@NV62JZ78SLdkhqF&q@KD^b^^<6g?7^>>}Q8Qnprb4x^}(lBSN zr!yzwt)C~ihmzI+ya7j+CL>9FOn%t+2FZ|H^*G>!lobD_Lh8VG zyqNNFI5#?1oZ>gH0S!*2Xi9=-S2idp+pDm{Or=Gx(`iJD6(#i9$cG9gEnRAEo-lR;=odzY4xqFH{OvP+=IpTEhjSh=pS^n?6nC;m5q)k!~V_9duU}QBmU0?$7xUruAUhB>{5(^~x*Osi5Mw379X^ zq-0AEcJI;Xf+}l=Eka+053w3VMA+~5C9S87Ap}zg(|;573m#P1rE zpSJc*d)Zx^o-tm`dCX7~1NN53np+M0$MfgTlzqu5vBrcxuPx_EfzdsBJ-hVSsOOK7 zI7--{&$E=}oUrl`+1N7Sl!I_x%h~lkQnTow_}@RS?c2h-6BB=Ko91;4X&wQ!PsDHr z$PS*2!__ahMhEt?l}suym#9b1Rk_7m^e)hW1UYJ8qT#;+hrw#aYM7=aMkii6UPm!MGuH>qmsMcN8KL;2bb1eD?aD2oH}H75i`FzVmb96W z#1*&PVt9BIQbW8@HI~lO=<9Ac!ErDtwAS)oS3`>u%UKlKEi&&sl!dw_7QMQFj43kI z8wWXn@lvP)3#)qPfc;^rKb7Ane=lwlfutdO{W-}+^fz5W5G460XP`tlLt(Zii(WbP zk<*5dV6)p5X(W7sB!vcZc7c*U(qC#c7CJgfA2~4rc$h4PR*3kdY&-d$@Y(tgvw=cx zu6bu%kd0&-;Z=Om=qgiFBW{q}3*i_w7Qw{V5I5dvcs;V;r_&RZSiH+QZJA>SSmrl= z$>=U%L_V6e!N9|!V?Y1!@|}W;uULtnf3!ai6Gc8P5zJ+j=YiH{%Nuedoxz+ALd)A8 zop4N&;L8Yjz=2hpG3#1v-Tqa$DvLLWUN2tAiEPR^Q(!2vS=CoCqrg{>ON4JX3G0#B z&$$of#f=p|tl1rS(4)8@yNlLrDRjJAvnGgEctlM**n6+vFzWc1bfvVJt@?J|Rf= z^eM0s#!A|^&FD-}19rE+fV%W&Bl{&eO&(~4tH0p3zzU%!jUXu9Y4E{KT1dC47mb|* zORd(zHz_Ge(=$*qgoq2<`vVd`xDeKU2@fTnu`7I3w#K-5H}S-$7q})Op;z&iUY<$_ zgHIwv&>JGkdLtr9{7dt_OHjN~p=%bgm!Q}n(DNOYv2Sxq_68z)^U{23sp!=EVt

NA`YS!Q6)5QWeY!05Oh8?bPw*Fs4Kn0CrDoH(0gMb z-TJMtDE>P0__MF5Xy)52IpWrm__#^U_yon&S-Od?rnl>8MKFK^+9E;6z@OS+mp`$? z*v@0yK%G;1vi&P>+|tsjVLtm?C9bWmJ|2)WlmrNO2nE`ixNaCbv;y~X+?VcH`w<(q zsUc`8==rR;8kx0Z*V20;#^PKX#h?6zYIztkTKyQDWSur~$RkH?dxG80^XLeC5kzBn z#-Td3X1N$`rD?6u>t`y9J}llJ2t#I0+m&w-lonso9`j`9q{jRIve?S794_{iky~sy zr(mAl3$9G)hjV>%&&i~I;A!FE$qAmhc@m5M_3R-WsATo>$S^(kQ$NAwcJ&1 zO(7@mSrCJP?t%gb&w0{YuSh`(akqifmFdsTHi=Tgn6~@{C(ikKpCu)$PA&$m;!6_R z&#Diy>c>!{z36j?uvJw9U(%WNr91^O3ssF{i>dn?m>#E6MrgtQ zedE=5!lQFIvbhpJVBbBc=y3J|5F}k@?h9- zW?=jPy;891of>wUl!d#ntgU@Wn8!iz#V;)~D&rRANIv$Nks=o4A z4wuX0p0Oz+s#uiCoM?cqB@Ol{b)4GUg`h&A-h$D3HC;;Ojl+Ah>20AzhExgwTnd}X6z@04-5vWo9K50xU?v8l2dT8v* zUHYjvf7gJHAdregLrpZ|Om6eJ|J#KA6%aPh)_RbKYkrFVUlG{L~Gt+R~313^qJD>yrf!$qd=fW!e|5lzxp1Ei*Qj zG0ei1aykfnDvpA#!#dX#aP4{XJsNYN8^&^Y>pl_(nd*_QwTbcdkI9jmb5)Yb48(Z$ z#-%buA=<*P!BU;~_tq2I>FSqP>WNVQ+Ou8b-94S_yrR<76d3kMa_t-Lg$&`Z#|mTn zXY>V3e^N*3F%m~%cnNl+Gj5!PYzef74M%9cThVC0lZrw4yl(^cD10u5Tq3iT+LM)s zh1ykSC6;4byQ@qj+$`EJD3Xggj92?3_&}g{&afdzr9v#T{98lB?j0d3bX~}zUr(>q zgA5iE(H>r~iJss9W@M#m*%C@V3EoPCYT;^FYpJg@NhZK#T&35tGP~odH%qj5W1vn@ zs>K5tWc^W=dYi~xcoa1cYurMMC}xjj<#$O1Y=uS^siId+W?>PM=f2ahh^@x{?x${A zvm+k5bd*?#A95#$_~ws*n?~m;AyX7UHv#sGt<;J;V^dChd`&MsaanD>u}CQeG!0wc zJv!mF?QSSkkAZ5)RTB&0KNPHf&6P6HA%AqiY+x-P@8!P~r19i6jb!ueoTDcFd}C>Rhus5tx#Mn-rq(K);a( zla$ZsFJ06*t_N;$9bESknC3-F`EOv9o7|zruyQ(_J{Vy;&K+eW6@IVx5q-k``YUr4 z5;?b2u~uyhYFUm$=O)Wgx$W(T_rFJxP4goNcFT0@qG%FdDC|*!cMz~g@Nyej5L}HpMGfAm-wA!O=lPdU%CrT{1(tI$S*T7%}5UZv&-@#Pi ze2=n^SOj6j(+Gs$vsvTiIDBu9Afz_7Bdz+cn$FHqn&025HWGdq%%C^O6^I}U$`8W& z4pG!K`}*|g%S-)~v8~D4=XqGQdcD|ERRLOIoda^zDwB^q0QxP_mlHkHE-EAi#;rs#mt%TypJ@uFdiq9Gd$SW*Ec

F&vp%mHpy;LbYA$zNG3n>6Yp^ny>5nGcMCa% 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 "_Host-auth-myapp".equals(cookie.getName())) + .findFirst() + .orElse(null); + + if (firstPartyCookie != null) { + // Also handles refresh automatically, if available. + final String accessToken = webTokenClient.getAccessToken(firstPartyCookie.getValue()); + + // Use the new accessToken in an GET call, or create a Subject with the token principal. + + final Subject subject = new Subject(); + subject.getPrincipals().add(new AuthorizationTokenPrincipal(AuthenticationUtil.AUTHORIZATION_HEADER, + AuthenticationUtil.CHALLENGE_TYPE_BEARER + + " " + accessToken)); + subject.getPublicCredentials().add( + new AuthorizationToken(AuthenticationUtil.CHALLENGE_TYPE_BEARER, accessToken, + Collections.singletonList( + URI.create(request.getRequestURI()).getHost()))); + subject.getPublicCredentials().add(AuthMethod.TOKEN); + + // Create the Subject, then use it to make authenticated calls. + Subject.doAs(subject, ...); + } + } +} +``` + +## OpenID Connect + +Browser based applications use the Authorization Code flow to authenticate users to the OpenId Provider (OIdP). A good example of how that works is shown at [Medium.com](https://darutk.medium.com/diagrams-of-all-the-openid-connect-flows-6968e3990660#c027), with `openid` included in the `scope` parameter. + +Once that flow succeeds, the OpenID Connect Client (the application) will have an Access Token (and Refresh Token) to use to make authenticated calls on behalf of the user to an API, such as Cavern or Skaha. + +As this flow is inefficient to use each time a request is made, the Access Token and Refresh Tokens are stored for the user, and retrieved when an authenticated call is necessary. Access Tokens cannot be securely stored in the browser however, so a secure way of doing it is to implement the [Backend For Frontend (BFF)](#bff-pattern) pattern. + +### Login + +Login is supplied by the OpenID Connect Provider, and the endpoint can be looked up using the JSON document at the `.well-known/openid-configuration` endpoint, and looking up the `authorization_endpoint` key. To start the Authorization Code flow, redirect the user to the `authorization_endpoint` with the following `properties`: + +| Property | Value | +| ------- |---------------------------------------------------| +| `scope` | `openid profile offline_access` | +| `redirect_uri` | `https://example.com/myapplication/oidc-callback` | +| `response_type` | `code` | +| `client_id` | `myclient_identifier` | + + +**Example**: + +*https[]()://example-oidc.com/authorize?client_id=asfaslkfjlkj3-asdfdsdflkj&scope=openid%20profile%20offline_access&response_type=code&redirect_uri=https%3A%2F%2Fexample.com%2Fmyapplication%2Foidc-callback* + + +If successful, this will call the URL at `redirect_uri` with a `code` parameter, containing a very short lived string value: + +*https[]()://example.com/myapplication/oidc-callback?code=sdfue887hdyr* + +The `redirect_uri` endpoint can then pull the `code` query parameter, and use the `token_endpoint` from the `.well-known/openid-configuration` endpoint to exchange that `code` for tokens. In order to do that, the client must authenticate with the same `client_id` used in the Login, as well as the `client_secret`, and POST the values. The `client_secret` is typically generated by the client on registration. Code below +is taken from the `Client` class and happens internally. + +**Example**: +```java +final String codeFromCallbackURI = request.getParameter("code"); +final ClientID clientID = new ClientID(this.clientID); +final Secret clientSecret = new Secret(this.clientSecret); +final AuthorizationCodeGrant codeGrant = new AuthorizationCodeGrant(codeFromCallbackURI); + +// Basic Authentication to obtain a Token from the IAM service. +final ClientAuthentication clientAuth = new ClientSecretBasic(clientID, clientSecret); + +final URI tokenEndpoint = URI.create(Client.getTokenEndpoint().toExternalForm()); +final TokenRequest tokenRequest = new TokenRequest(tokenEndpoint, clientAuth, codeGrant); + +// Send the request for the token... +final TokenResponse tokenResponse = sendTokenRequest(tokenRequest); +final AccessTokenResponse tokenSuccessResponse = tokenResponse.toSuccessResponse(); + +// We now have the Assets (Access Token, Refresh Token, and Expiry Time of Access Token) +final Assets assets = new Assets(new JSONObject(tokenSuccessResponse.toJSONObject().toJSONString())); +``` + +Document returned from the `TokenRequest`: +```json +{ + "access_token": "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", + "expires_in": 3600, + "refresh_token": "IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", + + "id_token": "asklIILLdnsf9sdjsdfhkjhjh" // Not actually used, but there if needed. +} +``` + +The application now has what it needs to make authenticated calls to the API(s). Let's look at how they're stored and used with the [BFF Pattern](#bff-pattern). + +## BFF Pattern + +The UI applications use the Backend For Frontend (BFF) pattern to securely store tokens in a server-side cache, and can only be retrieved with an encrypted, HTTP-Only, and Secure, first-party cookie from the browser. First-party cookies are obtained from a direct visit to the site, such as from a redirect, rather than from a request made from the page using JavaScript (third-party). As browsers tighten security on cookies, this helps to future proof it. + +All OpenID Connect (OIDC) interaction is handled by the [Nimbus OAuth2 Java Library](https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions/src/master/). + +### After Successful Login + +The JSON document with a token set represents the Assets. These Assets are stored in a Redis cache on the server, and a key is issued to retrieve them. Each application has its own Token Cache. The Assets are made up of the `access_token`, `refresh_token`, and the `expires_in` values. + +That returned Assets key is SHA-256 encrypted, and set in the browser in a secure cookie. That cookie is only good for this application, and cannot be read by JavaScript (`http-only`). + +### Authenticated Requests + +That encrypted cookie can now be used with the application to make authenticated requests. The browser will send the cookie with each request, and follow the path as laid out in the diagram. + +![BFF Pattern](./BFF.png) + +#### General Steps + +1. User makes a request for a resource from a browser application +2. If there is no first-party cookie, then proceed as though anonymous. If the resource is protected, then a 401 or 403 status code is returned. + 1. For the Science Portal, this means denying access with a modal login box as authentication is required. + 2. For the Storage UI, this means producing a button to optionally authenticate, as public browsing is allowed for Public items. +3. Decrypt the cookie if present, then use the key to look up the Assets in the Redis cache. +4. Use the Access Token from the Assets as a Bearer token in the request header to the API. + +If the Access Token is valid (and the user is granted access to the resource), then the resource is returned. The system will check the `expires_in` value to determine if the Access Token will soon expire, and if so, will request a refresh. + +1. If no Refresh Token is present in the Assets, the user needs to re-authenticate. +2. If a Refresh Token is present in the Assets, then request a new Access Token from the token endpoint. +3. If the Refresh Token is expired (i.e. 401 is returned from the OIdP), then the user needs to re-authenticate. +4. Use the refreshed Access Token and return the resource to the user. diff --git a/cadc-web-token/build.gradle b/cadc-web-token/build.gradle new file mode 100644 index 0000000..7c2f52b --- /dev/null +++ b/cadc-web-token/build.gradle @@ -0,0 +1,52 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This generated file contains a sample Java library project to get you started. + * For more details take a look at the 'Building Java & JVM projects' chapter in the Gradle + * User Manual available at https://docs.gradle.org/7.6.1/userguide/building_java_projects.html + */ + +plugins { + // Apply the java-library plugin for API and implementation separation. + id 'java-library' + id 'maven-publish' + + // Needed to support the old install command. Remove with Gradle version >= 7 + id 'maven' +} + +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() + mavenLocal() +} + +group = 'org.opencadc' +version = '1.0.2' +sourceCompatibility = '11' + +// Minimal publishing required to run publishToMavenLocal with Gradle version >= 7 +publishing { + publications { + maven(MavenPublication) { + groupId = group + version = version + sourceCompatibility = sourceCompatibility + + from components.java + } + } +} + +dependencies { + api 'com.nimbusds:oauth2-oidc-sdk:11.6' + + implementation 'org.opencadc:cadc-registry:[1.7.4,2.0.0)' + implementation 'org.opencadc:cadc-util:[1.10.0,2.0.0)' + implementation 'org.apache.commons:commons-jcs3:[3.2,3.3)' + implementation 'org.apache.commons:commons-lang3:[3.11,4.0)' + implementation 'redis.clients:jedis:[5.0.2,6.0.0)' + + // Use JUnit test framework. + testImplementation 'junit:junit:4.13.2' +} diff --git a/cadc-web-token/src/main/java/org/opencadc/token/Assets.java b/cadc-web-token/src/main/java/org/opencadc/token/Assets.java new file mode 100644 index 0000000..40082b6 --- /dev/null +++ b/cadc-web-token/src/main/java/org/opencadc/token/Assets.java @@ -0,0 +1,177 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2023. (c) 2023. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la “GNU Affero General Public + * License as published by the License” telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l’espoir qu’il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n’est + * . pas le cas, consultez : + * . + * + * + ************************************************************************ + */ + +package org.opencadc.token; + +import ca.nrc.cadc.util.StringUtil; +import org.json.JSONObject; + +import java.util.Objects; + +/** + * Class that represents the document that will be stored in cache. Instances can be updated from time to time + * during a refresh. + */ +public final class Assets { + // Provide a short buffer to check for the expiry time. This will be used to ensure that the expiry time isn't + // in the future by, say, one millisecond, which won't benefit a future request. One minute is the default. + private static final long EXPIRY_BUFFER_CHECK_MS = 60000L; + + // Keys to access the values in JSON. + static final String ACCESS_TOKEN_KEY = "access_token"; + static final String REFRESH_TOKEN_KEY = "refresh_token"; + static final String EXPIRES_IN_KEY = "expires_in"; + private static final String EXPIRES_AT_MS_KEY = "expires_at_ms"; + + + private final String accessToken; + private final String refreshToken; + private final long expiryTimeMilliseconds; + + /** + * Plain constructor. Used when being pulled out of cache. + * + * @param accessToken The current Access Token. + * @param refreshToken The current Refresh Token (if present). + * @param expiryTimeMilliseconds The expiry time in milliseconds. Used to compare for expiry. + */ + public Assets(final String accessToken, final String refreshToken, final long expiryTimeMilliseconds) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expiryTimeMilliseconds = expiryTimeMilliseconds; + } + + /** + * A new instance based on the JSON document from the OpenID Connect Provider. This is typically used when a + * new Access Token is obtained through Refresh or Authorization. + * + * @param tokenSet The JSON document of tokens. + */ + public Assets(final JSONObject tokenSet) { + this.accessToken = tokenSet.getString(Assets.ACCESS_TOKEN_KEY); + this.refreshToken = tokenSet.getString(Assets.REFRESH_TOKEN_KEY); + + final int expirySeconds = tokenSet.getInt(Assets.EXPIRES_IN_KEY); + this.expiryTimeMilliseconds = System.currentTimeMillis() + (expirySeconds * 1000L); + } + + @Override + public String toString() { + final JSONObject jsonObject = new JSONObject(); + + jsonObject.put(Assets.ACCESS_TOKEN_KEY, accessToken); + + if (StringUtil.hasText(refreshToken)) { + jsonObject.put(Assets.REFRESH_TOKEN_KEY, refreshToken); + } + + jsonObject.put(Assets.EXPIRES_AT_MS_KEY, expiryTimeMilliseconds); + + return jsonObject.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Assets assets = (Assets) o; + return expiryTimeMilliseconds == assets.expiryTimeMilliseconds + && Objects.equals(accessToken, assets.accessToken) + && Objects.equals(refreshToken, assets.refreshToken); + } + + @Override + public int hashCode() { + return Objects.hash(accessToken, refreshToken, expiryTimeMilliseconds); + } + + public String getAccessToken() { + return this.accessToken; + } + + public String getRefreshToken() { + return this.refreshToken; + } + + public long getExpiryTimeMilliseconds() { + return this.expiryTimeMilliseconds; + } + + /** + * Determine whether this asset's expiry time has already come, or is about to. Used to determine whether a + * refresh should be attempted. + * @return True if expiry time is in the past (or close to), false otherwise. + */ + public boolean isAccessTokenExpired() { + return this.expiryTimeMilliseconds < (System.currentTimeMillis() - Assets.EXPIRY_BUFFER_CHECK_MS); + } +} diff --git a/cadc-web-token/src/main/java/org/opencadc/token/Client.java b/cadc-web-token/src/main/java/org/opencadc/token/Client.java new file mode 100644 index 0000000..d3a8695 --- /dev/null +++ b/cadc-web-token/src/main/java/org/opencadc/token/Client.java @@ -0,0 +1,533 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2023. (c) 2023. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la “GNU Affero General Public + * License as published by the License” telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l’espoir qu’il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n’est + * . pas le cas, consultez : + * . + * + * + ************************************************************************ + */ + +package org.opencadc.token; + +import ca.nrc.cadc.auth.NotAuthenticatedException; +import ca.nrc.cadc.net.HttpGet; +import ca.nrc.cadc.reg.Standards; +import ca.nrc.cadc.reg.client.LocalAuthority; +import ca.nrc.cadc.util.StringUtil; +import com.nimbusds.oauth2.sdk.AccessTokenResponse; +import com.nimbusds.oauth2.sdk.AuthorizationCode; +import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; +import com.nimbusds.oauth2.sdk.AuthorizationErrorResponse; +import com.nimbusds.oauth2.sdk.AuthorizationGrant; +import com.nimbusds.oauth2.sdk.AuthorizationRequest; +import com.nimbusds.oauth2.sdk.AuthorizationResponse; +import com.nimbusds.oauth2.sdk.AuthorizationSuccessResponse; +import com.nimbusds.oauth2.sdk.ErrorObject; +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.RefreshTokenGrant; +import com.nimbusds.oauth2.sdk.ResponseType; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.TokenErrorResponse; +import com.nimbusds.oauth2.sdk.TokenRequest; +import com.nimbusds.oauth2.sdk.TokenResponse; +import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.id.State; +import com.nimbusds.oauth2.sdk.token.RefreshToken; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.log4j.Logger; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StringWriter; +import java.io.Writer; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.Arrays; +import java.util.Objects; + + +/** + * A configured Client necessary to connect to an OpenID Connect Provider from a CADC/CANFAR application. + */ +public class Client { + private static final Logger LOGGER = Logger.getLogger(Client.class); + + private static final String WELL_KNOWN_ENDPOINT = "/.well-known/openid-configuration"; + private static final String AUTH_ENDPOINT_KEY = "authorization_endpoint"; + private static final String TOKEN_ENDPOINT_KEY = "token_endpoint"; + + + private final String clientID; + private final String clientSecret; + private final URL callbackURL; + private final URL redirectURL; + private final String[] scope; + private final TokenStore tokenStore; + + + /** + * Full constructor. Mostly used for testing, but feel free to use an alternate TokenStore implementation. + * + * @param clientID The ID (Not the name) of the configured Client registered at the provider. + * @param clientSecret The secret associated with the Client ID for authorization to the provider. + * @param callbackURL Where to send the user after successful login and successful redirect to callback. + * @param redirectURL The Callback URL to redirect the user to after successful login. + * @param scope The array of Scope values to send. + * @param tokenStore The TokenStore cache. + */ + public Client(String clientID, String clientSecret, URL callbackURL, URL redirectURL, String[] scope, + TokenStore tokenStore) { + this.clientID = clientID; + this.clientSecret = clientSecret; + this.callbackURL = callbackURL; + this.redirectURL = redirectURL; + this.scope = scope; + this.tokenStore = tokenStore; + } + + /** + * Full (mostly) constructor. + * + * @param clientID The ID (Not the name) of the configured Client registered at the provider. + * @param clientSecret The secret associated with the Client ID for authorization to the provider. + * @param callbackURL Where to send the user after successful login and successful redirect to callback. + * @param redirectURL The Callback URL to redirect the user to after successful login. + * @param scope The array of Scope values to send. + * @param tokenStoreCacheURL The URL to the default cache implementation. + */ + public Client(String clientID, String clientSecret, URL callbackURL, URL redirectURL, String[] scope, + String tokenStoreCacheURL) { + this(clientID, clientSecret, callbackURL, redirectURL, scope, new RedisTokenStore(tokenStoreCacheURL)); + } + + /** + * Obtain the URL that the user will be redirected to after successful login and redirect_uri. + * + * @return The URL of the end callback. + */ + public URL getCallbackURL() { + return callbackURL; + } + + /** + * Obtain the URL that the user will be redirected to after successful login by the OpenID Connect provider. + * + * @return The URL of the end redirect. + */ + public URL getRedirectURL() { + return redirectURL; + } + + /** + * Obtain the login endpoint without the optional State string. + * + * @return URI to redirect the user to. Never null. + * @throws IOException If any URLs cannot be used. + */ + public URL getAuthorizationURL() throws IOException { + return getAuthorizationURL(""); + } + + /** + * Obtain the login endpoint, but provide the optional State string to be stored by the caller. + * + * @param stateString The state value to check later (optional). + * @return URI to redirect the user to. Never null. + * @throws IOException If any URLs cannot be used. + */ + public URL getAuthorizationURL(final String stateString) throws IOException { + // The authorization endpoint of the server + final URI authorizationEndpoint = URI.create(Client.getAuthorizationEndpoint().toExternalForm()); + + // The client identifier provisioned by the server + final ClientID clientID = new ClientID(this.clientID); + + // The requested scope values for the token + final Scope scope = new Scope(this.scope); + + // The client callback URI, typically pre-registered with the server + final URI callback = URI.create(this.redirectURL.toExternalForm()); + + final AuthorizationRequest.Builder requestBuilder = + new AuthorizationRequest.Builder(new ResponseType(ResponseType.Value.CODE), clientID) + .scope(scope) + .redirectionURI(callback) + .endpointURI(authorizationEndpoint); + + if (StringUtil.hasText(stateString)) { + requestBuilder.state(new State(stateString)); + } + + final AuthorizationRequest request = requestBuilder.build(); + return request.toURI().toURL(); + } + + /** + * Decrypt the given cookie value to obtain the key, then look it up in the cache to return the access token. + * + * @param encryptedCookieValue The encrypted cookie value from the caller. + * @return String access token. + * @throws Exception If the Assets with the given key don't exist, or the cookie cannot be decrypted. + */ + public String getAccessToken(final String encryptedCookieValue) throws Exception { + final String assetsKey = getAssetsKey(encryptedCookieValue); + final Assets storedAssets = this.tokenStore.get(assetsKey); + final Assets assets; + + if (Client.needsRefresh(storedAssets)) { + final Assets refreshedAssets = refresh(storedAssets); + this.tokenStore.put(assetsKey, refreshedAssets); + assets = refreshedAssets; + } else { + assets = storedAssets; + } + + return assets.getAccessToken(); + } + + /** + * Obtain an access token from the token endpoint for the current configuration, obtaining necessary elements + * from the provided response URI from the authorization endpoint. This will not use the optional State. + * + * @param responseURI The response URI from the authorization's login. + * @return The encrypted Assets key. Never null. + * @throws IOException If any URLs cannot be used. + */ + public byte[] setAccessToken(final URI responseURI) throws Exception { + final AuthorizationCode code = getAuthorizationCode(responseURI); + return setAccessToken(code); + } + + /** + * Obtain an access token from the token endpoint for the current configuration, obtaining necessary elements + * from the provided response URI from the authorization endpoint. + * + * @param responseURI The response URI from the authorization's login. + * @param state The optional state value to be used to compare against later. + * @return The encrypted Assets key. Never null. + * @throws IOException If any URLs cannot be used. + */ + public byte[] setAccessToken(final URI responseURI, final String state) throws Exception { + final AuthorizationCode code = getAuthorizationCode(responseURI, new State(state)); + return setAccessToken(code); + } + + byte[] setAccessToken(final AuthorizationCode authorizationCode) throws Exception { + final URI callback = URI.create(this.redirectURL.toExternalForm()); + final AuthorizationGrant codeGrant = new AuthorizationCodeGrant(authorizationCode, callback); + + // The credentials to authenticate the client at the token endpoint + final ClientID clientID = new ClientID(this.clientID); + final Secret clientSecret = new Secret(this.clientSecret); + final ClientAuthentication clientAuth = new ClientSecretBasic(clientID, clientSecret); + final URI tokenEndpoint = URI.create(Client.getTokenEndpoint().toExternalForm()); + final TokenRequest tokenRequest = new TokenRequest(tokenEndpoint, clientAuth, codeGrant); + final TokenResponse tokenResponse; + + try { + tokenResponse = TokenResponse.parse(tokenRequest.toHTTPRequest().send()); + } catch (ParseException parseException) { + throw new IllegalArgumentException("Invalid or missing response parameters from token endpoint: " + + parseException.getMessage(), parseException); + } + + if (!tokenResponse.indicatesSuccess()) { + // We got an error response... + handleTokenErrorResponse(tokenResponse.toErrorResponse()); + } + + final AccessTokenResponse tokenSuccessResponse = tokenResponse.toSuccessResponse(); + return setAccessToken(new JSONObject(tokenSuccessResponse.toJSONObject().toJSONString())); + } + + byte[] setAccessToken(final JSONObject tokenSet) throws Exception { + final Assets assets = new Assets(tokenSet); + return encryptAssetsKey(this.tokenStore.put(assets)); + } + + /** + * Encrypt the given assets key to be used in a cookie and sent to the browser. + * + * @param assetsKey The key to encrypt and put into a cookie. + * @return byte array of encrypted value, never null. + * @throws Exception If the encryption fails. + */ + byte[] encryptAssetsKey(final String assetsKey) throws Exception { + final CookieEncrypt cookieEncrypt = new CookieEncrypt(); + final EncryptedCookie encryptionEncryptedCookie = cookieEncrypt.encrypt(assetsKey); + return encryptionEncryptedCookie.marshall(); + } + + /** + * Perform a refresh of the given Assets and return the new version. + * + * @param assets The (possibly expired) assets to be refreshed using its refresh token. + * @return The refreshed Assets object. + * @throws Exception For any HTTP errors, or in obtaining the Token Endpoint URL. + */ + Assets refresh(final Assets assets) throws Exception { + final RefreshToken refreshToken = new RefreshToken(assets.getRefreshToken()); + final RefreshTokenGrant refreshTokenGrant = new RefreshTokenGrant(refreshToken); + + // The credentials to authenticate the client at the token endpoint + final ClientID clientID = new ClientID(this.clientID); + final Secret clientSecret = new Secret(this.clientSecret); + final ClientAuthentication clientAuth = new ClientSecretBasic(clientID, clientSecret); + final URI tokenEndpoint = URI.create(Client.getTokenEndpoint().toExternalForm()); + final TokenRequest tokenRequest = new TokenRequest(tokenEndpoint, clientAuth, refreshTokenGrant); + final TokenResponse tokenResponse; + + try { + tokenResponse = TokenResponse.parse(tokenRequest.toHTTPRequest().send()); + } catch (ParseException parseException) { + throw new IllegalArgumentException("Invalid or missing response parameters from token endpoint: " + + parseException.getMessage(), parseException); + } + + if (!tokenResponse.indicatesSuccess()) { + handleTokenErrorResponse(tokenResponse.toErrorResponse()); + } + + final AccessTokenResponse tokenSuccessResponse = tokenResponse.toSuccessResponse(); + + return new Assets(new JSONObject(tokenSuccessResponse.toJSONObject().toJSONString())); + } + + String getAssetsKey(final String encryptedCookieValue) throws Exception { + final EncryptedCookie encryptedEncryptedCookie = new EncryptedCookie(encryptedCookieValue); + final CookieDecrypt cookieDecrypt = new CookieDecrypt(); + return cookieDecrypt.getAssetsKey(encryptedEncryptedCookie); + } + + /** + * Obtain an authorization code without the optional state provided. + * + * @param responseURI The response URI from the authorization's login. + * @return AuthorizationCode instance, never null. + */ + AuthorizationCode getAuthorizationCode(final URI responseURI) { + return getAuthorizationCode(responseURI, null); + } + + /** + * Obtain an authorization code and provide the optional state. + * + * @param responseURI The response URI from the authorization's login. + * @param state The state value to compare against to the response. + * @return AuthorizationCode instance, never null. + */ + AuthorizationCode getAuthorizationCode(final URI responseURI, final State state) { + // Parse the authorisation response from the callback URI + final AuthorizationResponse response; + + try { + response = AuthorizationResponse.parse(responseURI); + } catch (ParseException parseException) { + throw new IllegalArgumentException("Invalid or missing response parameters from authorization endpoint: " + + parseException.getMessage(), parseException); + } + + // Check the returned state parameter, must match the original. + final State responseState = response.getState(); + if (responseState == null && state != null) { + throw new IllegalStateException("Caller state expected, but none provided to compare to by response."); + } else if (responseState != null && state == null) { + throw new IllegalStateException("Response state expected, but none provided to compare to by caller."); + } else if (responseState != null && !state.equals(responseState)) { + throw new NotAuthenticatedException("Caller state does not match request state! Possible tampering."); + } else if (!response.indicatesSuccess()) { + // The request was denied or some error occurred + final AuthorizationErrorResponse errorResponse = response.toErrorResponse(); + throw new IllegalArgumentException("Invalid response from authorization server: " + errorResponse); + } + + final AuthorizationSuccessResponse successResponse = response.toSuccessResponse(); + + // Retrieve the authorisation code, to be used later to exchange the code for + // an access token at the token endpoint of the server + return successResponse.getAuthorizationCode(); + } + + void handleTokenErrorResponse(final TokenErrorResponse tokenErrorResponse) { + final ErrorObject tokenErrorObject = tokenErrorResponse.getErrorObject(); + if (tokenErrorObject.getHTTPStatusCode() == 401) { + throw new NotAuthenticatedException("Refresh token expired. Please re-authenticate."); + } else { + throw new IllegalArgumentException("Invalid response from token server: " + + tokenErrorResponse.toJSONObject()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Client that = (Client) o; + return Objects.equals(clientID, that.clientID) && Objects.equals(callbackURL, that.callbackURL) + && Objects.equals(redirectURL, that.redirectURL) && Arrays.equals(scope, that.scope); + } + + @Override + public int hashCode() { + int result = Objects.hash(clientID, callbackURL, redirectURL); + result = 31 * result + Arrays.hashCode(scope); + return result; + } + + /** + * Generate a new 16-character state value for the caller. The caller will need to store this and retrieve it + * later to compare. + * + * @return String random state value. + */ + public static String generateState() { + return RandomStringUtils.randomAlphanumeric(16); + } + + /** + * Obtain whether the given Assets is expired, or about to expire. + * + * @param assets The Assets to check. + * @return True if about to expire or is expired. False otherwise. + */ + public static boolean needsRefresh(final Assets assets) { + return assets.isAccessTokenExpired(); + } + + /** + * Obtain the Issuer base URL. + * + * @return URL of the Issuer. Never null. + * @throws IOException For a poorly formed URL. + * @throws UnsupportedOperationException If the configured Issuer URL is not an HTTPS URL. + */ + public static URL getIssuer() throws IOException { + final LocalAuthority localAuthority = new LocalAuthority(); + final URI openIDIssuerURI = localAuthority.getServiceURI(Standards.SECURITY_METHOD_OPENID.toASCIIString()); + if (!"https".equals(openIDIssuerURI.getScheme())) { + throw new UnsupportedOperationException("OpenID Provider not configured."); + } else { + return openIDIssuerURI.toURL(); + } + } + + /** + * Pull the Authorization Endpoint URL from the Well Known JSON document. + * + * @return URL of the Authorization Endpoint for authentication. Never null. + * @throws IOException For a poorly formed URL. + */ + public static URL getAuthorizationEndpoint() throws IOException { + final JSONObject jsonObject = Client.getWellKnownJSON(); + final String authEndpointString = jsonObject.getString(Client.AUTH_ENDPOINT_KEY); + return new URL(authEndpointString); + } + + /** + * Pull the Token Endpoint URL from the Well Known JSON document. + * + * @return URL of the Token Endpoint for access and refresh tokens. Never null. + * @throws IOException For a poorly formed URL. + */ + public static URL getTokenEndpoint() throws IOException { + final JSONObject jsonObject = Client.getWellKnownJSON(); + final String tokenEndpointString = jsonObject.getString(Client.TOKEN_ENDPOINT_KEY); + return new URL(tokenEndpointString); + } + + /** + * Obtain the .well-known endpoint JSON document. + * TODO: Cache this? + * + * @return The JSON Object of the response data. + * @throws MalformedURLException If URLs cannot be created as expected. + */ + private static JSONObject getWellKnownJSON() throws IOException { + final URL oidcIssuer = Client.getIssuer(); + final URL configurationURL = new URL(oidcIssuer.toExternalForm() + Client.WELL_KNOWN_ENDPOINT); + final Writer writer = new StringWriter(); + final HttpGet httpGet = new HttpGet(configurationURL, inputStream -> { + final Reader inputReader = new BufferedReader(new InputStreamReader(inputStream)); + final char[] buffer = new char[8192]; + int charsRead; + while ((charsRead = inputReader.read(buffer)) >= 0) { + writer.write(buffer, 0, charsRead); + } + writer.flush(); + }); + + httpGet.run(); + + return new JSONObject(writer.toString()); + } +} diff --git a/cadc-web-token/src/main/java/org/opencadc/token/CookieDecrypt.java b/cadc-web-token/src/main/java/org/opencadc/token/CookieDecrypt.java new file mode 100644 index 0000000..e31f43a --- /dev/null +++ b/cadc-web-token/src/main/java/org/opencadc/token/CookieDecrypt.java @@ -0,0 +1,113 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2023. (c) 2023. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la “GNU Affero General Public + * License as published by the License” telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l’espoir qu’il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n’est + * . pas le cas, consultez : + * . + * + * + ************************************************************************ + */ + +package org.opencadc.token; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * The class to decrypt the cookie value. This will decrypt the key from the encrypted values. + */ +class CookieDecrypt { + private static final String DEFAULT_CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; + + private final String algorithm; + + + /** + * Useful for testing. + * @param algorithm The cipher algorithm to use. + */ + CookieDecrypt(final String algorithm) { + this.algorithm = algorithm; + } + + public CookieDecrypt() { + this(CookieDecrypt.DEFAULT_CIPHER_ALGORITHM); + } + + + public String getAssetsKey(final EncryptedCookie encryptedCookie) + throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, + InvalidKeyException, BadPaddingException, IllegalBlockSizeException { + + final Cipher cipher = Cipher.getInstance(this.algorithm); + cipher.init(Cipher.DECRYPT_MODE, encryptedCookie.getSecretKey(), + new IvParameterSpec(encryptedCookie.getInitializationVector())); + + return new String(cipher.doFinal(Base64.getDecoder().decode(encryptedCookie.getValue()))); + } +} diff --git a/cadc-web-token/src/main/java/org/opencadc/token/CookieEncrypt.java b/cadc-web-token/src/main/java/org/opencadc/token/CookieEncrypt.java new file mode 100644 index 0000000..326ff4c --- /dev/null +++ b/cadc-web-token/src/main/java/org/opencadc/token/CookieEncrypt.java @@ -0,0 +1,146 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2023. (c) 2023. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la “GNU Affero General Public + * License as published by the License” telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l’espoir qu’il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n’est + * . pas le cas, consultez : + * . + * + * + ************************************************************************ + */ + +package org.opencadc.token; + +import org.apache.commons.lang3.RandomStringUtils; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +class CookieEncrypt { + private static final String DEFAULT_CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; + + private final String algorithm; + + + public CookieEncrypt() { + this(CookieEncrypt.DEFAULT_CIPHER_ALGORITHM); + } + + CookieEncrypt(String algorithm) { + this.algorithm = algorithm; + } + + /** + * Encrypt the provided string value with the desired SecretKey. To use the default SecretKey, then use a generated + * #encrypt(String) method instead. + * + * @param value The value to encrypt. + * @param secretKey The SecretKey to use to decipher it later. + * @throws GeneralSecurityException For Cipher exceptions. + */ + EncryptedCookie encrypt(final String value, final Key secretKey) throws GeneralSecurityException { + final Cipher cipher = Cipher.getInstance(this.algorithm); + final byte[] iv = CookieEncrypt.initializeInitializationVector(cipher.getBlockSize()); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv)); + + final byte[] encryptedValue = + Base64.getEncoder().encode(cipher.doFinal(value.getBytes(StandardCharsets.ISO_8859_1))); + return new EncryptedCookie(encryptedValue, iv, secretKey); + } + + /** + * Encrypt the provided string value with a generated SecretKey. + * + * @param value The value to encrypt. + * @throws GeneralSecurityException For Cipher exceptions. + */ + public EncryptedCookie encrypt(final String value) throws GeneralSecurityException { + return encrypt(value, generateAESKey()); + } + + private static byte[] initializeInitializationVector(final int blockSize) { + final byte[] initializationVector = new byte[blockSize]; + final SecureRandom random = new SecureRandom(); + random.nextBytes(initializationVector); + + return initializationVector; + } + + Key generateAESKey() throws NoSuchAlgorithmException { + final String secretKeyString = RandomStringUtils.randomAlphanumeric(16); + + // Generate a Secret Key. + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(secretKeyString.getBytes(StandardCharsets.ISO_8859_1)); + + final byte[] keyBytes = new byte[16]; + System.arraycopy(digest.digest(), 0, keyBytes, 0, keyBytes.length); + + return new SecretKeySpec(keyBytes, "AES"); + } +} diff --git a/cadc-web-token/src/main/java/org/opencadc/token/EncryptedCookie.java b/cadc-web-token/src/main/java/org/opencadc/token/EncryptedCookie.java new file mode 100644 index 0000000..e4081ba --- /dev/null +++ b/cadc-web-token/src/main/java/org/opencadc/token/EncryptedCookie.java @@ -0,0 +1,146 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2023. (c) 2023. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la “GNU Affero General Public + * License as published by the License” telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l’espoir qu’il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n’est + * . pas le cas, consultez : + * . + * + * + ************************************************************************ + */ + +package org.opencadc.token; + +import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Base64; + +/** + * The product of the encryption. The values will all be included in the cookie value as a Base64 Encoded value. + */ +final class EncryptedCookie { + final byte[] value; + final byte[] initializationVector; + final Key secretKey; + + /** + * Create a new instance after encrypting a key. The raw key value is in the encrypted value. + * @param value The encrypted key linked to the document in cache. + * @param initializationVector The InitializationVector value used in the encryption. + * @param secretKey The Secret Key value used in the encryption. + * @see CookieEncrypt + */ + public EncryptedCookie(byte[] value, byte[] initializationVector, Key secretKey) { + this.value = value; + this.initializationVector = initializationVector; + this.secretKey = secretKey; + } + + + /** + * Parse out the metadata from the given String input. This is used when a cookie is received. + * @param input The String value from a cookie. + */ + public EncryptedCookie(final String input) { + final byte[] decodedInput = Base64.getDecoder().decode(input.getBytes(StandardCharsets.ISO_8859_1)); + this.initializationVector = new byte[16]; + System.arraycopy(decodedInput, 0, this.initializationVector, 0, 16); + + final byte[] secretKeyBytes = new byte[16]; + System.arraycopy(decodedInput, 16, secretKeyBytes, 0, 16); + this.secretKey = new SecretKeySpec(secretKeyBytes, "AES"); + + final int startPos = initializationVector.length + secretKeyBytes.length; + this.value = new byte[decodedInput.length - startPos]; + System.arraycopy(decodedInput, startPos, this.value, 0, value.length); + } + + public byte[] getValue() { + return value; + } + + public byte[] getInitializationVector() { + return initializationVector; + } + + public Key getSecretKey() { + return secretKey; + } + + /** + * Obtain the encoded cookie value as it should be when written out. + * @return A byte array of Base64 Encoded values in this instance. Never null. + * @throws IOException For writing data problems. + */ + public byte[] marshall() throws IOException { + try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + byteArrayOutputStream.write(this.initializationVector); + byteArrayOutputStream.write(this.secretKey.getEncoded()); + byteArrayOutputStream.write(this.value); + + byteArrayOutputStream.flush(); + + return Base64.getEncoder().encode(byteArrayOutputStream.toByteArray()); + } + } +} diff --git a/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java b/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java new file mode 100644 index 0000000..df96b68 --- /dev/null +++ b/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java @@ -0,0 +1,147 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2023. (c) 2023. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la “GNU Affero General Public + * License as published by the License” telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l’espoir qu’il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n’est + * . pas le cas, consultez : + * . + * + * + ************************************************************************ + */ + +package org.opencadc.token; + + +import redis.clients.jedis.JedisPooled; + +import java.util.HashMap; +import java.util.Map; +import java.util.NoSuchElementException; + + +/** + * Default TokenStore implementation access to a cache. By default, this relies on a Redis instance and the Jedis + * Java library. + */ +class RedisTokenStore implements TokenStore { + private static final String ACCESS_TOKEN_FIELD = "accessToken"; + private static final String REFRESH_TOKEN_FIELD = "refreshToken"; + private static final String EXPIRES_AT_MS_TOKEN_FIELD = "expiresAtMS"; + + private static final String KEY_FIELD = "asset_key_index"; + private final JedisPooled jedisPool; + + RedisTokenStore() { + this.jedisPool = new JedisPooled(); + } + + RedisTokenStore(final String url) { + this.jedisPool = new JedisPooled(url); + } + + + /** + * Insert a new Asset, then return the generated key. + * + * @param assets The Assets to store. + * @return String key, never null. + */ + @Override + public String put(final Assets assets) { + final String assetsKey = Long.toString(this.jedisPool.incrBy(RedisTokenStore.KEY_FIELD, 1L)); + put(assetsKey, assets); + + return assetsKey; + } + + /** + * Insert or update an Asset at the given key. + * + * @param assetsKey The key to store the Assets at. + * @param assets The Assets to store. + */ + @Override + public void put(final String assetsKey, final Assets assets) { + final Map assetsHash = new HashMap<>(); + assetsHash.put(RedisTokenStore.ACCESS_TOKEN_FIELD, assets.getAccessToken()); + assetsHash.put(RedisTokenStore.REFRESH_TOKEN_FIELD, assets.getRefreshToken()); + assetsHash.put(RedisTokenStore.EXPIRES_AT_MS_TOKEN_FIELD, Long.toString(assets.getExpiryTimeMilliseconds())); + this.jedisPool.hset(assetsKey, assetsHash); + } + + /** + * Obtain the Assets from the cache at the given key, or throw an Exception. + * + * @param assetsKey The key to look up. + * @return The Assets document. Never null. + * @throws NoSuchElementException If the given key returns nothing. + */ + @Override + public Assets get(final String assetsKey) { + if (jedisPool.exists(assetsKey)) { + final Map assetsHash = jedisPool.hgetAll(assetsKey); + return new Assets(assetsHash.get(RedisTokenStore.ACCESS_TOKEN_FIELD), + assetsHash.get(RedisTokenStore.REFRESH_TOKEN_FIELD), + Long.parseLong(assetsHash.get(RedisTokenStore.EXPIRES_AT_MS_TOKEN_FIELD))); + } else { + throw new NoSuchElementException("No asset with key " + assetsKey); + } + } +} diff --git a/cadc-web-token/src/main/java/org/opencadc/token/TokenStore.java b/cadc-web-token/src/main/java/org/opencadc/token/TokenStore.java new file mode 100644 index 0000000..68ef5d9 --- /dev/null +++ b/cadc-web-token/src/main/java/org/opencadc/token/TokenStore.java @@ -0,0 +1,98 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2023. (c) 2023. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la “GNU Affero General Public + * License as published by the License” telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l’espoir qu’il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n’est + * . pas le cas, consultez : + * . + * + * + ************************************************************************ + */ + +package org.opencadc.token; + +import java.util.NoSuchElementException; + +public interface TokenStore { + /** + * Insert a new Asset, then return the generated key. + * + * @param assets The Assets to store. + * @return String key, never null. + */ + String put(final Assets assets); + + /** + * Insert or update an Asset at the given key. + * + * @param assetsKey The key to store the Assets at. + * @param assets The Assets to store. + */ + void put(final String assetsKey, final Assets assets); + + /** + * Obtain the Assets from the cache at the given key, or throw an Exception. + * + * @param assetsKey The key to look up. + * @return The Assets document. Never null. + * @throws NoSuchElementException If the given key returns nothing. + */ + Assets get(final String assetsKey); +} diff --git a/cadc-web-token/src/test/java/org/opencadc/token/ClientTest.java b/cadc-web-token/src/test/java/org/opencadc/token/ClientTest.java new file mode 100644 index 0000000..221e19b --- /dev/null +++ b/cadc-web-token/src/test/java/org/opencadc/token/ClientTest.java @@ -0,0 +1,172 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2023. (c) 2023. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la “GNU Affero General Public + * License as published by the License” telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l’espoir qu’il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n’est + * . pas le cas, consultez : + * . + * + * + ************************************************************************ + */ + +package org.opencadc.token; + +import static org.junit.Assert.*; + +import ca.nrc.cadc.auth.NotAuthenticatedException; +import com.nimbusds.oauth2.sdk.AuthorizationCode; +import com.nimbusds.oauth2.sdk.id.State; +import org.json.JSONObject; +import org.junit.Test; + +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +public class ClientTest { + @Test + public void needsRefresh() { + final long goodExpiryTime = System.currentTimeMillis() - 50000L; + final Assets goodAssets = new Assets("access", "refresh", goodExpiryTime); + + assertFalse("Should not need refresh.", Client.needsRefresh(goodAssets)); + + final long expiredExpiryTime = System.currentTimeMillis() - 61000L; + final Assets expiredAssets = new Assets("access", "refresh", expiredExpiryTime); + + assertTrue("Should not need refresh.", Client.needsRefresh(expiredAssets)); + } + + @Test + public void setAndGet() throws Exception { + final Client testSubject = new Client("clientID", "clientSecret", + new URL("https://example.org/myapp/redirect"), + new URL("https://example.org/myapp/callback"), + new String[] { + "openid" + }, new TestTokenStore()); + + // Emulate the JSON coming from the OpenID Connect Provider. + final JSONObject testTokenSet = new JSONObject(); + testTokenSet.put(Assets.ACCESS_TOKEN_KEY, "myaccesstoken"); + testTokenSet.put(Assets.REFRESH_TOKEN_KEY, "myrefreshtoken"); + testTokenSet.put(Assets.EXPIRES_IN_KEY, Integer.toString(3600)); + + final byte[] cookieValue = testSubject.setAccessToken(testTokenSet); + + final String accessToken = testSubject.getAccessToken(new String(cookieValue, StandardCharsets.ISO_8859_1)); + + assertEquals("Wrong accessToken", "myaccesstoken", accessToken); + } + + @Test + public void getAuthorizationCode() throws Exception { + final Client testSubject = new Client("clientID", "clientSecret", + new URL("https://example.org/myapp/redirect"), + new URL("https://example.org/myapp/callback"), + new String[] { + "openid" + }, new TestTokenStore()); + + final URI testURI = URI.create("https://example.com/myapp/redirect?code=mycode"); + final AuthorizationCode authorizationCode = testSubject.getAuthorizationCode(testURI); + assertEquals("Wrong code", "mycode", authorizationCode.getValue()); + } + + @Test + public void getAuthorizationCodeErrors() throws Exception { + final Client testSubject = new Client("clientID", "clientSecret", + new URL("https://example.org/myapp/redirect"), + new URL("https://example.org/myapp/callback"), + new String[] { + "openid" + }, new TestTokenStore()); + + try { + final URI testURI = URI.create("https://example.com/myapp/redirect?code=mycode&state=mystate"); + testSubject.getAuthorizationCode(testURI); + fail("Should throw IllegalStateException"); + } catch (IllegalStateException illegalStateException) { + assertEquals("Wrong message.", + "Response state expected, but none provided to compare to by caller.", + illegalStateException.getMessage()); + } + + try { + final URI testURI = URI.create("https://example.com/myapp/redirect?code=mycode"); + testSubject.getAuthorizationCode(testURI, new State("mystate")); + fail("Should throw IllegalStateException"); + } catch (IllegalStateException illegalStateException) { + assertEquals("Wrong message", + "Caller state expected, but none provided to compare to by response.", + illegalStateException.getMessage()); + } + + try { + final URI testURI = URI.create("https://example.com/myapp/redirect?code=mycode&state=mystateone"); + testSubject.getAuthorizationCode(testURI, new State("mystatetwo")); + fail("Should throw NotAuthenticatedException"); + } catch (NotAuthenticatedException notAuthenticatedException) { + assertEquals("Wrong message", + "Caller state does not match request state! Possible tampering.", + notAuthenticatedException.getMessage()); + } + } +} diff --git a/cadc-web-token/src/test/java/org/opencadc/token/CookieEncryptionTest.java b/cadc-web-token/src/test/java/org/opencadc/token/CookieEncryptionTest.java new file mode 100644 index 0000000..fc52b13 --- /dev/null +++ b/cadc-web-token/src/test/java/org/opencadc/token/CookieEncryptionTest.java @@ -0,0 +1,28 @@ +/* + * This Java source file was generated by the Gradle 'init' task. + */ +package org.opencadc.token; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.*; + + +public class CookieEncryptionTest { + + @Test + public void roundTrip() throws Exception { + final String assetKey = "ivegotabadfeelingaboutthis"; + final CookieEncrypt cookieEncrypt = new CookieEncrypt(); + final EncryptedCookie encryptedCookie = cookieEncrypt.encrypt(assetKey); + + final String marshalledCookieValue = new String(encryptedCookie.marshall(), StandardCharsets.UTF_8); + final EncryptedCookie unmarshalledCookieValue = new EncryptedCookie(marshalledCookieValue); + final CookieDecrypt cookieDecrypt = new CookieDecrypt(); + + final String decrypted = cookieDecrypt.getAssetsKey(unmarshalledCookieValue); + assertEquals("Wrong decrypted value.", assetKey, decrypted); + } +} diff --git a/cadc-web-token/src/test/java/org/opencadc/token/TestTokenStore.java b/cadc-web-token/src/test/java/org/opencadc/token/TestTokenStore.java new file mode 100644 index 0000000..e7ad769 --- /dev/null +++ b/cadc-web-token/src/test/java/org/opencadc/token/TestTokenStore.java @@ -0,0 +1,99 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2023. (c) 2023. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la “GNU Affero General Public + * License as published by the License” telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l’espoir qu’il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n’est + * . pas le cas, consultez : + * . + * + * + ************************************************************************ + */ + +package org.opencadc.token; + +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class TestTokenStore implements TokenStore { + private final Map cache = new ConcurrentHashMap<>(); + + @Override + public String put(Assets assets) { + final String newKey = UUID.randomUUID().toString(); + put(newKey, assets); + return newKey; + } + + @Override + public void put(String assetsKey, Assets assets) { + cache.put(assetsKey, assets); + } + + @Override + public Assets get(String assetsKey) { + if (cache.containsKey(assetsKey)) { + return cache.get(assetsKey); + } else { + throw new NoSuchElementException("TEST"); + } + } +} diff --git a/cadc-web-util/build.gradle b/cadc-web-util/build.gradle index 1271fbc..be13b06 100644 --- a/cadc-web-util/build.gradle +++ b/cadc-web-util/build.gradle @@ -10,7 +10,7 @@ repositories { mavenLocal() } -sourceCompatibility = 1.8 +sourceCompatibility = 11 group = 'org.opencadc' version = '1.2.13' diff --git a/settings.gradle b/settings.gradle index 6de682b..6ed700f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,2 @@ rootProject.name = 'opencadc-web' -include('cadc-web-util', 'cadc-web-test') +include('cadc-web-token', 'cadc-web-util', 'cadc-web-test')