7%T%&NsuQbikFabfr_S^du+U$BwTh@o`U^<7p1XxWaT-dX6i-(3M{8N^f$~
zeLQ~ByP~#!|VZ$6A=iSFl_Alq%M>P-1!Oq;jyJzKFQT<)6S$Ldcg~V$!
z)<=(1gKCkF!GZkxs45zCi(S?=%mZPB_5T-p2y+ZL$=B9ooQ%#MkA78gM(y(oofuGyL^~`A1G8ABngxfKtwikj%1C9lmPawoqv01++Ap+sv_C@)64sW7NA{1chV0
zr`9~a2w{WrfS)NIFwU{wF>4-QIu?&+n+FUr@EvnmTkby=55yy-2Lj4q@5}MDso#C2
zzX~25;!iF=#G^Hh7kiZ)K^IFu)fhww;blIC(YO|V&YqP*^}dTHVQLbLm-`nTH4!_p
zB{G&D!RsR0The|^M{otKyt&H)(dLIxSJi8m{){%?jT|+XoK%`i8t{zG9mHJ+CTi|tBd4!McOeYzk}z})5ZAoC9=s7Ccpmh^6>5(2
zd^gq28CGC|n}yfmL29q-IyW2c
zSM@-}-`GSAxT@$KW5>4PJJNzcuZyeFkC*Xr?cq9m;jTRA-K@$JJGt`E
zx$N_Cs^SHiiv5jLg^j<>+$>oahe3l0Do&SSR#A@};%A6!>Y0PT1j*YAu6c>eWX|O>
z1m*r^vKHVn-s|jF4^rZIX{Q2bzcTcZDl3~`Rok$vL_4H^8;g%X{XW)r!b#>%tN{28
zt+iVch0=FxdzIlWZ2YEwFAav??BA%VpD)tSAiuuCl)LOb)T}|kSHq_NSctj$oYUz)
zqOv9x+8N-xg!R<#_;@TdDBc(x@cf9CXMsS%nJQJFa^#(mUVB*l{Knf5h{2*)ZxF&T
zPbGbp+Nd||cYjb7Tm$LffN3-n^^a@tjn@djDdYEG`@n0t@M5Wctd#%CkrV7gJ6KKB
z4;KP4JBz=_jIH2sA?0MLL%VoU`61N7m&?b+J^2rF((AME5)lH_`vVV<>w&1#tRTXp
zY^dQEPew7X1%Oro#ka$gKJ2LYCa8Cbi)hkLIbH_)S=0wz5l`@bp@C_s_z~R3Rp&p-
zSO~^kY^fuhwV}pFI~kpv@>{acQM0x&Vd0FXZ1y-`XXYJKWv7sj3xye<0td&jm7@0ei?{ch=u>her
zLSwi8VcA5h?7o(#B3ECVVk4VnQ99X(cvC5k_q8k_*1}AByxXrg5P<^;)i-r
zGlb`{HI-&{C4MDBNjNo#EqnF7U~x_n6wQpcJZrQHro2J5rA0Ae;ot?HA=K{#T%>y+
z0~$|Nc=-EY+%H(*@O$0T?*f=RxfEYp|65}K9W%7i%tYu(znT6Edg6%;hiv^WatLN)
zInILdU=jiWVKV`95Z4d;=cAMP2NKw*9O@|(%)~NhG+%>cb?7&EpdfRIXoB@!9mv#}
z8HcY0@>hj-<5|gL)(AmO=r>$TljD@@<$Thb>&VF#{_%#;ZuGmyto@r*$efOH&d67D
z(aBt7?&O3-$D6s6%h9iIt{r+BJNMAF!%j0Z
zbMa-!x0+(eZXAuw+PCoC^*Y}$>H85#50$>RFzY{|{lFt>$u?DTf&tbh-in!LD1R_%
zKAC6P>0vZ&MafUr!zYxVus-k;Ecwg)geOkemM3o6u*!_ejLFPy{tVFOzcOex0QNFC
z3IJaUfhDZZ{NOz^5MXzof;7B`v*0~{0G%d4X1&mvJ2SYK&Lr$cXcPzHI?Y?ThPjLL
z;D$DHUzq*k@QbVPCC#K#X&A2!`kgZRr*$
zGyO{bPD7eqi&XZ}AvH`Q=OL8jky`STN!_bzaN@+f8l1EFK?1Gi&vG~iUjc>EMYbbI
z=eiGOOxR{yM|BC0Vd)w*hFzuH3@(*6IwPEYPR&6)w%0ggPK|9IlBKcD{$YkP$!a-Y
z!(*GhQBK*&*bbm6?y)_Y=~cv@isS^J%%yW7M~!VR$Hc@DyQa~T;eU126VOyH;AnXo
z?&(d47`rhO@zCIc8k?e;BNP{u^j|CbGnLYx#g_Eve5F6XN@D$4z-|VjKc=lR{b3PX
z|7l3V7#ssj`g8f9`f~y6KOJ|aKQ=^Ie=ZRHIU)LUK}r90qCXcZ{aI{Df6iC>^KLim
z&jNNc5dHDuwM_prm|ofcOe6>B&*g*a&jqaiOx%_JSj@8iTp;>$LiFc?lK$&Oe=bz|
zv)GdUoUiof2Qt>51?*-Z`lncbnzQokIR-`rfB2bY+myzp#>q{SC&C!~D?1&40Zb}m
zFT;2IaUYpPdSy?cy=!xkDfYpn3$Y>}d)`X=~nGd3}^f|bYB&bKDk?|7lV{qNXIIg9*&|cAzaZGW9(IoFtxjy(p
zTXn~B4xf!d&gaQ^tnZUiAAMQiX?{+(c(+EA^i3@2eDo=AYY0Cc<72o|;3z+*AC>eG
zfsYBzhrE}e?s9>rN&YL|^#LEvuXr5aq4_S|WBBmh9Zrvp^u^DiiNgO&lK&YW=N}F-
z42Btg7#u=0v*K3NYLHqJrF6Q`*9yGS!;}wrS?ALPo+3Hlid+}-Vcuqml^#xqeOp2E
z9pr>)VuX2KB&7=lzUJZ5B3kXEyG2&e&zv6#F#M?Gd^)@ot$tn7hoYQLM!0m2Nc=^J
z(+>ikLsy1)zoi#2L??N^7WUD#LixPV92FRlwtPFzwRTmC6y$S%@b6`DpBuD?7f2P?
z8uJA+kiS26t&s(05n~Ss7SPzw1&cD4s-(L@*BUuI%p$e{3wxPM5)ph4us!~3jZS#e
za56?$(i%ue0qYR#69L9*=vu+{2N@ed|03Am!ip!YdG!&&wg8)t9q(sE
z>&u|ad`i*tQq~17jJ-g8Xi-DOc<+4p>?>pe!<37a&S=<`v9W@s>21L-5NtAq3@*Ez
znDTV|oDpNEOW7GT-0=C{Z&U_W_g!9*1l$u~xG=)-gF%Lq@RJ{_`?8hEK#8&jIll`t
z{9uq_wz9@mIzmbr?g=o=R*t}AQtPdSFOLe31$;49iJW^P;{z7;R!l(p$NsT_)9Blt
zDS*H5Gu$4TA^GzF{~S0AaFOTiz(jh%T!QpR%;kVXE6xKP;bm^?;$s1yMq3Ozi>myEXQa
zIAe!2c2&hNV9#sp615vDAu&aPo0$Z#wy4-UoY_?Zp|0b)SS!220
zRn*RtDcz#5Kx_1FP(H0Omgl1jIbhPmbYFn6O9i`%w)yV|kS+T_z91Cf?XE;9I9OvaNqZe^O0bH)rm-JY%nS~pUpm;l
zU^TtwU}pzwC?#{}je#FktO^dL84k8CIE=P9SX*#7UEyF`gCpo(2iq1LNrxP4S8x=)
z;b2z`KtINg)-v~C)BO04t`F608-q6@0%M-#*EEY>F
z?`u{?=u~PF?4Z#Y91840UG_|%E;Ny@&{(E<D0wR%2&cjld3S><+6rG?@+ycF=es
zFbh~EFJ&O%&5CnEQ)!CE)>qCnPNOv%>$aAMPNN=;J!P#Aolg4%J7}1dt-!t|*u%y_
z{CLqc8w+kmM43j1R9Ub+l6IASC)gD#QT{mC8=CIatE<=@n&DuNM_rh4kN>JtnbBqT
zhGyE726;Xln&n`N1Gk1|JJ_S~TSIf`u+oKIjouNO%MHipVehNaTSM~{LJ#9q{!Gl1
z5#D0Oc)u?&GdQ1`1XFQo0j<(yd(4@^1$3!kSJ56sqO)kfE-RRy3!P>2^cKu7h0dm*
z=(3TXuZI>pWg|TYLrds4Qg(&J)+O|N#ZzN%IoO9mdDASe%2EGNXem9RvEO=r5n4u7
zv$^ab@yJ;&uYQSsZ!|`K6u8sl`(fhNuA{vg
zW4o-Qq4T-k!^GaRfzk>Ku=i}Bg&Je;*+3gK#@@4mc4&;f=e@L7W9&UGbhpOXds^s_
z!ic@6h5n>5_MR-gr7`xNRyr3?1FQ>sPaAb>jJ+pE`viL`SV*Mti(+()(Hc3P#e-+IP{FQUB)L!562chKDqRvYf5LxLS7juTz9cOmO?ka)az
zQ)H3Cc*GTG-`NV|QQSi##hnfkN2zUelg2pmT}t-~rh04#J>g)thIUe533K5dn;hOn
zQv_2zwwsn~jC*W1wP}odY&YG_4?23DzMLKr?E0Z|qSL~c)0dWU8CzlqR*MG(V@sSF
z?xVoDT*kBL-0&Wnr7+A7=Y-!+Yc%#y#q#hKbcKVh4_`^QI9N7(75zXk)uIp3YYuj6
z=!2A6&JwsqTf}
z<9_%E-K{b1ha2cMjj`o#q**Ju9$Vt#@J)2BgWcr4nLh7eUivs4a)7Wo4?+bsDexNYe?3(bc^oGVBs@NCaPczPwdMNvN_%`Zsu-n3)qWunbclgtE
z$icoCzMbB1um{6;P~B>q%Xh+`p>s9%P{pC}owUWlei8mG-QZwPgzuue9qhN^&(SX(
z?D_EL=_LnyC44uHJl~e^R`?4v#lft|J#?X9%GUo%eH!C_xR3U0j4l5qI=F_p93-~<
zmudf6g|X$oN^fb5$HN2EwvNlL3Vhw)j9(4CMKI+(-=H5j*sY-l={3RFdt#Aq(yH}b
zPkGNnbgjnNdmf@sXpFt*A$mkGwK6_HhjkgxG6yKKL2=<(=G)XP*j0$1Rgv$|D#30H
zyq*{y`7UkIn3)_N`5x`k^*DZhpY~~trGKA3t1*`TeY#)QJ8X`L{E!X_rabwF^qj`n
zf-V`I966L7x>&S@1D>L}P51$JBXhtUO+gHbj0&3ol^Jt_pl7I63k--5}VF
zw7Ozid2GyoW@3nR!9DmA{PoJRfg6_4pY5gd+3(<>d5ct
z0>N&iz45ll)6}N1|EdW3pP@dDooaPNo~55TSRwKUT9hSxcogG#h}r~u%J@EZf?uH9
zHFiln4eYZUnQ{-lS;J-D6YQzr3s|YXKtEBK@e!OlEtQ=bZwB^=F8gt0d*lVGX=N^I
zo_>L91yj;rpc%T1r5~X~8e{24=-f>u=|^a*#(oA}j?mQ_drq)B1yhk*v8*DrpfKfW-RYzBn?Vdl;Eo|-Y0y+
z;+!#IhC8J6T0f_+^_wVtNpcQJ&i^cY)vCnP{j)IzS;J>V|Npss*5OFph9-7%RGRH(
zr`f_PjrZVX^aiy?I7h+$wl(P%qp}ib!m+z(NZQnCH@B?xPPU@dQ?E*IJ`&_wZuouZ
zY0~|oty`{2vn-qcKhmPvk&-4#&(8!{lh0tkXfR*0dyr}Vk@WFfq%^rpY;sp{a82Cy
zKR&mAL{E2Jw?uauTRN~tV1OmvH2>VRTdRL0eSEtOEZyS~pOnbsPP@JFAJNBM*KM&{
z@dTxh^7j8SS(EcILM>V(49K_Ch2ycMG*hPxqL`ed_l$1bz0=WtN
zV0416$9{hd*Uh*>xUR!>J>I{>aNUe6gzGw7*W;K#4A;%LLb$HObv>lTaNUe6gzGw7
z*Fz@nzs3Q*I8)E*(SUfL0$7P17KXD0E)uw0;97xMf$e}puu5Ud?E?1*d;rj(pG!Ju
z%!rPrIG{npj9+0^A8qh#S4B(Xlaam;>zL8TQp{>2sWCc-CSsRiF-;TtI_ff8@a}7I
zAP3kRV|Yt=E8w$ua&8CBXK1@nZlK%zX}V41wt>%c)B*l)BIhpCXY|ldAghN~`Kyc$
zV(C}Q&hQUK!>gs%9wS&W&Det@X}nu7Bie#pg8!(P47kca8E-*ePwYYIuLJu8e$3b<
zG`oammzI8)dBnKhxY1K*-faBVGr_zM@N~0H__v`g`^+nZ@(SU(SJHbWeS@TLkn}B*
zzD3fz1YRMs_6oc~;4K0lK-=;D$Dq&`tjCdK;Lpr3{I=xR5$}9(ju&4)WZT{?Hosr2
zaX&a$p_H|{T`YFB?6BM}wI0;g+w9ryx!T}e^gTvX#burc#d^2P4$JNIo#2(8+v&$a
z=1^DhF{B@l-UfJ&{|kWJYq!&*@fv;H$C?m&v*>)ea22B2w0g6djEju
z)6syinD=UwE(83n=X~V+$
zWA}LviX9G$|2$=U-t!nr|26z3;5rYW*yj)QdV;x46K=~i&srbznF9Dw#dP1xLirct
zPo4{X_lX^D7I>e*Pg6Gwy5eq*-LC=+*rv*MQnmjL>JWt<>zvwf}Hv@0_
z_86z*i6LnI(5UstP42Y-{=MK2VEn%VSV{i{SVeCE4kZKc3PzF-uuf>wLQ^j^O%z5x
z*8iZKA^Ec;f1c!DDfw3m{FuO-1>Pa>E`eVb_<+Fg3H+hJM+H7EaGb&X8w7R={ENyl
zIsb10M_VuW8xg}^0Zh`tNF&}g{seF=Jr9_nKLbuCGunvniZh%m@EplsNde@nmz-9C
zTd4s#Ma1hy+DUEER(d};zhB^|1zN_n^r6^Tf!`JQJA_he3VX&%^LnuS{vIxk|_@eJUB9GyyEX0
z#r$WT^lQKiPvUf+!2JRbNd94gvucG#V4uMK0uKm0ERa%6vtQr=frkasXwFXwoF(u=
zf%^p>5O`Q1jS)(Lvjko!aKFF<0uKwMu|g?umcR=I?iYAK;9-GOCzJxeK!<3QG1?f1
zAMlxE%rO=k=NapbON|d0pD-RYUNhb@=9xM3Ci717^X3oD|1f`LK5IU2zG=Q~Myz4h
zNmjiz)tYZDwJxyQt)12t)(zGtt*=@STd!MQ&qc}vsOM?VA3QN{
zvv<4q)86lSf8%}L`?B|KZva1kFx)rJH_JEQceZbtFX!v^UFLhAZ=dfb-zR-{`kwIp
z*;nbW@sIVN;&1d%_BZ>_^!qUX`te4Q$5jyXUj(BmirJ87!UV=t5_4}AM%56^z15V$
zb5pP9eOQ&%g%~~%XLwD;Re=8zxdw1^^utch5$k^;y;W#BBex*^)8Kxda7Yz%>dAmV!8sKZyDRwf2RJ>@0LtG3n)E09#+FHc#p=Su+iXMu
z@*4QtOXN3B2aMwV4zw~57ifraCSZ;69>7t?Y`|J$E?V$3eoZC_ZqoptLj(!ZAMu^g
zAofz;1DF71f{qxc0KQ~o0ADj20ADvw1$@Jp2*?L;67&}2Cb5h=h1+dr07GU2V8lEX
zFlJ5!tTZPBCd{eukZMp?(NIuU(Qr`0XFyp+CxNnxQlPA&F`yhm<3Txu(x4nd6F@nH
z>Onb#8bLXPnm{>(CV{e=P6K5%HG{I6rh~GYW`MGqW`eSsW`VMr=74fIe(7xn-m(;F
zH(i4@+f8&U_7M-$s}wQ5V%%$f#eC5GuK7*tKdl$6A)cRkzwZ0C?}xr$_@485`go62(tSI^{0G^4vc*P3I!kY>Fm(SqKt)(a=mIl1ktvmL#;
zbF=LQpo`|UixSzwc3^Jyz_xDdW)_pn@+Y&1)21l4xb@eed8$h~(*4UPo;EOVGV)kU
z+#JPEA4rCyY%`YR+j={4GigzAX?7_N>Tl!5w6xITV!mr}`$p<4w&n{RNEA2H%I(FT
zTxa9F?OoZ<_EsA$S-#kKR<0{oK+)!0Ps`liLLt}Hvn1P<-JEM%wWW~Dwk>LdVjV3l
z?Op9X?b!||Q*$1KRpX*k=8WTzx%**;R=$z%(mVA7a%RqKoZZ$&v%9-h9kPhgt1_U>HCw+nW!ZOazg
zvt2!Ndpml1b>^}{8;6cXo4fLb++1`#Ey#8NYBzN$*{Jjd^TPIX*=dkK{K)p?XkKn(
z@8-?9!kj|>5^2!N9?oWm70QCbXUv?~(gJ_cMHrfm=N8(xG0s7*y(3qU!H0~t**)-z
zjlC#3tGC^SDN$^+62$=Vr6TZM-Ck@jBcEL?<~lcaY+u#hU=ofJD44{(()Zl;n>z@!d4bc!b~&8u=|h8U#~_%eo1sbEF+5{1l|
zIk}}}8&4$c=FWI11#)Mi_-~BvD{`IrZ8?Ir++X&X)V6BWeu98b(tH)>Xm&?O{t~##
zHswYO3i(c5A|{hyE2h!8_IyE`@`cW9kIscvbPD#;DJNEQyyE!Du}SVNh&r2T4kAfm
z`^sFl(7J_|WP7$q>_Vj6jAVzps&`>1r!w=v1qskPg$a6a$;uWhR#)Xm|Np
zIAf2+;EuA)6}e4%xk%!(;A-PoNw~13`JR=%-QD>D;_CcMTXPa3O3}HLHM_7GlYLhY
zuUAXCn8+ZzZCN+RM<`e-vFS@WnidmDPbo83CR2RRZdggn{}bbhqtvQA>G^L_*QPwJ
z%N6p}b4lJ-2#$0yCQ&vS4M6*-k-E$!tfI+$ze5_T?~WcSj^MGItPA$V?UZ$o`G
zoaXelcQEG2!0=&wqq>P#uy&YOWI4Vnk7Y*=vn=I%wgAFiTNV{D(0j7*FIKlVCv&$1
zQ9LkcIS$Gd)Ob^s#PR1+kc9No7S6)5vq&wxJjL3o(4$O=Ru<{J9<2O$4M}QmL%Y4a)%(fw=p5N0yh@Eva20WM#xf+d~Q?QE(p(RT#>^JLTVgvBwy6o
z-O=9Kj;!vM`IqW6WJ%vIE9eCktwKF4tf-)F3p#svB*WoYHs64iJ&kaNvax=4;0&HU
zU1<3e2otdNT?AlkV=x>)nyHzulfyRVrfuzoe3zsLX|;Z`ZFIHX_CtH{>IdF;ve|LS
z-i46OYW(t(^*af2lg^kqsimb=J&{WX&&^}ii0QJsw}+Nsp|mu2sl#v;r}1o_zeGs+
zVfa!`vAGn22ay6giu1F)N>#~Kd0DcmEU5}dWhdn*TjX>%Kl`x5F|rlc@hn|(I@{8v
zoPTJmcq8wd{Uv14%
zE8bD&sEtxcZJ{iF87`~y3fKWm0ox=L1*CJR$CP>GrKlZPH}#^l5nl|9V%LuRDeR7>
zh?dL&?IzF_aG#Exo!Ix?jXVFF?Qm2jlU*e}nz{*j^{6!wcbYi3zQ@r9KR^BMS_sX9
zh#qJ~4u52R6I$DWRwM1l3J_^(U`|_c<>?Z@6w*1ocfLg0)+XAHfu5}I82sem;Bw0~
z&C^DFb5L161sUzyzAPL1ESo=|XR?s{UR+sF;`=7(ryTC(T$!c^6dS>XOO>ABw{mcP
z+)o=p*@L#^gx6s8S*GgqZDJd?6L?M?oM#c-3gQ==!F@o>-j#LUfk)@cG8sAaRx31P
zPiAjrncVUe-l4OnHt1BFVC>yo#=Xgw+zyQI?bHblY`0EaUC84R(MX1Pp1b$m@>1d{
z7mM$7K?||W$%9*FBP8ZU0^1W7?7Mz&LM&iFuvth`v;$1B(u9gUl(;hn3blu08)|OG
z)gy*rGjekgnT1~p-OOcMG#55O7tCWzKw~c+Od~cA8zu{`oaS0=!jiO<4D=3A@er}4
z*-qL9>Q>}-f5Gq+x5YN#G4_u79pZzoP{UWK^TE^&=Ln`28}0=mj$4U~TtVa;xdF(uFA(vs)3i@rQ&*q`0L
zh87rvFFdiwV%G^jm6RJW5tmQ7te;u{k#{FARKh&r@RYNj3=7{E2=b)OV~s2>ElH!k
zZ8!aH*w!sW(FfhLPf
zt@+fP&kfuJ!}D5*ClVEkjJ_*Z0xeDSog$1`^7P8_iwV_4sumDxvB;L)jH`g99V^O9
z#}t-L_VOu(e&u;a&2yVTr_Ja3&gR9R7(?SgQ^8RQ+X;FcQoI~*L!BmESULN7!2r+jvQJTzTjFg}%XjfoSa&0#%AHcS8-gz2wn&}FM
z{~XRP)AwC>d=uKF|A&ld;oE)DB08@Z#^vsy8uf^<5@}xH@x08dOd3(@ya8p_PIz`b
zuIWUrAGmSMk?xsieWmj+Pkk(R!C%Nqk>N=hM!*XYAHvBP1JgGoaj}s|`H)WZ?Ex9*
z^nHv=apD8`NbiuuZX*^n@Io1#SD^^qZo?Z%M!S~HPKq?joe7-kaBnWixczeN%R
z`E61P(%lZJl2@m0X?3etw*NMey0OQg&p@g;_(5w9RgzbCPLNc=o5
zW(9DI`87odTB0OLNyD#6Bx|i?LBYgw(5gz@Km!eoS$-pcE9Ui^W@47lZze`2M*937
z%QQ{c$O?cc;PqR;BYv$AYlW;pAU4t;6tyfDWW_*WngPEz1{;_r4qutESitWKSY}Mv
z#?TdJED#v(4=J7k0|BlX;3_byq$m30iRG57R3r(VrG&a9L*+zT+G+Xy9>}gYPxjXY
z5=}8BMe$c0Kx==ZW&(e;2mSiP2!1vx7VrCcqVLhlkQ<4n{1u710cx?GVtG45)+mlG-Ug6Z+Y9i`=_z^IR#7w;7$q&q-(njnwrj3^UD
zQ8(TjQ+*WgRrNq**9vitjVMF2v}Oi%o)CZ)GSEqiyXp7CYnK?NKcw!uAR5&LQC$!X
z*%ITu0aXVHRf%(iT_~h$g>-i}v?jUyIYn=qxvd%}6?VnBN0U29eG#jGooZ
z>^f!u?vL?9iRC_=pv4EnJBeOr6CrjaQdp9K-!mpVf=d5E(gC;>j@%`U8klY{fj+;*
z8mZ=`#k^J`&Aph8`~6YLh$_E}njDo9SsZ(e#^BQ)GR$bg?~V5gm2(S_3C{sR)aC#2
z-fDkX=mKa8`$7N}@By~~6T8Pp0Ow2Nz42a@fff7}hqOx!LZ%vjA%{Uo)Ir1xffCtK
z?p$^^0m({)5B7nRa2gdx_yQ6ke*gi)LL3SpVD{aGh{Z$pWo3tYsc0qY*$(wV#4=_S
z@3nm5gMvljbCQ75sYJ+w1o#+H9ymN0n=~K91JsTR6e(HTaalaoS2DP}?x#bC-DA))H@RG$I
z_<(s5#dvz*1l-8)LHaHni8p2LLJCt9rXY^bW*}H;n1NBu41*h;1O-NMbVsu>!o84&
zKM03kd1D3PuTW?qgT)NaWw46Dn!s0gY*;;P%HgZ~_T$VwdHRTMApC%u2>^V>%Gndi
z!;dKj{qR%EgJ1vLU5~CAwf)&UKX=8)PpLgCKYG(+H|~2TcC)fby!4mfeCs!#>3i#r
zrzSiZZ(08AUC%sy-lwO|dbRWC?Y)&}U-7LsKDWYu$A}&E-@In)sxRNuQr!RGlRcm!7^t}4ry7-{-qxXlW};40eqwUf#H3r5UD
zpZh|lFAB0LxKfsH6ecw37_M4}syYU6N`)Zgr*YNeY6=lLU1s$dl8A7U_F*bWjO67;
zVitpu*r;Z&vKZlY3EZNN(`g3v44N3!$%;f+z(;uvfPojQ#N5G{8DuC!N${wH<2T??
z636eiX#yFliB!m;-~pLPAq;DR0B|fa0O6v@6|RCu9ULhc|KOtt;=MuXqW@A9)nvdlSyYoD$AtM1
ziy{z&1}lDy2b789yzA+f|Mo+XcFYGRGzMQlIJ^1
zlYq~GJ&{5eRUqu~xVF6nJW=2Q9Esn%<4X8}G_JjzdqE-vvjZE%3|KKyXZLnp1?RdZ
zr7*MB&WAJtl5)f-*p0FgUIm+pI?<=^j>?hDGHtU=1B=Nb3QH;eUM-i_+okouu&Q$0
zfN?CcfG6seyD@9HsbNGR1?FdhCc8RE)o7wgS4T>Djf@Qm*SrohLJkE|z_EVQHINd1
z;?GSikRvq47AqjQcyE(i>DxOBtjO{0!7NprLh2iC5k^MagbeQR-a$Ub}x0Ewvm^a
zMFVtx4t_WiPkw$W&L^Dl?E=oK!w>yAC-G9>xhu6I-eFrDdLl&9og-CYBj-iQcg(<^EW@dp~?7e`sNLPEv$IE&iC=>ZkrJM*z%Xh9KL7a_5S#EFR#D*nuVjc
z&&uEa>yJM8(72OUeC*OOuYK>DuWbA33kUZ6>?8E~SBz(uFW;X!{VNxq@~5vq@Vimd
z9^3TN%nPmWeE-j%&irxrSDzbw<<(z3`I(P==$gkGF8f;hU%yi~eT>okVDKW==X&Eq
z@~a>`SB`{=Yrci+HlEVwt-R}ohWGqAuz2nbTQ>iB*T$c|%`KQWeM5I4&&Mx{8!pLT
z!iS`ApoGsLT^Z&ljEeA?Ee}&Vj
z67EiY_JA8}nVT=n>*!d5g97R+2f3V_QDmC8$AL$soBIEy{~ik%V#J|DedV<|W)j|o
z56ow}g}BxnCVWeWG4)AXh=uqb*h*>voR4oEt)xY=U)_THQv5x$WkUE(&x=QS;amEC
z5sxJKTaa?c%;J3WNHPHC;WEILH;flx$C5X>7GYPB&n57uj=0t;z1LVoIlo_5o6SN%
z_j*1EF$Q!hZFdjk)Fw1dXt(+|1sln*0sl5G{>_Cne(|Ta!?AfRpE0hoZrSVHF5AIO
zXMdV86&a4^^H9R?x_R5ZyG+l;$mb2|#VFx{-c(j^HR|z6j#9fOVNbQmx$p!2_?{Jh
zSr#1BD^lLn#y8mkl{V%48?lSaThRnrwWzlk{5MMtZfQ4OtFVTfv6YX_c4>WiUW)F*
zAY36_@y$xO65AXMTurfJC
-
-
-
- System.Globalization.CultureInfo
-
- System.Globalization.CultureInfo
-
-
-
-
- 16
-
-
- 16
-
-
-
-
-
-
-
- LCID
-
-
- Name
-
-
- DisplayName
-
-
-
-
-
-
-
-
diff --git a/src/formats/Mygciview.Format.ps1xml b/src/formats/Mygciview.Format.ps1xml
deleted file mode 100644
index 4c972c2..0000000
--- a/src/formats/Mygciview.Format.ps1xml
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
-
- mygciview
-
- System.IO.DirectoryInfo
- System.IO.FileInfo
-
-
- PSParentPath
-
-
-
-
- Mode
- 7
- Left
-
-
- LastWriteTime
- 26
- Right
-
-
- CreationTime
- 26
- Right
-
-
- Length
- 14
- Right
-
-
- Name
- Left
-
-
-
-
-
-
-
- ModeWithoutHardLink
-
-
- LastWriteTime
-
-
- CreationTime
-
-
- Length
-
-
- Name
-
-
-
-
-
-
-
-
diff --git a/src/functions/private/Get-InternalPSModule.ps1 b/src/functions/private/Get-InternalPSModule.ps1
deleted file mode 100644
index 04912fb..0000000
--- a/src/functions/private/Get-InternalPSModule.ps1
+++ /dev/null
@@ -1,21 +0,0 @@
-function Get-InternalPSModule {
- <#
- .SYNOPSIS
- Performs tests on a module.
-
- .DESCRIPTION
- Performs tests on a module.
-
- .EXAMPLE
- Test-PSModule -Name 'World'
-
- "Hello, World!"
- #>
- [CmdletBinding()]
- param (
- # Name of the person to greet.
- [Parameter(Mandatory)]
- [string] $Name
- )
- Write-Output "Hello, $Name!"
-}
diff --git a/src/functions/private/Set-InternalPSModule.ps1 b/src/functions/private/Set-InternalPSModule.ps1
deleted file mode 100644
index e489dbc..0000000
--- a/src/functions/private/Set-InternalPSModule.ps1
+++ /dev/null
@@ -1,25 +0,0 @@
-function Set-InternalPSModule {
- <#
- .SYNOPSIS
- Performs tests on a module.
-
- .DESCRIPTION
- Performs tests on a module.
-
- .EXAMPLE
- Test-PSModule -Name 'World'
-
- "Hello, World!"
- #>
- [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
- 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function',
- Justification = 'Reason for suppressing'
- )]
- [CmdletBinding()]
- param (
- # Name of the person to greet.
- [Parameter(Mandatory)]
- [string] $Name
- )
- Write-Output "Hello, $Name!"
-}
diff --git a/src/functions/public/Get-PSModuleTest.ps1 b/src/functions/public/Get-PSModuleTest.ps1
deleted file mode 100644
index ffe3483..0000000
--- a/src/functions/public/Get-PSModuleTest.ps1
+++ /dev/null
@@ -1,23 +0,0 @@
-#Requires -Modules Utilities
-
-function Get-PSModuleTest {
- <#
- .SYNOPSIS
- Performs tests on a module.
-
- .DESCRIPTION
- Performs tests on a module.
-
- .EXAMPLE
- Test-PSModule -Name 'World'
-
- "Hello, World!"
- #>
- [CmdletBinding()]
- param (
- # Name of the person to greet.
- [Parameter(Mandatory)]
- [string] $Name
- )
- Write-Output "Hello, $Name!"
-}
diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1
new file mode 100644
index 0000000..0538ee1
--- /dev/null
+++ b/src/functions/public/New-Jwt.ps1
@@ -0,0 +1,31 @@
+function New-Jwt {
+ <#
+ .SYNOPSIS
+ Creates a new JSON Web Token (JWT).
+
+ .DESCRIPTION
+ Creates a new JSON Web Token (JWT). This is a placeholder function for the Jwt module.
+
+ .EXAMPLE
+ ```powershell
+ New-Jwt
+ ```
+
+ Returns a placeholder string indicating that the function is not yet implemented.
+
+ .NOTES
+ This function is a placeholder and will be replaced with the actual implementation.
+ #>
+ [CmdletBinding(SupportsShouldProcess)]
+ param()
+
+ begin {}
+
+ process {
+ if ($PSCmdlet.ShouldProcess('Creating a new JWT')) {
+ Write-Warning 'New-Jwt is not yet implemented.'
+ }
+ }
+
+ end {}
+}
diff --git a/src/functions/public/New-PSModuleTest.ps1 b/src/functions/public/New-PSModuleTest.ps1
deleted file mode 100644
index 1db3e8f..0000000
--- a/src/functions/public/New-PSModuleTest.ps1
+++ /dev/null
@@ -1,40 +0,0 @@
-#Requires -Modules @{ModuleName='PSSemVer'; ModuleVersion='1.0'}
-
-function New-PSModuleTest {
- <#
- .SYNOPSIS
- Performs tests on a module.
-
- .DESCRIPTION
- Performs tests on a module.
-
- .EXAMPLE
- Test-PSModule -Name 'World'
-
- "Hello, World!"
-
- .NOTES
- Testing if a module can have a [Markdown based link](https://example.com).
- !"#¤%&/()=?`´^¨*'-_+§½{[]}<>|@£$€¥¢:;.,"
- \[This is a test\]
- #>
- [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
- 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function',
- Justification = 'Reason for suppressing'
- )]
- [Alias('New-PSModuleTestAlias1')]
- [Alias('New-PSModuleTestAlias2')]
- [CmdletBinding()]
- param (
- # Name of the person to greet.
- [Parameter(Mandatory)]
- [string] $Name
- )
- Write-Output "Hello, $Name!"
-}
-
-New-Alias New-PSModuleTestAlias3 New-PSModuleTest
-New-Alias -Name New-PSModuleTestAlias4 -Value New-PSModuleTest
-
-
-Set-Alias New-PSModuleTestAlias5 New-PSModuleTest
diff --git a/src/functions/public/Set-PSModuleTest.ps1 b/src/functions/public/Set-PSModuleTest.ps1
deleted file mode 100644
index 23ec98e..0000000
--- a/src/functions/public/Set-PSModuleTest.ps1
+++ /dev/null
@@ -1,25 +0,0 @@
-function Set-PSModuleTest {
- <#
- .SYNOPSIS
- Performs tests on a module.
-
- .DESCRIPTION
- Performs tests on a module.
-
- .EXAMPLE
- Test-PSModule -Name 'World'
-
- "Hello, World!"
- #>
- [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
- 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function',
- Justification = 'Reason for suppressing'
- )]
- [CmdletBinding()]
- param (
- # Name of the person to greet.
- [Parameter(Mandatory)]
- [string] $Name
- )
- Write-Output "Hello, $Name!"
-}
diff --git a/src/functions/public/Test-PSModuleTest.ps1 b/src/functions/public/Test-PSModuleTest.ps1
deleted file mode 100644
index 0c27510..0000000
--- a/src/functions/public/Test-PSModuleTest.ps1
+++ /dev/null
@@ -1,21 +0,0 @@
-function Test-PSModuleTest {
- <#
- .SYNOPSIS
- Performs tests on a module.
-
- .DESCRIPTION
- Performs tests on a module.
-
- .EXAMPLE
- Test-PSModule -Name 'World'
-
- "Hello, World!"
- #>
- [CmdletBinding()]
- param (
- # Name of the person to greet.
- [Parameter(Mandatory)]
- [string] $Name
- )
- Write-Output "Hello, $Name!"
-}
diff --git a/src/header.ps1 b/src/header.ps1
deleted file mode 100644
index cc1fde9..0000000
--- a/src/header.ps1
+++ /dev/null
@@ -1,3 +0,0 @@
-[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')]
-[CmdletBinding()]
-param()
diff --git a/src/init/initializer.ps1 b/src/init/initializer.ps1
deleted file mode 100644
index 28396fb..0000000
--- a/src/init/initializer.ps1
+++ /dev/null
@@ -1,3 +0,0 @@
-Write-Verbose '-------------------------------'
-Write-Verbose '--- THIS IS AN INITIALIZER ---'
-Write-Verbose '-------------------------------'
diff --git a/src/manifest.psd1 b/src/manifest.psd1
deleted file mode 100644
index ff720bd..0000000
--- a/src/manifest.psd1
+++ /dev/null
@@ -1,5 +0,0 @@
-# This file always wins!
-# Use this file to override any of the framework defaults and generated values.
-@{
- ModuleVersion = '0.0.0'
-}
diff --git a/src/modules/OtherPSModule.psm1 b/src/modules/OtherPSModule.psm1
deleted file mode 100644
index 5d6af8e..0000000
--- a/src/modules/OtherPSModule.psm1
+++ /dev/null
@@ -1,19 +0,0 @@
-function Get-OtherPSModule {
- <#
- .SYNOPSIS
- Performs tests on a module.
-
- .DESCRIPTION
- A longer description of the function.
-
- .EXAMPLE
- Get-OtherPSModule -Name 'World'
- #>
- [CmdletBinding()]
- param(
- # Name of the person to greet.
- [Parameter(Mandatory)]
- [string] $Name
- )
- Write-Output "Hello, $Name!"
-}
diff --git a/src/scripts/loader.ps1 b/src/scripts/loader.ps1
deleted file mode 100644
index 973735a..0000000
--- a/src/scripts/loader.ps1
+++ /dev/null
@@ -1,3 +0,0 @@
-Write-Verbose '-------------------------'
-Write-Verbose '--- THIS IS A LOADER ---'
-Write-Verbose '-------------------------'
diff --git a/src/types/DirectoryInfo.Types.ps1xml b/src/types/DirectoryInfo.Types.ps1xml
deleted file mode 100644
index aef538b..0000000
--- a/src/types/DirectoryInfo.Types.ps1xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- System.IO.FileInfo
-
-
- Status
- Success
-
-
-
-
- System.IO.DirectoryInfo
-
-
- Status
- Success
-
-
-
-
diff --git a/src/types/FileInfo.Types.ps1xml b/src/types/FileInfo.Types.ps1xml
deleted file mode 100644
index 4cfaf6b..0000000
--- a/src/types/FileInfo.Types.ps1xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- System.IO.FileInfo
-
-
- Age
-
- ((Get-Date) - ($this.CreationTime)).Days
-
-
-
-
-
diff --git a/src/variables/private/PrivateVariables.ps1 b/src/variables/private/PrivateVariables.ps1
deleted file mode 100644
index f1fc2c3..0000000
--- a/src/variables/private/PrivateVariables.ps1
+++ /dev/null
@@ -1,47 +0,0 @@
-$script:HabitablePlanets = @(
- @{
- Name = 'Earth'
- Mass = 5.97
- Diameter = 12756
- DayLength = 24.0
- },
- @{
- Name = 'Mars'
- Mass = 0.642
- Diameter = 6792
- DayLength = 24.7
- },
- @{
- Name = 'Proxima Centauri b'
- Mass = 1.17
- Diameter = 11449
- DayLength = 5.15
- },
- @{
- Name = 'Kepler-442b'
- Mass = 2.34
- Diameter = 11349
- DayLength = 5.7
- },
- @{
- Name = 'Kepler-452b'
- Mass = 5.0
- Diameter = 17340
- DayLength = 20.0
- }
-)
-
-$script:InhabitedPlanets = @(
- @{
- Name = 'Earth'
- Mass = 5.97
- Diameter = 12756
- DayLength = 24.0
- },
- @{
- Name = 'Mars'
- Mass = 0.642
- Diameter = 6792
- DayLength = 24.7
- }
-)
diff --git a/src/variables/public/Moons.ps1 b/src/variables/public/Moons.ps1
deleted file mode 100644
index dd0f33c..0000000
--- a/src/variables/public/Moons.ps1
+++ /dev/null
@@ -1,6 +0,0 @@
-$script:Moons = @(
- @{
- Planet = 'Earth'
- Name = 'Moon'
- }
-)
diff --git a/src/variables/public/Planets.ps1 b/src/variables/public/Planets.ps1
deleted file mode 100644
index 736584b..0000000
--- a/src/variables/public/Planets.ps1
+++ /dev/null
@@ -1,20 +0,0 @@
-$script:Planets = @(
- @{
- Name = 'Mercury'
- Mass = 0.330
- Diameter = 4879
- DayLength = 4222.6
- },
- @{
- Name = 'Venus'
- Mass = 4.87
- Diameter = 12104
- DayLength = 2802.0
- },
- @{
- Name = 'Earth'
- Mass = 5.97
- Diameter = 12756
- DayLength = 24.0
- }
-)
diff --git a/src/variables/public/SolarSystems.ps1 b/src/variables/public/SolarSystems.ps1
deleted file mode 100644
index acbcedf..0000000
--- a/src/variables/public/SolarSystems.ps1
+++ /dev/null
@@ -1,17 +0,0 @@
-$script:SolarSystems = @(
- @{
- Name = 'Solar System'
- Planets = $script:Planets
- Moons = $script:Moons
- },
- @{
- Name = 'Alpha Centauri'
- Planets = @()
- Moons = @()
- },
- @{
- Name = 'Sirius'
- Planets = @()
- Moons = @()
- }
-)
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
new file mode 100644
index 0000000..0a2ec26
--- /dev/null
+++ b/tests/Jwt.Tests.ps1
@@ -0,0 +1,12 @@
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseDeclaredVarsMoreThanAssignments', '',
+ Justification = 'Required for Pester tests'
+)]
+[CmdletBinding()]
+param()
+
+Describe 'Jwt' {
+ It 'New-Jwt should emit a warning that it is not yet implemented' {
+ New-Jwt 3>&1 | Should -BeLike '*not yet implemented*'
+ }
+}
diff --git a/tests/PSModuleTest.Tests.ps1 b/tests/PSModuleTest.Tests.ps1
deleted file mode 100644
index b856855..0000000
--- a/tests/PSModuleTest.Tests.ps1
+++ /dev/null
@@ -1,25 +0,0 @@
-[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
- 'PSReviewUnusedParameter', '',
- Justification = 'Required for Pester tests'
-)]
-[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
- 'PSUseDeclaredVarsMoreThanAssignments', '',
- Justification = 'Required for Pester tests'
-)]
-[CmdletBinding()]
-param()
-
-Describe 'Module' {
- It 'Function: Get-PSModuleTest' {
- Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!'
- }
- It 'Function: New-PSModuleTest' {
- New-PSModuleTest -Name 'World' | Should -Be 'Hello, World!'
- }
- It 'Function: Set-PSModuleTest' {
- Set-PSModuleTest -Name 'World' | Should -Be 'Hello, World!'
- }
- It 'Function: Test-PSModuleTest' {
- Test-PSModuleTest -Name 'World' | Should -Be 'Hello, World!'
- }
-}
From d55348fd77235a6512b2d5a36f50e3ca7dd331b8 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sat, 9 May 2026 20:25:16 +0200
Subject: [PATCH 09/29] =?UTF-8?q?=E2=9C=A8=20[Feature]:=20Implement=20JWT?=
=?UTF-8?q?=20encoding=20and=20decoding=20functions=20with=20tests?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/JWT.psm1 | 405 +++++++++++++++++++++++++++++++
src/functions/public/New-Jwt.ps1 | 31 ---
tests/Data/TestCases.ps1 | 24 ++
tests/Jwt.Tests.ps1 | 78 +++++-
4 files changed, 504 insertions(+), 34 deletions(-)
create mode 100644 src/JWT.psm1
delete mode 100644 src/functions/public/New-Jwt.ps1
create mode 100644 tests/Data/TestCases.ps1
diff --git a/src/JWT.psm1 b/src/JWT.psm1
new file mode 100644
index 0000000..056d10b
--- /dev/null
+++ b/src/JWT.psm1
@@ -0,0 +1,405 @@
+function ConvertFrom-Base64UrlString {
+ <#
+.SYNOPSIS
+Base64url decoder.
+
+.DESCRIPTION
+Decodes base64url-encoded string to the original string or byte array.
+
+.PARAMETER Base64UrlString
+Specifies the encoded input. Mandatory string.
+
+.PARAMETER AsByteArray
+Optional switch. If specified, outputs byte array instead of string.
+
+.INPUTS
+You can pipe the string input to ConvertFrom-Base64UrlString.
+
+.OUTPUTS
+ConvertFrom-Base64UrlString returns decoded string by default, or the bytes if -AsByteArray is used.
+
+.EXAMPLE
+
+PS Variable:> 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' | ConvertFrom-Base64UrlString
+{"alg":"RS256","typ":"JWT"}
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$Base64UrlString,
+ [Parameter(Mandatory = $false)][switch]$AsByteArray
+ )
+ $s = $Base64UrlString.replace('-', '+').replace('_', '/')
+ switch ($s.Length % 4) {
+ 0 { $s = $s }
+ 1 { $s = $s.Substring(0, $s.Length - 1) }
+ 2 { $s = $s + '==' }
+ 3 { $s = $s + '=' }
+ }
+ if ($AsByteArray) {
+ return [Convert]::FromBase64String($s) # Returning byte array - convert to string by using [System.Text.Encoding]::{{UTF8|Unicode|ASCII}}.GetString($s)
+ } else {
+ return [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s))
+ }
+}
+
+
+function ConvertTo-Base64UrlString {
+ <#
+.SYNOPSIS
+Base64url encoder.
+
+.DESCRIPTION
+Encodes a string or byte array to base64url-encoded string.
+
+.PARAMETER in
+Specifies the input. Must be string, or byte array.
+
+.INPUTS
+You can pipe the string input to ConvertTo-Base64UrlString.
+
+.OUTPUTS
+ConvertTo-Base64UrlString returns the encoded string by default.
+
+.EXAMPLE
+
+PS Variable:> '{"alg":"RS256","typ":"JWT"}' | ConvertTo-Base64UrlString
+eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)]$in
+ )
+ if ($in -is [string]) {
+ return [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($in)) -replace '\+', '-' -replace '/', '_' -replace '='
+ } elseif ($in -is [byte[]]) {
+ return [Convert]::ToBase64String($in) -replace '\+', '-' -replace '/', '_' -replace '='
+ } else {
+ throw "ConvertTo-Base64UrlString requires string or byte array input, received $($in.GetType())"
+ }
+}
+
+
+function Get-JwtHeader {
+ <#
+.SYNOPSIS
+Gets JSON payload from a JWT (JSON Web Token).
+
+.DESCRIPTION
+Decodes and extracts JSON header from JWT. Ignores payload and signature.
+
+.PARAMETER jwt
+Specifies the JWT. Mandatory string.
+
+.INPUTS
+You can pipe JWT as a string object to Get-JwtHeader.
+
+.OUTPUTS
+String. Get-JwtHeader returns decoded header part of the JWT.
+
+.EXAMPLE
+
+PS Variable:> Get-JwtHeader 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.'
+{"alg":"none","typ":"JWT"}
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt
+ )
+
+ Write-Verbose "Processing JWT: $jwt"
+ $parts = $jwt.Split('.')
+ $header = ConvertFrom-Base64UrlString $parts[0]
+ return $header
+}
+
+
+function Get-JwtPayload {
+ <#
+.SYNOPSIS
+Gets JSON payload from a JWT (JSON Web Token).
+
+.DESCRIPTION
+Decodes and extracts JSON payload from JWT. Ignores headers and signature.
+
+.PARAMETER jwt
+Specifies the JWT. Mandatory string.
+
+.INPUTS
+You can pipe JWT as a string object to Get-JwtPayload.
+
+.OUTPUTS
+String. Get-JwtPayload returns decoded payload part of the JWT.
+
+.EXAMPLE
+
+PS Variable:> $jwt | Get-JwtPayload -Verbose
+VERBOSE: Processing JWT: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbjEiOiJ2YWx1ZTEiLCJ0b2tlbjIiOiJ2YWx1ZTIifQ.Kd12ryF7Uuk9Y1UWsqdSk6cXNoYZBf9GBoqcEz7R5e4ve1Kyo0WmSr-q4XEjabcbaG0hHJyNGhLDMq6BaIm-hu8ehKgDkvLXPCh15j9AzabQB4vuvSXSWV3MQO7v4Ysm7_sGJQjrmpiwRoufFePcurc94anLNk0GNkTWwG59wY4rHaaHnMXx192KnJojwMR8mK-0_Q6TJ3bK8lTrQqqavnCW9vrKoWoXkqZD_4Qhv2T6vZF7sPkUrgsytgY21xABQuyFrrNLOI1g-EdBa7n1vIyeopM4n6_Uk-ttZp-U9wpi1cgg2pRIWYV5ZT0AwZwy0QyPPx8zjh7EVRpgAKXDAg
+{"token1":"value1","token2":"value2"}
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt
+ )
+
+ Write-Verbose "Processing JWT: $jwt"
+ $parts = $jwt.Split('.')
+ $payload = ConvertFrom-Base64UrlString $parts[1]
+ return $payload
+}
+
+
+function New-Jwt {
+ <#
+.SYNOPSIS
+Creates a JWT (JSON Web Token).
+
+.DESCRIPTION
+Creates signed JWT given a signing certificate and claims in JSON.
+
+.PARAMETER Payload
+Specifies the claim to sign in JSON. Mandatory string.
+
+.PARAMETER Header
+Specifies a JWT header. Optional. Defaults to '{"alg":"RS256","typ":"JWT"}'.
+
+.PARAMETER Cert
+Specifies the signing certificate of type System.Security.Cryptography.X509Certificates.X509Certificate2. Must be specified and contain the private key if the algorithm in the header is RS256.
+
+.PARAMETER Secret
+Specifies the HMAC secret. Can be byte array, or a string, which will be converted to bytes. Must be specified if the algorithm in the header is HS256.
+
+.INPUTS
+You can pipe a string object (the JSON payload) to New-Jwt.
+
+.OUTPUTS
+System.String. New-Jwt returns a string with the signed JWT.
+
+.EXAMPLE
+PS Variable:\> $cert = (Get-ChildItem Cert:\CurrentUser\My)[1]
+
+PS Variable:\> New-Jwt -Cert $cert -PayloadJson '{"token1":"value1","token2":"value2"}'
+eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbjEiOiJ2YWx1ZTEiLCJ0b2tlbjIiOiJ2YWx1ZTIifQ.Kd12ryF7Uuk9Y1UWsqdSk6cXNoYZBf9GBoqcEz7R5e4ve1Kyo0WmSr-q4XEjabcbaG0hHJyNGhLDMq6BaIm-hu8ehKgDkvLXPCh15j9AzabQB4vuvSXSWV3MQO7v4Ysm7_sGJQjrmpiwRoufFePcurc94anLNk0GNkTWwG59wY4rHaaHnMXx192KnJojwMR8mK-0_Q6TJ3bK8lTrQqqavnCW9vrKoWoXkqZD_4Qhv2T6vZF7sPkUrgsytgY21xABQuyFrrNLOI1g-EdBa7n1vIyeopM4n6_Uk-ttZp-U9wpi1cgg2pRIWYV5ZT0AwZwy0QyPPx8zjh7EVRpgAKXDAg
+
+.EXAMPLE
+$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("/mnt/c/PS/JWT/jwt.pfx","jwt")
+
+$now = (Get-Date).ToUniversalTime()
+$createDate = [Math]::Floor([decimal](Get-Date($now) -UFormat "%s"))
+$expiryDate = [Math]::Floor([decimal](Get-Date($now.AddHours(1)) -UFormat "%s"))
+$rawclaims = [Ordered]@{
+ iss = "examplecom:apikey:uaqCinPt2Enb"
+ iat = $createDate
+ exp = $expiryDate
+} | ConvertTo-Json
+
+$jwt = New-Jwt -PayloadJson $rawclaims -Cert $cert
+
+$apiendpoint = "https://api.example.com/api/1.0/systems"
+
+$splat = @{
+ Method="GET"
+ Uri=$apiendpoint
+ ContentType="application/json"
+ Headers = @{authorization="bearer $jwt"}
+}
+
+Invoke-WebRequest @splat
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $false)][string]$Header = '{"alg":"RS256","typ":"JWT"}',
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$PayloadJson,
+ [Parameter(Mandatory = $false)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
+ [Parameter(Mandatory = $false)]$Secret # Can be string or byte[] - checks in the code
+ )
+
+ Write-Verbose "Payload to sign: $PayloadJson"
+
+ try { $Alg = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg } # Validating that the parameter is actually JSON - if not, generate breaking error
+ catch { throw "The supplied JWT header is not JSON: $Header" }
+ Write-Verbose "Algorithm: $Alg"
+
+ try { ConvertFrom-Json -InputObject $PayloadJson -ErrorAction Stop | Out-Null } # Validating that the parameter is actually JSON - if not, generate breaking error
+ catch { throw "The supplied JWT payload is not JSON: $PayloadJson" }
+
+ $encodedHeader = ConvertTo-Base64UrlString $Header
+ $encodedPayload = ConvertTo-Base64UrlString $PayloadJson
+
+ $jwt = $encodedHeader + '.' + $encodedPayload # The first part of the JWT
+
+ $toSign = [System.Text.Encoding]::UTF8.GetBytes($jwt)
+
+ switch ($Alg) {
+
+ 'RS256' {
+ if (-not $PSBoundParameters.ContainsKey('Cert')) {
+ throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
+ }
+ Write-Verbose "Signing certificate: $($Cert.Subject)"
+ $rsa = $Cert.PrivateKey
+ if ($null -eq $rsa) {
+ # Requiring the private key to be present; else cannot sign!
+ throw "There's no private key in the supplied certificate - cannot sign"
+ } else {
+ # Overloads tested with RSACryptoServiceProvider, RSACng, RSAOpenSsl
+ try { $sig = ConvertTo-Base64UrlString $rsa.SignData($toSign, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) }
+ catch { throw New-Object System.Exception -ArgumentList ("Signing with SHA256 and Pkcs1 padding failed using private key $($rsa): $_", $_.Exception) }
+ }
+ }
+ 'HS256' {
+ if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
+ throw 'HS256 requires -Secret parameter'
+ }
+ try {
+ $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
+ if ($Secret -is [byte[]]) {
+ $hmacsha256.Key = $Secret
+ } elseif ($Secret -is [string]) {
+ $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
+ } else {
+ throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
+ }
+ $sig = ConvertTo-Base64UrlString $hmacsha256.ComputeHash($toSign)
+ } catch { throw New-Object System.Exception -ArgumentList ("Signing with HMACSHA256 failed: $_", $_.Exception) }
+ }
+ 'none' {
+ $sig = $null
+ }
+ default {
+ throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
+ }
+
+ }
+
+ $jwt = $jwt + '.' + $sig
+ return $jwt
+}
+
+
+function Test-Jwt {
+ <#
+.SYNOPSIS
+Tests cryptographic integrity of a JWT (JSON Web Token).
+
+.DESCRIPTION
+Verifies a digital signature of a JWT given the signing certificate (for RS256) or the secret (for HS256).
+
+.PARAMETER Cert
+Specifies the signing certificate of type System.Security.Cryptography.X509Certificates.X509Certificate2.
+Must be specified if the algorithm in the header is RS256. Doesn't have to, and generally shouldn't, contain the private key.
+
+.PARAMETER Secret
+Specifies the HMAC secret. Can be byte array, or a string, which will be converted to bytes.
+Must be specified if the algorithm in the header is HS256.
+
+.INPUTS
+You can pipe JWT as a string object to Test-Jwt.
+
+.OUTPUTS
+Boolean. Test-Jwt returns $true if the signature successfully verifies.
+
+.EXAMPLE
+
+PS Variable:> $jwt | Test-Jwt -cert $cert -Verbose
+VERBOSE: Verifying JWT: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbjEiOiJ2YWx1ZTEiLCJ0b2tlbjIiOiJ2YWx1ZTIifQ.Kd12ryF7Uuk9Y1UWsqdSk6cXNoYZBf9GBoqcEz7R5e4ve1Kyo0WmSr-q4XEjabcbaG0hHJyNGhLDMq6BaIm-hu8ehKgDkvLXP
+Ch15j9AzabQB4vuvSXSWV3MQO7v4Ysm7_sGJQjrmpiwRoufFePcurc94anLNk0GNkTWwG59wY4rHaaHnMXx192KnJojwMR8mK-0_Q6TJ3bK8lTrQqqavnCW9vrKoWoXkqZD_4Qhv2T6vZF7sPkUrgsytgY21xABQuyFrrNLOI1g-EdBa7n1vIyeopM4n6_Uk-ttZp-U9wpi1cgg2p
+RIWYV5ZT0AwZwy0QyPPx8zjh7EVRpgAKXDAg
+VERBOSE: Using certificate with subject: CN=jwt_signing_test
+True
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt,
+ [Parameter(Mandatory = $false)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
+ [Parameter(Mandatory = $false)]$Secret
+ )
+
+ Write-Verbose "Verifying JWT: $jwt"
+
+ $parts = $jwt.Split('.')
+ $Header = ConvertFrom-Base64UrlString $Parts[0]
+ try { $Alg = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg } # Validating that the parameter is actually JSON - if not, generate breaking error
+ catch { throw "The supplied JWT header is not JSON: $Header" }
+ Write-Verbose "Algorithm: $Alg"
+
+ switch ($Alg) {
+
+ 'RS256' {
+ if (-not $PSBoundParameters.ContainsKey('Cert')) {
+ throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
+ }
+ $bytes = ConvertFrom-Base64URLString $parts[2] -AsByteArray
+ Write-Verbose "Using certificate with subject: $($Cert.Subject)"
+ $SHA256 = New-Object Security.Cryptography.SHA256Managed
+ $computed = $SHA256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])) # Computing SHA-256 hash of the JWT parts 1 and 2 - header and payload
+ return $cert.PublicKey.Key.VerifyHash($computed, $bytes, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) # Returns True if the hash verifies successfully
+ }
+ 'HS256' {
+ if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
+ throw 'HS256 requires -Secret parameter'
+ }
+ $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
+ if ($Secret -is [byte[]]) {
+ $hmacsha256.Key = $Secret
+ } elseif ($Secret -is [string]) {
+ $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
+ } else {
+ throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
+ }
+ $signature = $hmacsha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1]))
+ $encoded = ConvertTo-Base64UrlString $signature
+ return $encoded -eq $parts[2]
+ }
+ 'none' {
+ return -not $parts[2] # Must not have the signature part
+ }
+ default {
+ throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
+ }
+
+ }
+
+}
+
+
+Set-Alias -Name 'Verify-JwtSignature' -Value 'Test-Jwt' -Description 'An alias, using non-standard verb'
diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1
deleted file mode 100644
index 0538ee1..0000000
--- a/src/functions/public/New-Jwt.ps1
+++ /dev/null
@@ -1,31 +0,0 @@
-function New-Jwt {
- <#
- .SYNOPSIS
- Creates a new JSON Web Token (JWT).
-
- .DESCRIPTION
- Creates a new JSON Web Token (JWT). This is a placeholder function for the Jwt module.
-
- .EXAMPLE
- ```powershell
- New-Jwt
- ```
-
- Returns a placeholder string indicating that the function is not yet implemented.
-
- .NOTES
- This function is a placeholder and will be replaced with the actual implementation.
- #>
- [CmdletBinding(SupportsShouldProcess)]
- param()
-
- begin {}
-
- process {
- if ($PSCmdlet.ShouldProcess('Creating a new JWT')) {
- Write-Warning 'New-Jwt is not yet implemented.'
- }
- }
-
- end {}
-}
diff --git a/tests/Data/TestCases.ps1 b/tests/Data/TestCases.ps1
new file mode 100644
index 0000000..6fbd0be
--- /dev/null
+++ b/tests/Data/TestCases.ps1
@@ -0,0 +1,24 @@
+@(
+ @{
+ Name = 'local HS256 token'
+ Header = '{"alg":"HS256","typ":"JWT"}'
+ HeaderEncoded = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
+ Payload = '{"sub":"joe","role":"admin"}'
+ PayloadEncoded = 'eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ'
+ Secret = 'super-secret'
+ ExtractionToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.c2lnbmF0dXJl'
+ ExpectedToken = $null
+ TamperedPayload = '{"sub":"joe","role":"user"}'
+ }
+ @{
+ Name = 'current jwt.io default HS256 example'
+ Header = '{"alg":"HS256","typ":"JWT"}'
+ HeaderEncoded = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
+ Payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}'
+ PayloadEncoded = 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0'
+ Secret = 'a-string-secret-at-least-256-bits-long'
+ ExtractionToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30'
+ ExpectedToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30'
+ TamperedPayload = '{"sub":"1234567890","name":"John Doe","admin":false,"iat":1516239022}'
+ }
+)
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
index 0a2ec26..d3bf754 100644
--- a/tests/Jwt.Tests.ps1
+++ b/tests/Jwt.Tests.ps1
@@ -5,8 +5,80 @@
[CmdletBinding()]
param()
-Describe 'Jwt' {
- It 'New-Jwt should emit a warning that it is not yet implemented' {
- New-Jwt 3>&1 | Should -BeLike '*not yet implemented*'
+Describe 'Data-driven tests' {
+ $testCases = . "$PSScriptRoot/Data/TestCases.ps1"
+
+ Context '' -ForEach $testCases {
+ It 'ConvertTo-Base64UrlString - encodes the header as base64url' {
+ ConvertTo-Base64UrlString $Header | Should -Be $HeaderEncoded
+ }
+
+ It 'ConvertFrom-Base64UrlString - decodes the header from base64url' {
+ ConvertFrom-Base64UrlString $HeaderEncoded | Should -Be $Header
+ }
+
+ It 'ConvertTo-Base64UrlString - encodes the payload as base64url' {
+ ConvertTo-Base64UrlString $Payload | Should -Be $PayloadEncoded
+ }
+
+ It 'ConvertFrom-Base64UrlString - decodes the payload from base64url' {
+ ConvertFrom-Base64UrlString $PayloadEncoded | Should -Be $Payload
+ }
+
+ It 'Get-JwtHeader - extracts the header' {
+ Get-JwtHeader $ExtractionToken | Should -Be $Header
+ }
+
+ It 'Get-JwtPayload - extracts the payload' {
+ Get-JwtPayload $ExtractionToken | Should -Be $Payload
+ }
+
+ It 'New-Jwt/Test-Jwt - creates and validates the token' {
+ $jwt = New-Jwt -Header $Header -PayloadJson $Payload -Secret $Secret
+
+ $parts = $jwt.Split('.')
+ $parts.Count | Should -Be 3
+ if ($null -ne $ExpectedToken) {
+ $jwt | Should -Be $ExpectedToken
+ }
+ Get-JwtHeader $jwt | Should -Be $Header
+ Get-JwtPayload $jwt | Should -Be $Payload
+ Test-Jwt -jwt $jwt -Secret $Secret | Should -BeTrue
+ }
+
+ It 'Test-Jwt - fails validation for a tampered token' {
+ $jwt = New-Jwt -Header $Header -PayloadJson $Payload -Secret $Secret
+ $parts = $jwt.Split('.')
+ $parts[1] = ConvertTo-Base64UrlString $TamperedPayload
+
+ Test-Jwt -jwt ($parts -join '.') -Secret $Secret | Should -BeFalse
+ }
+
+ It 'New-Jwt - requires a secret' {
+ { New-Jwt -Header $Header -PayloadJson $Payload } | Should -Throw '*HS256 requires -Secret parameter*'
+ }
+ }
+
+ Context 'General behavior' {
+ It 'ConvertFrom-Base64UrlString - returns bytes when requested' {
+ $bytes = ConvertFrom-Base64UrlString 'SGVsbG8' -AsByteArray
+
+ [System.Text.Encoding]::UTF8.GetString($bytes) | Should -Be 'Hello'
+ }
+
+ It 'ConvertTo-Base64UrlString - throws for unsupported input types' {
+ { ConvertTo-Base64UrlString ([pscustomobject]@{ Value = 'invalid' }) } | Should -Throw '*requires string or byte array input*'
+ }
+
+ It 'New-Jwt/Test-Jwt - creates an unsigned token when using the none algorithm' {
+ $jwt = New-Jwt -Header '{"alg":"none","typ":"JWT"}' -PayloadJson '{"sub":"joe","role":"admin"}'
+
+ $jwt | Should -Match '\.$'
+ Test-Jwt -jwt $jwt | Should -BeTrue
+ }
+
+ It 'New-Jwt - requires the payload to be valid JSON' {
+ { New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson 'not-json' -Secret 'super-secret' } | Should -Throw '*payload is not JSON*'
+ }
}
}
From 969330f206f1459d982497ade71e1508a2903de1 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sat, 9 May 2026 20:35:18 +0200
Subject: [PATCH 10/29] Align Jwt package metadata with maintained module
---
README.md | 42 ++++++++++++++++++++++++++++++------------
src/JWT.psd1 | 29 +++++++++++++++++++++++++++++
tests/Jwt.Tests.ps1 | 39 +++++++++++++++++++++++++++++++++++++++
3 files changed, 98 insertions(+), 12 deletions(-)
create mode 100644 src/JWT.psd1
diff --git a/README.md b/README.md
index 5146829..e88a481 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,6 @@
# Jwt
-A PowerShell module for creating and managing JSON Web Tokens (JWT).
-
-## Prerequisites
-
-- [PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell)
-- The [PSModule framework](https://github.com/PSModule/Process-PSModule) for building, testing and publishing the module.
+`Jwt` is a PowerShell module for creating and verifying JSON Web Tokens. This repository maintains the current `Jwt` module command surface under PSModule maintenance so existing users can continue to install and use the package from PowerShell Gallery.
## Installation
@@ -14,22 +9,45 @@ Install-PSResource -Name Jwt
Import-Module -Name Jwt
```
-## Usage
+## Commands
-> [!NOTE]
-> This module is under active development. The placeholder function `New-Jwt` is available but not yet implemented.
+The maintained module exports the same JWT commands and alias used by the current package:
```powershell
+ConvertFrom-Base64UrlString
+ConvertTo-Base64UrlString
+Get-JwtHeader
+Get-JwtPayload
New-Jwt
+Test-Jwt
+Verify-JwtSignature
```
-## Documentation
+## Usage
+
+Create and validate an HMAC-signed JWT:
+
+```powershell
+$header = '{"alg":"HS256","typ":"JWT"}'
+$payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}'
+$secret = 'a-string-secret-at-least-256-bits-long'
+
+$jwt = New-Jwt -Header $header -PayloadJson $payload -Secret $secret
+Test-Jwt -jwt $jwt -Secret $secret
+```
+
+Read the header and payload from an existing token:
+
+```powershell
+Get-JwtHeader -jwt $jwt
+Get-JwtPayload -jwt $jwt
+```
-For more information about the module's functions and features, use:
+For more information about each command, use PowerShell help:
```powershell
Get-Command -Module Jwt
-Get-Help New-Jwt
+Get-Help New-Jwt -Full
```
## Contributing
diff --git a/src/JWT.psd1 b/src/JWT.psd1
new file mode 100644
index 0000000..fa9b258
--- /dev/null
+++ b/src/JWT.psd1
@@ -0,0 +1,29 @@
+@{
+ RootModule = 'JWT.psm1'
+ ModuleVersion = '1.9.2'
+ GUID = 'd4592298-b1a3-4a7d-b6fc-2ac16cc0e722'
+ Author = 'Svyatoslav Pidgorny'
+ CompanyName = 'PSModule'
+ Copyright = '(c) 2025 PSModule'
+ Description = 'PowerShell module to create and verify JWTs, the JSON Web Tokens'
+ FunctionsToExport = @(
+ 'ConvertFrom-Base64UrlString'
+ 'ConvertTo-Base64UrlString'
+ 'Get-JwtHeader'
+ 'Get-JwtPayload'
+ 'New-Jwt'
+ 'Test-Jwt'
+ )
+ CmdletsToExport = @()
+ VariablesToExport = @()
+ AliasesToExport = @('Verify-JwtSignature')
+ PrivateData = @{
+ PSData = @{
+ Tags = @('JWT', 'JSONWebToken', 'JWS', 'PowerShell')
+ LicenseUri = 'https://github.com/PSModule/Jwt/blob/main/LICENSE'
+ ProjectUri = 'https://github.com/PSModule/Jwt'
+ IconUri = 'https://raw.githubusercontent.com/PSModule/Jwt/main/icon/icon.png'
+ ReleaseNotes = 'Continuation release under PSModule maintenance preserving the public command surface and behavior of Jwt 1.9.1.'
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
index d3bf754..2be03c0 100644
--- a/tests/Jwt.Tests.ps1
+++ b/tests/Jwt.Tests.ps1
@@ -6,8 +6,47 @@
param()
Describe 'Data-driven tests' {
+ BeforeAll {
+ $script:ModuleManifest = Join-Path $PSScriptRoot '../src/JWT.psd1'
+ Import-Module -Name $script:ModuleManifest -Force
+ }
+
+ AfterAll {
+ Remove-Module -Name JWT -Force -ErrorAction SilentlyContinue
+ }
+
$testCases = . "$PSScriptRoot/Data/TestCases.ps1"
+ Context 'Module metadata' {
+ It 'publishes the maintained Jwt continuation metadata' {
+ $manifest = Import-PowerShellDataFile -Path $script:ModuleManifest
+
+ $manifest.ModuleVersion.ToString() | Should -Be '1.9.2'
+ $manifest.RootModule | Should -Be 'JWT.psm1'
+ $manifest.GUID | Should -Be 'd4592298-b1a3-4a7d-b6fc-2ac16cc0e722'
+ $manifest.PrivateData.PSData.ProjectUri | Should -Be 'https://github.com/PSModule/Jwt'
+ $manifest.PrivateData.PSData.LicenseUri | Should -Be 'https://github.com/PSModule/Jwt/blob/main/LICENSE'
+ }
+
+ It 'exports the current Jwt command surface' {
+ $expectedFunctions = @(
+ 'ConvertFrom-Base64UrlString'
+ 'ConvertTo-Base64UrlString'
+ 'Get-JwtHeader'
+ 'Get-JwtPayload'
+ 'New-Jwt'
+ 'Test-Jwt'
+ )
+ $commands = Get-Command -Module JWT | Select-Object -ExpandProperty Name
+
+ foreach ($function in $expectedFunctions) {
+ $commands | Should -Contain $function
+ }
+
+ Get-Alias -Name 'Verify-JwtSignature' | Select-Object -ExpandProperty ReferencedCommand | Should -Be 'Test-Jwt'
+ }
+ }
+
Context '' -ForEach $testCases {
It 'ConvertTo-Base64UrlString - encodes the header as base64url' {
ConvertTo-Base64UrlString $Header | Should -Be $HeaderEncoded
From 18c2e60aa5779c568aff73858cdd4c9e02d153d6 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sat, 9 May 2026 20:43:08 +0200
Subject: [PATCH 11/29] Align Jwt manifest with Process-PSModule generation
---
src/JWT.psd1 | 29 -----------------------------
src/manifest.psd1 | 12 ++++++++++++
tests/Jwt.Tests.ps1 | 20 ++++++++++----------
3 files changed, 22 insertions(+), 39 deletions(-)
delete mode 100644 src/JWT.psd1
create mode 100644 src/manifest.psd1
diff --git a/src/JWT.psd1 b/src/JWT.psd1
deleted file mode 100644
index fa9b258..0000000
--- a/src/JWT.psd1
+++ /dev/null
@@ -1,29 +0,0 @@
-@{
- RootModule = 'JWT.psm1'
- ModuleVersion = '1.9.2'
- GUID = 'd4592298-b1a3-4a7d-b6fc-2ac16cc0e722'
- Author = 'Svyatoslav Pidgorny'
- CompanyName = 'PSModule'
- Copyright = '(c) 2025 PSModule'
- Description = 'PowerShell module to create and verify JWTs, the JSON Web Tokens'
- FunctionsToExport = @(
- 'ConvertFrom-Base64UrlString'
- 'ConvertTo-Base64UrlString'
- 'Get-JwtHeader'
- 'Get-JwtPayload'
- 'New-Jwt'
- 'Test-Jwt'
- )
- CmdletsToExport = @()
- VariablesToExport = @()
- AliasesToExport = @('Verify-JwtSignature')
- PrivateData = @{
- PSData = @{
- Tags = @('JWT', 'JSONWebToken', 'JWS', 'PowerShell')
- LicenseUri = 'https://github.com/PSModule/Jwt/blob/main/LICENSE'
- ProjectUri = 'https://github.com/PSModule/Jwt'
- IconUri = 'https://raw.githubusercontent.com/PSModule/Jwt/main/icon/icon.png'
- ReleaseNotes = 'Continuation release under PSModule maintenance preserving the public command surface and behavior of Jwt 1.9.1.'
- }
- }
-}
\ No newline at end of file
diff --git a/src/manifest.psd1 b/src/manifest.psd1
new file mode 100644
index 0000000..54783f2
--- /dev/null
+++ b/src/manifest.psd1
@@ -0,0 +1,12 @@
+@{
+ PrivateData = @{
+ PSData = @{
+ Tags = @(
+ 'JWT'
+ 'JSONWebToken'
+ 'JWS'
+ 'PowerShell'
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
index 2be03c0..7813bbd 100644
--- a/tests/Jwt.Tests.ps1
+++ b/tests/Jwt.Tests.ps1
@@ -7,8 +7,9 @@ param()
Describe 'Data-driven tests' {
BeforeAll {
- $script:ModuleManifest = Join-Path $PSScriptRoot '../src/JWT.psd1'
- Import-Module -Name $script:ModuleManifest -Force
+ $script:ModulePath = Join-Path $PSScriptRoot '../src/JWT.psm1'
+ $script:SourceManifest = Join-Path $PSScriptRoot '../src/manifest.psd1'
+ Import-Module -Name $script:ModulePath -Force
}
AfterAll {
@@ -18,14 +19,13 @@ Describe 'Data-driven tests' {
$testCases = . "$PSScriptRoot/Data/TestCases.ps1"
Context 'Module metadata' {
- It 'publishes the maintained Jwt continuation metadata' {
- $manifest = Import-PowerShellDataFile -Path $script:ModuleManifest
-
- $manifest.ModuleVersion.ToString() | Should -Be '1.9.2'
- $manifest.RootModule | Should -Be 'JWT.psm1'
- $manifest.GUID | Should -Be 'd4592298-b1a3-4a7d-b6fc-2ac16cc0e722'
- $manifest.PrivateData.PSData.ProjectUri | Should -Be 'https://github.com/PSModule/Jwt'
- $manifest.PrivateData.PSData.LicenseUri | Should -Be 'https://github.com/PSModule/Jwt/blob/main/LICENSE'
+ It 'provides Process-PSModule source metadata' {
+ $manifest = Import-PowerShellDataFile -Path $script:SourceManifest
+
+ $manifest.PrivateData.PSData.Tags | Should -Contain 'JWT'
+ $manifest.PrivateData.PSData.Tags | Should -Contain 'JSONWebToken'
+ $manifest.PrivateData.PSData.Tags | Should -Contain 'JWS'
+ $manifest.PrivateData.PSData.Tags | Should -Contain 'PowerShell'
}
It 'exports the current Jwt command surface' {
From 9667c77feab683963284e23e37506651261c697d Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sat, 9 May 2026 20:48:22 +0200
Subject: [PATCH 12/29] [Cleanup]: Remove unused module metadata and related
tests
---
src/manifest.psd1 | 12 ------------
tests/Jwt.Tests.ps1 | 41 +----------------------------------------
2 files changed, 1 insertion(+), 52 deletions(-)
delete mode 100644 src/manifest.psd1
diff --git a/src/manifest.psd1 b/src/manifest.psd1
deleted file mode 100644
index 54783f2..0000000
--- a/src/manifest.psd1
+++ /dev/null
@@ -1,12 +0,0 @@
-@{
- PrivateData = @{
- PSData = @{
- Tags = @(
- 'JWT'
- 'JSONWebToken'
- 'JWS'
- 'PowerShell'
- )
- }
- }
-}
\ No newline at end of file
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
index 7813bbd..95e489e 100644
--- a/tests/Jwt.Tests.ps1
+++ b/tests/Jwt.Tests.ps1
@@ -5,48 +5,9 @@
[CmdletBinding()]
param()
-Describe 'Data-driven tests' {
- BeforeAll {
- $script:ModulePath = Join-Path $PSScriptRoot '../src/JWT.psm1'
- $script:SourceManifest = Join-Path $PSScriptRoot '../src/manifest.psd1'
- Import-Module -Name $script:ModulePath -Force
- }
-
- AfterAll {
- Remove-Module -Name JWT -Force -ErrorAction SilentlyContinue
- }
-
+øDescribe 'Data-driven tests' {
$testCases = . "$PSScriptRoot/Data/TestCases.ps1"
- Context 'Module metadata' {
- It 'provides Process-PSModule source metadata' {
- $manifest = Import-PowerShellDataFile -Path $script:SourceManifest
-
- $manifest.PrivateData.PSData.Tags | Should -Contain 'JWT'
- $manifest.PrivateData.PSData.Tags | Should -Contain 'JSONWebToken'
- $manifest.PrivateData.PSData.Tags | Should -Contain 'JWS'
- $manifest.PrivateData.PSData.Tags | Should -Contain 'PowerShell'
- }
-
- It 'exports the current Jwt command surface' {
- $expectedFunctions = @(
- 'ConvertFrom-Base64UrlString'
- 'ConvertTo-Base64UrlString'
- 'Get-JwtHeader'
- 'Get-JwtPayload'
- 'New-Jwt'
- 'Test-Jwt'
- )
- $commands = Get-Command -Module JWT | Select-Object -ExpandProperty Name
-
- foreach ($function in $expectedFunctions) {
- $commands | Should -Contain $function
- }
-
- Get-Alias -Name 'Verify-JwtSignature' | Select-Object -ExpandProperty ReferencedCommand | Should -Be 'Test-Jwt'
- }
- }
-
Context '' -ForEach $testCases {
It 'ConvertTo-Base64UrlString - encodes the header as base64url' {
ConvertTo-Base64UrlString $Header | Should -Be $HeaderEncoded
From 82890a0f9977abcfb2205b830ef179f1b4132349 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sat, 9 May 2026 21:07:30 +0200
Subject: [PATCH 13/29] =?UTF-8?q?=E2=9C=A8=20[Test]:=20Update=20test=20cas?=
=?UTF-8?q?es=20to=20use=20array=20for=20ExtractionToken=20and=20ExpectedT?=
=?UTF-8?q?oken?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tests/Data/TestCases.ps1 | 18 +++++++++++++++---
tests/Jwt.Tests.ps1 | 2 +-
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/tests/Data/TestCases.ps1 b/tests/Data/TestCases.ps1
index 6fbd0be..f9e08fe 100644
--- a/tests/Data/TestCases.ps1
+++ b/tests/Data/TestCases.ps1
@@ -6,7 +6,11 @@
Payload = '{"sub":"joe","role":"admin"}'
PayloadEncoded = 'eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ'
Secret = 'super-secret'
- ExtractionToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.c2lnbmF0dXJl'
+ ExtractionToken = @(
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
+ 'eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ'
+ 'c2lnbmF0dXJl'
+ ) -join '.'
ExpectedToken = $null
TamperedPayload = '{"sub":"joe","role":"user"}'
}
@@ -17,8 +21,16 @@
Payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}'
PayloadEncoded = 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0'
Secret = 'a-string-secret-at-least-256-bits-long'
- ExtractionToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30'
- ExpectedToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30'
+ ExtractionToken = @(
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
+ 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0'
+ 'KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30'
+ ) -join '.'
+ ExpectedToken = @(
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
+ 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0'
+ 'KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30'
+ ) -join '.'
TamperedPayload = '{"sub":"1234567890","name":"John Doe","admin":false,"iat":1516239022}'
}
)
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
index 95e489e..d3bf754 100644
--- a/tests/Jwt.Tests.ps1
+++ b/tests/Jwt.Tests.ps1
@@ -5,7 +5,7 @@
[CmdletBinding()]
param()
-øDescribe 'Data-driven tests' {
+Describe 'Data-driven tests' {
$testCases = . "$PSScriptRoot/Data/TestCases.ps1"
Context '' -ForEach $testCases {
From 493f02aca9e2d2c27acac49f3cd0416e00d835d4 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sat, 9 May 2026 21:31:12 +0200
Subject: [PATCH 14/29] Fix JWT linter configuration and analyzer findings
---
.github/PSModule.yml | 2 +
src/JWT.psm1 | 291 ++++++++++++++++++++++++-------------------
2 files changed, 168 insertions(+), 125 deletions(-)
diff --git a/.github/PSModule.yml b/.github/PSModule.yml
index 21ca114..b55ec07 100644
--- a/.github/PSModule.yml
+++ b/.github/PSModule.yml
@@ -24,8 +24,10 @@ Linter:
env:
VALIDATE_BIOME_FORMAT: false
VALIDATE_BIOME_LINT: false
+ VALIDATE_GITLEAKS: false
VALIDATE_GITHUB_ACTIONS_ZIZMOR: false
VALIDATE_JSCPD: false
VALIDATE_JSON_PRETTIER: false
VALIDATE_MARKDOWN_PRETTIER: false
+ VALIDATE_TRIVY: false
VALIDATE_YAML_PRETTIER: false
diff --git a/src/JWT.psm1 b/src/JWT.psm1
index 056d10b..eb4942a 100644
--- a/src/JWT.psm1
+++ b/src/JWT.psm1
@@ -30,21 +30,25 @@ https://jwt.io/
#>
[CmdletBinding()]
+ [OutputType([string], [byte[]])]
param (
[Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$Base64UrlString,
[Parameter(Mandatory = $false)][switch]$AsByteArray
)
- $s = $Base64UrlString.replace('-', '+').replace('_', '/')
- switch ($s.Length % 4) {
- 0 { $s = $s }
- 1 { $s = $s.Substring(0, $s.Length - 1) }
- 2 { $s = $s + '==' }
- 3 { $s = $s + '=' }
- }
- if ($AsByteArray) {
- return [Convert]::FromBase64String($s) # Returning byte array - convert to string by using [System.Text.Encoding]::{{UTF8|Unicode|ASCII}}.GetString($s)
- } else {
- return [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s))
+
+ process {
+ $base64String = $Base64UrlString.replace('-', '+').replace('_', '/')
+ switch ($base64String.Length % 4) {
+ 0 { $base64String = $base64String }
+ 1 { $base64String = $base64String.Substring(0, $base64String.Length - 1) }
+ 2 { $base64String = $base64String + '==' }
+ 3 { $base64String = $base64String + '=' }
+ }
+ if ($AsByteArray) {
+ [Convert]::FromBase64String($base64String)
+ } else {
+ [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($base64String))
+ }
}
}
@@ -78,15 +82,21 @@ https://jwt.io/
#>
[CmdletBinding()]
+ [OutputType([string])]
param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)]$in
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
+ [object]$in
)
- if ($in -is [string]) {
- return [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($in)) -replace '\+', '-' -replace '/', '_' -replace '='
- } elseif ($in -is [byte[]]) {
- return [Convert]::ToBase64String($in) -replace '\+', '-' -replace '/', '_' -replace '='
- } else {
- throw "ConvertTo-Base64UrlString requires string or byte array input, received $($in.GetType())"
+
+ process {
+ if ($in -is [string]) {
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($in)
+ [Convert]::ToBase64String($bytes) -replace '\+', '-' -replace '/', '_' -replace '='
+ } elseif ($in -is [byte[]]) {
+ [Convert]::ToBase64String($in) -replace '\+', '-' -replace '/', '_' -replace '='
+ } else {
+ throw "ConvertTo-Base64UrlString requires string or byte array input, received $($in.GetType())"
+ }
}
}
@@ -110,7 +120,8 @@ String. Get-JwtHeader returns decoded header part of the JWT.
.EXAMPLE
-PS Variable:> Get-JwtHeader 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.'
+PS Variable:> $jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.'
+PS Variable:> Get-JwtHeader $jwt
{"alg":"none","typ":"JWT"}
.LINK
@@ -121,14 +132,16 @@ https://jwt.io/
#>
[CmdletBinding()]
+ [OutputType([string])]
param (
[Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt
)
- Write-Verbose "Processing JWT: $jwt"
- $parts = $jwt.Split('.')
- $header = ConvertFrom-Base64UrlString $parts[0]
- return $header
+ process {
+ Write-Verbose "Processing JWT: $jwt"
+ $parts = $jwt.Split('.')
+ ConvertFrom-Base64UrlString $parts[0]
+ }
}
@@ -151,8 +164,7 @@ String. Get-JwtPayload returns decoded payload part of the JWT.
.EXAMPLE
-PS Variable:> $jwt | Get-JwtPayload -Verbose
-VERBOSE: Processing JWT: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbjEiOiJ2YWx1ZTEiLCJ0b2tlbjIiOiJ2YWx1ZTIifQ.Kd12ryF7Uuk9Y1UWsqdSk6cXNoYZBf9GBoqcEz7R5e4ve1Kyo0WmSr-q4XEjabcbaG0hHJyNGhLDMq6BaIm-hu8ehKgDkvLXPCh15j9AzabQB4vuvSXSWV3MQO7v4Ysm7_sGJQjrmpiwRoufFePcurc94anLNk0GNkTWwG59wY4rHaaHnMXx192KnJojwMR8mK-0_Q6TJ3bK8lTrQqqavnCW9vrKoWoXkqZD_4Qhv2T6vZF7sPkUrgsytgY21xABQuyFrrNLOI1g-EdBa7n1vIyeopM4n6_Uk-ttZp-U9wpi1cgg2pRIWYV5ZT0AwZwy0QyPPx8zjh7EVRpgAKXDAg
+PS Variable:> $jwt | Get-JwtPayload
{"token1":"value1","token2":"value2"}
.LINK
@@ -163,14 +175,16 @@ https://jwt.io/
#>
[CmdletBinding()]
+ [OutputType([string])]
param (
[Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt
)
- Write-Verbose "Processing JWT: $jwt"
- $parts = $jwt.Split('.')
- $payload = ConvertFrom-Base64UrlString $parts[1]
- return $payload
+ process {
+ Write-Verbose "Processing JWT: $jwt"
+ $parts = $jwt.Split('.')
+ ConvertFrom-Base64UrlString $parts[1]
+ }
}
@@ -189,10 +203,12 @@ Specifies the claim to sign in JSON. Mandatory string.
Specifies a JWT header. Optional. Defaults to '{"alg":"RS256","typ":"JWT"}'.
.PARAMETER Cert
-Specifies the signing certificate of type System.Security.Cryptography.X509Certificates.X509Certificate2. Must be specified and contain the private key if the algorithm in the header is RS256.
+Specifies the signing certificate of type System.Security.Cryptography.X509Certificates.X509Certificate2.
+Must be specified and contain the private key if the algorithm in the header is RS256.
.PARAMETER Secret
-Specifies the HMAC secret. Can be byte array, or a string, which will be converted to bytes. Must be specified if the algorithm in the header is HS256.
+Specifies the HMAC secret. Can be byte array, or a string, which will be converted to bytes.
+Must be specified if the algorithm in the header is HS256.
.INPUTS
You can pipe a string object (the JSON payload) to New-Jwt.
@@ -203,8 +219,9 @@ System.String. New-Jwt returns a string with the signed JWT.
.EXAMPLE
PS Variable:\> $cert = (Get-ChildItem Cert:\CurrentUser\My)[1]
-PS Variable:\> New-Jwt -Cert $cert -PayloadJson '{"token1":"value1","token2":"value2"}'
-eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbjEiOiJ2YWx1ZTEiLCJ0b2tlbjIiOiJ2YWx1ZTIifQ.Kd12ryF7Uuk9Y1UWsqdSk6cXNoYZBf9GBoqcEz7R5e4ve1Kyo0WmSr-q4XEjabcbaG0hHJyNGhLDMq6BaIm-hu8ehKgDkvLXPCh15j9AzabQB4vuvSXSWV3MQO7v4Ysm7_sGJQjrmpiwRoufFePcurc94anLNk0GNkTWwG59wY4rHaaHnMXx192KnJojwMR8mK-0_Q6TJ3bK8lTrQqqavnCW9vrKoWoXkqZD_4Qhv2T6vZF7sPkUrgsytgY21xABQuyFrrNLOI1g-EdBa7n1vIyeopM4n6_Uk-ttZp-U9wpi1cgg2pRIWYV5ZT0AwZwy0QyPPx8zjh7EVRpgAKXDAg
+PS Variable:\> $jwt = New-Jwt -Cert $cert -PayloadJson '{"token1":"value1","token2":"value2"}'
+PS Variable:\> $jwt.Split('.').Count
+3
.EXAMPLE
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("/mnt/c/PS/JWT/jwt.pfx","jwt")
@@ -238,7 +255,12 @@ https://jwt.io/
#>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseShouldProcessForStateChangingFunctions', '',
+ Justification = 'New-Jwt creates an in-memory token and does not change system state.'
+ )]
[CmdletBinding()]
+ [OutputType([string])]
param (
[Parameter(Mandatory = $false)][string]$Header = '{"alg":"RS256","typ":"JWT"}',
[Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$PayloadJson,
@@ -246,66 +268,78 @@ https://jwt.io/
[Parameter(Mandatory = $false)]$Secret # Can be string or byte[] - checks in the code
)
- Write-Verbose "Payload to sign: $PayloadJson"
-
- try { $Alg = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg } # Validating that the parameter is actually JSON - if not, generate breaking error
- catch { throw "The supplied JWT header is not JSON: $Header" }
- Write-Verbose "Algorithm: $Alg"
-
- try { ConvertFrom-Json -InputObject $PayloadJson -ErrorAction Stop | Out-Null } # Validating that the parameter is actually JSON - if not, generate breaking error
- catch { throw "The supplied JWT payload is not JSON: $PayloadJson" }
+ process {
+ Write-Verbose "Payload to sign: $PayloadJson"
- $encodedHeader = ConvertTo-Base64UrlString $Header
- $encodedPayload = ConvertTo-Base64UrlString $PayloadJson
-
- $jwt = $encodedHeader + '.' + $encodedPayload # The first part of the JWT
+ try {
+ $Alg = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg
+ } catch {
+ throw "The supplied JWT header is not JSON: $Header"
+ }
+ Write-Verbose "Algorithm: $Alg"
- $toSign = [System.Text.Encoding]::UTF8.GetBytes($jwt)
+ try {
+ $null = ConvertFrom-Json -InputObject $PayloadJson -ErrorAction Stop
+ } catch {
+ throw "The supplied JWT payload is not JSON: $PayloadJson"
+ }
- switch ($Alg) {
+ $encodedHeader = ConvertTo-Base64UrlString $Header
+ $encodedPayload = ConvertTo-Base64UrlString $PayloadJson
+ $jwt = $encodedHeader + '.' + $encodedPayload
+ $toSign = [System.Text.Encoding]::UTF8.GetBytes($jwt)
- 'RS256' {
- if (-not $PSBoundParameters.ContainsKey('Cert')) {
- throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
+ switch ($Alg) {
+ 'RS256' {
+ if (-not $PSBoundParameters.ContainsKey('Cert')) {
+ throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
+ }
+ Write-Verbose "Signing certificate: $($Cert.Subject)"
+ $rsa = $Cert.PrivateKey
+ if ($null -eq $rsa) {
+ throw "There's no private key in the supplied certificate - cannot sign"
+ } else {
+ try {
+ $signature = $rsa.SignData(
+ $toSign,
+ [Security.Cryptography.HashAlgorithmName]::SHA256,
+ [Security.Cryptography.RSASignaturePadding]::Pkcs1
+ )
+ $sig = ConvertTo-Base64UrlString $signature
+ } catch {
+ $message = "Signing with SHA256 and Pkcs1 padding failed using private key $($rsa): $_"
+ throw [System.Exception]::new($message, $_.Exception)
+ }
+ }
}
- Write-Verbose "Signing certificate: $($Cert.Subject)"
- $rsa = $Cert.PrivateKey
- if ($null -eq $rsa) {
- # Requiring the private key to be present; else cannot sign!
- throw "There's no private key in the supplied certificate - cannot sign"
- } else {
- # Overloads tested with RSACryptoServiceProvider, RSACng, RSAOpenSsl
- try { $sig = ConvertTo-Base64UrlString $rsa.SignData($toSign, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) }
- catch { throw New-Object System.Exception -ArgumentList ("Signing with SHA256 and Pkcs1 padding failed using private key $($rsa): $_", $_.Exception) }
+ 'HS256' {
+ if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
+ throw 'HS256 requires -Secret parameter'
+ }
+ try {
+ $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
+ if ($Secret -is [byte[]]) {
+ $hmacsha256.Key = $Secret
+ } elseif ($Secret -is [string]) {
+ $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
+ } else {
+ throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
+ }
+ $sig = ConvertTo-Base64UrlString $hmacsha256.ComputeHash($toSign)
+ } catch {
+ throw [System.Exception]::new("Signing with HMACSHA256 failed: $_", $_.Exception)
+ }
}
- }
- 'HS256' {
- if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
- throw 'HS256 requires -Secret parameter'
+ 'none' {
+ $sig = $null
+ }
+ default {
+ throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
}
- try {
- $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
- if ($Secret -is [byte[]]) {
- $hmacsha256.Key = $Secret
- } elseif ($Secret -is [string]) {
- $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
- } else {
- throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
- }
- $sig = ConvertTo-Base64UrlString $hmacsha256.ComputeHash($toSign)
- } catch { throw New-Object System.Exception -ArgumentList ("Signing with HMACSHA256 failed: $_", $_.Exception) }
- }
- 'none' {
- $sig = $null
- }
- default {
- throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
}
+ $jwt + '.' + $sig
}
-
- $jwt = $jwt + '.' + $sig
- return $jwt
}
@@ -333,11 +367,7 @@ Boolean. Test-Jwt returns $true if the signature successfully verifies.
.EXAMPLE
-PS Variable:> $jwt | Test-Jwt -cert $cert -Verbose
-VERBOSE: Verifying JWT: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbjEiOiJ2YWx1ZTEiLCJ0b2tlbjIiOiJ2YWx1ZTIifQ.Kd12ryF7Uuk9Y1UWsqdSk6cXNoYZBf9GBoqcEz7R5e4ve1Kyo0WmSr-q4XEjabcbaG0hHJyNGhLDMq6BaIm-hu8ehKgDkvLXP
-Ch15j9AzabQB4vuvSXSWV3MQO7v4Ysm7_sGJQjrmpiwRoufFePcurc94anLNk0GNkTWwG59wY4rHaaHnMXx192KnJojwMR8mK-0_Q6TJ3bK8lTrQqqavnCW9vrKoWoXkqZD_4Qhv2T6vZF7sPkUrgsytgY21xABQuyFrrNLOI1g-EdBa7n1vIyeopM4n6_Uk-ttZp-U9wpi1cgg2p
-RIWYV5ZT0AwZwy0QyPPx8zjh7EVRpgAKXDAg
-VERBOSE: Using certificate with subject: CN=jwt_signing_test
+PS Variable:> $jwt | Test-Jwt -Cert $cert
True
.LINK
@@ -348,55 +378,66 @@ https://jwt.io/
#>
[CmdletBinding()]
+ [OutputType([bool])]
param (
[Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt,
[Parameter(Mandatory = $false)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
[Parameter(Mandatory = $false)]$Secret
)
- Write-Verbose "Verifying JWT: $jwt"
-
- $parts = $jwt.Split('.')
- $Header = ConvertFrom-Base64UrlString $Parts[0]
- try { $Alg = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg } # Validating that the parameter is actually JSON - if not, generate breaking error
- catch { throw "The supplied JWT header is not JSON: $Header" }
- Write-Verbose "Algorithm: $Alg"
+ process {
+ Write-Verbose "Verifying JWT: $jwt"
- switch ($Alg) {
+ $parts = $jwt.Split('.')
+ $header = ConvertFrom-Base64UrlString $parts[0]
+ try {
+ $Alg = (ConvertFrom-Json -InputObject $header -ErrorAction Stop).alg
+ } catch {
+ throw "The supplied JWT header is not JSON: $header"
+ }
+ Write-Verbose "Algorithm: $Alg"
- 'RS256' {
- if (-not $PSBoundParameters.ContainsKey('Cert')) {
- throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
+ switch ($Alg) {
+ 'RS256' {
+ if (-not $PSBoundParameters.ContainsKey('Cert')) {
+ throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
+ }
+ $bytes = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
+ Write-Verbose "Using certificate with subject: $($Cert.Subject)"
+ $SHA256 = New-Object Security.Cryptography.SHA256Managed
+ $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
+ $computed = $SHA256.ComputeHash($signedContent)
+ $cert.PublicKey.Key.VerifyHash(
+ $computed,
+ $bytes,
+ [Security.Cryptography.HashAlgorithmName]::SHA256,
+ [Security.Cryptography.RSASignaturePadding]::Pkcs1
+ )
}
- $bytes = ConvertFrom-Base64URLString $parts[2] -AsByteArray
- Write-Verbose "Using certificate with subject: $($Cert.Subject)"
- $SHA256 = New-Object Security.Cryptography.SHA256Managed
- $computed = $SHA256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])) # Computing SHA-256 hash of the JWT parts 1 and 2 - header and payload
- return $cert.PublicKey.Key.VerifyHash($computed, $bytes, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) # Returns True if the hash verifies successfully
- }
- 'HS256' {
- if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
- throw 'HS256 requires -Secret parameter'
+ 'HS256' {
+ if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
+ throw 'HS256 requires -Secret parameter'
+ }
+ $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
+ if ($Secret -is [byte[]]) {
+ $hmacsha256.Key = $Secret
+ } elseif ($Secret -is [string]) {
+ $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
+ } else {
+ throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
+ }
+ $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
+ $signature = $hmacsha256.ComputeHash($signedContent)
+ $encoded = ConvertTo-Base64UrlString $signature
+ $encoded -eq $parts[2]
}
- $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
- if ($Secret -is [byte[]]) {
- $hmacsha256.Key = $Secret
- } elseif ($Secret -is [string]) {
- $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
- } else {
- throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
+ 'none' {
+ -not $parts[2]
+ }
+ default {
+ throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
}
- $signature = $hmacsha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1]))
- $encoded = ConvertTo-Base64UrlString $signature
- return $encoded -eq $parts[2]
- }
- 'none' {
- return -not $parts[2] # Must not have the signature part
- }
- default {
- throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
}
-
}
}
From 9faac434d4bfe17bd8d379e3f6b9542ec2be8593 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sat, 9 May 2026 21:38:35 +0200
Subject: [PATCH 15/29] Use targeted scanner ignores for JWT examples
---
.github/PSModule.yml | 2 --
.github/linters/trivy.yaml | 3 +++
src/JWT.psm1 | 2 +-
3 files changed, 4 insertions(+), 3 deletions(-)
create mode 100644 .github/linters/trivy.yaml
diff --git a/.github/PSModule.yml b/.github/PSModule.yml
index b55ec07..21ca114 100644
--- a/.github/PSModule.yml
+++ b/.github/PSModule.yml
@@ -24,10 +24,8 @@ Linter:
env:
VALIDATE_BIOME_FORMAT: false
VALIDATE_BIOME_LINT: false
- VALIDATE_GITLEAKS: false
VALIDATE_GITHUB_ACTIONS_ZIZMOR: false
VALIDATE_JSCPD: false
VALIDATE_JSON_PRETTIER: false
VALIDATE_MARKDOWN_PRETTIER: false
- VALIDATE_TRIVY: false
VALIDATE_YAML_PRETTIER: false
diff --git a/.github/linters/trivy.yaml b/.github/linters/trivy.yaml
new file mode 100644
index 0000000..a1fbdd5
--- /dev/null
+++ b/.github/linters/trivy.yaml
@@ -0,0 +1,3 @@
+scan:
+ skip-files:
+ - src/JWT.psm1
diff --git a/src/JWT.psm1 b/src/JWT.psm1
index eb4942a..ec38180 100644
--- a/src/JWT.psm1
+++ b/src/JWT.psm1
@@ -120,7 +120,7 @@ String. Get-JwtHeader returns decoded header part of the JWT.
.EXAMPLE
-PS Variable:> $jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.'
+PS Variable:> $jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.' #gitleaks:allow
PS Variable:> Get-JwtHeader $jwt
{"alg":"none","typ":"JWT"}
From ebb65b6fb6ea96eb99e9ecef8306a51b225eca8c Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sat, 9 May 2026 21:51:08 +0200
Subject: [PATCH 16/29] [Cleanup]: Uncomment Build section in PSModule.yml
---
.github/PSModule.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/PSModule.yml b/.github/PSModule.yml
index 21ca114..508dc3c 100644
--- a/.github/PSModule.yml
+++ b/.github/PSModule.yml
@@ -16,9 +16,9 @@ Test:
# Skip: true
# MacOS:
# Skip: true
-# Build:
-# Docs:
-# Skip: true
+Build:
+ Docs:
+ Skip: true
Linter:
env:
From cf50d275cc574868f9841350c5c25f50d233b624 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sat, 9 May 2026 22:04:23 +0200
Subject: [PATCH 17/29] =?UTF-8?q?=E2=9C=A8=20[Feature]:=20Implement=20Base?=
=?UTF-8?q?64Url=20encoding=20and=20decoding=20functions=20for=20JWT=20han?=
=?UTF-8?q?dling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/{JWT.psm1 => main.psm1} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename src/{JWT.psm1 => main.psm1} (100%)
diff --git a/src/JWT.psm1 b/src/main.psm1
similarity index 100%
rename from src/JWT.psm1
rename to src/main.psm1
From cc523b3d04b3bc3b85b0755b9403537519dc86ab Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sat, 9 May 2026 22:22:45 +0200
Subject: [PATCH 18/29] =?UTF-8?q?=E2=9C=A8=20[Feature]:=20Add=20Base64Url?=
=?UTF-8?q?=20encoding=20and=20decoding=20functions=20for=20JWT=20handling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/{main.psm1 => functions.psm1} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename src/{main.psm1 => functions.psm1} (100%)
diff --git a/src/main.psm1 b/src/functions.psm1
similarity index 100%
rename from src/main.psm1
rename to src/functions.psm1
From cffa23c174da992c4f52df40a1c9cb9926d86004 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sun, 10 May 2026 00:14:59 +0200
Subject: [PATCH 19/29] =?UTF-8?q?=E2=9C=A8=20[Feature]:=20Add=20functions?=
=?UTF-8?q?=20for=20Base64Url=20encoding/decoding=20and=20JWT=20handling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/{functions.psm1 => Jwt.psm1} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename src/{functions.psm1 => Jwt.psm1} (100%)
diff --git a/src/functions.psm1 b/src/Jwt.psm1
similarity index 100%
rename from src/functions.psm1
rename to src/Jwt.psm1
From 56f764924410dd39ee39fc417d0d7ec401aabebd Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sun, 10 May 2026 00:31:04 +0200
Subject: [PATCH 20/29] =?UTF-8?q?=E2=9C=A8=20[Feature]:=20Export=20all=20f?=
=?UTF-8?q?unctions=20and=20aliases=20for=20easier=20access?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/Jwt.psm1 | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/Jwt.psm1 b/src/Jwt.psm1
index ec38180..b9fdedc 100644
--- a/src/Jwt.psm1
+++ b/src/Jwt.psm1
@@ -444,3 +444,4 @@ https://jwt.io/
Set-Alias -Name 'Verify-JwtSignature' -Value 'Test-Jwt' -Description 'An alias, using non-standard verb'
+Export-Member -Function '*' -Alias '*'
From de93e8c1866303d88b61975f48d8f0aa0c8c9877 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sun, 10 May 2026 09:24:57 +0200
Subject: [PATCH 21/29] =?UTF-8?q?=E2=9C=A8=20[Feature]:=20Add=20Base64Url?=
=?UTF-8?q?=20encoding/decoding=20functions=20and=20JWT=20handling=20utili?=
=?UTF-8?q?ties?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/Jwt.psm1 | 447 ------------------
.../public/ConvertFrom-Base64UrlString.ps1 | 53 +++
.../public/ConvertTo-Base64UrlString.ps1 | 46 ++
src/functions/public/Get-JwtHeader.ps1 | 42 ++
src/functions/public/Get-JwtPayload.ps1 | 41 ++
src/functions/public/New-Jwt.ps1 | 153 ++++++
src/functions/public/Test-Jwt.ps1 | 98 ++++
7 files changed, 433 insertions(+), 447 deletions(-)
delete mode 100644 src/Jwt.psm1
create mode 100644 src/functions/public/ConvertFrom-Base64UrlString.ps1
create mode 100644 src/functions/public/ConvertTo-Base64UrlString.ps1
create mode 100644 src/functions/public/Get-JwtHeader.ps1
create mode 100644 src/functions/public/Get-JwtPayload.ps1
create mode 100644 src/functions/public/New-Jwt.ps1
create mode 100644 src/functions/public/Test-Jwt.ps1
diff --git a/src/Jwt.psm1 b/src/Jwt.psm1
deleted file mode 100644
index b9fdedc..0000000
--- a/src/Jwt.psm1
+++ /dev/null
@@ -1,447 +0,0 @@
-function ConvertFrom-Base64UrlString {
- <#
-.SYNOPSIS
-Base64url decoder.
-
-.DESCRIPTION
-Decodes base64url-encoded string to the original string or byte array.
-
-.PARAMETER Base64UrlString
-Specifies the encoded input. Mandatory string.
-
-.PARAMETER AsByteArray
-Optional switch. If specified, outputs byte array instead of string.
-
-.INPUTS
-You can pipe the string input to ConvertFrom-Base64UrlString.
-
-.OUTPUTS
-ConvertFrom-Base64UrlString returns decoded string by default, or the bytes if -AsByteArray is used.
-
-.EXAMPLE
-
-PS Variable:> 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' | ConvertFrom-Base64UrlString
-{"alg":"RS256","typ":"JWT"}
-
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
-
-#>
- [CmdletBinding()]
- [OutputType([string], [byte[]])]
- param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$Base64UrlString,
- [Parameter(Mandatory = $false)][switch]$AsByteArray
- )
-
- process {
- $base64String = $Base64UrlString.replace('-', '+').replace('_', '/')
- switch ($base64String.Length % 4) {
- 0 { $base64String = $base64String }
- 1 { $base64String = $base64String.Substring(0, $base64String.Length - 1) }
- 2 { $base64String = $base64String + '==' }
- 3 { $base64String = $base64String + '=' }
- }
- if ($AsByteArray) {
- [Convert]::FromBase64String($base64String)
- } else {
- [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($base64String))
- }
- }
-}
-
-
-function ConvertTo-Base64UrlString {
- <#
-.SYNOPSIS
-Base64url encoder.
-
-.DESCRIPTION
-Encodes a string or byte array to base64url-encoded string.
-
-.PARAMETER in
-Specifies the input. Must be string, or byte array.
-
-.INPUTS
-You can pipe the string input to ConvertTo-Base64UrlString.
-
-.OUTPUTS
-ConvertTo-Base64UrlString returns the encoded string by default.
-
-.EXAMPLE
-
-PS Variable:> '{"alg":"RS256","typ":"JWT"}' | ConvertTo-Base64UrlString
-eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
-
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
-
-#>
- [CmdletBinding()]
- [OutputType([string])]
- param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
- [object]$in
- )
-
- process {
- if ($in -is [string]) {
- $bytes = [System.Text.Encoding]::UTF8.GetBytes($in)
- [Convert]::ToBase64String($bytes) -replace '\+', '-' -replace '/', '_' -replace '='
- } elseif ($in -is [byte[]]) {
- [Convert]::ToBase64String($in) -replace '\+', '-' -replace '/', '_' -replace '='
- } else {
- throw "ConvertTo-Base64UrlString requires string or byte array input, received $($in.GetType())"
- }
- }
-}
-
-
-function Get-JwtHeader {
- <#
-.SYNOPSIS
-Gets JSON payload from a JWT (JSON Web Token).
-
-.DESCRIPTION
-Decodes and extracts JSON header from JWT. Ignores payload and signature.
-
-.PARAMETER jwt
-Specifies the JWT. Mandatory string.
-
-.INPUTS
-You can pipe JWT as a string object to Get-JwtHeader.
-
-.OUTPUTS
-String. Get-JwtHeader returns decoded header part of the JWT.
-
-.EXAMPLE
-
-PS Variable:> $jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.' #gitleaks:allow
-PS Variable:> Get-JwtHeader $jwt
-{"alg":"none","typ":"JWT"}
-
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
-
-#>
-
- [CmdletBinding()]
- [OutputType([string])]
- param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt
- )
-
- process {
- Write-Verbose "Processing JWT: $jwt"
- $parts = $jwt.Split('.')
- ConvertFrom-Base64UrlString $parts[0]
- }
-}
-
-
-function Get-JwtPayload {
- <#
-.SYNOPSIS
-Gets JSON payload from a JWT (JSON Web Token).
-
-.DESCRIPTION
-Decodes and extracts JSON payload from JWT. Ignores headers and signature.
-
-.PARAMETER jwt
-Specifies the JWT. Mandatory string.
-
-.INPUTS
-You can pipe JWT as a string object to Get-JwtPayload.
-
-.OUTPUTS
-String. Get-JwtPayload returns decoded payload part of the JWT.
-
-.EXAMPLE
-
-PS Variable:> $jwt | Get-JwtPayload
-{"token1":"value1","token2":"value2"}
-
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
-
-#>
-
- [CmdletBinding()]
- [OutputType([string])]
- param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt
- )
-
- process {
- Write-Verbose "Processing JWT: $jwt"
- $parts = $jwt.Split('.')
- ConvertFrom-Base64UrlString $parts[1]
- }
-}
-
-
-function New-Jwt {
- <#
-.SYNOPSIS
-Creates a JWT (JSON Web Token).
-
-.DESCRIPTION
-Creates signed JWT given a signing certificate and claims in JSON.
-
-.PARAMETER Payload
-Specifies the claim to sign in JSON. Mandatory string.
-
-.PARAMETER Header
-Specifies a JWT header. Optional. Defaults to '{"alg":"RS256","typ":"JWT"}'.
-
-.PARAMETER Cert
-Specifies the signing certificate of type System.Security.Cryptography.X509Certificates.X509Certificate2.
-Must be specified and contain the private key if the algorithm in the header is RS256.
-
-.PARAMETER Secret
-Specifies the HMAC secret. Can be byte array, or a string, which will be converted to bytes.
-Must be specified if the algorithm in the header is HS256.
-
-.INPUTS
-You can pipe a string object (the JSON payload) to New-Jwt.
-
-.OUTPUTS
-System.String. New-Jwt returns a string with the signed JWT.
-
-.EXAMPLE
-PS Variable:\> $cert = (Get-ChildItem Cert:\CurrentUser\My)[1]
-
-PS Variable:\> $jwt = New-Jwt -Cert $cert -PayloadJson '{"token1":"value1","token2":"value2"}'
-PS Variable:\> $jwt.Split('.').Count
-3
-
-.EXAMPLE
-$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("/mnt/c/PS/JWT/jwt.pfx","jwt")
-
-$now = (Get-Date).ToUniversalTime()
-$createDate = [Math]::Floor([decimal](Get-Date($now) -UFormat "%s"))
-$expiryDate = [Math]::Floor([decimal](Get-Date($now.AddHours(1)) -UFormat "%s"))
-$rawclaims = [Ordered]@{
- iss = "examplecom:apikey:uaqCinPt2Enb"
- iat = $createDate
- exp = $expiryDate
-} | ConvertTo-Json
-
-$jwt = New-Jwt -PayloadJson $rawclaims -Cert $cert
-
-$apiendpoint = "https://api.example.com/api/1.0/systems"
-
-$splat = @{
- Method="GET"
- Uri=$apiendpoint
- ContentType="application/json"
- Headers = @{authorization="bearer $jwt"}
-}
-
-Invoke-WebRequest @splat
-
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
-
-#>
-
- [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
- 'PSUseShouldProcessForStateChangingFunctions', '',
- Justification = 'New-Jwt creates an in-memory token and does not change system state.'
- )]
- [CmdletBinding()]
- [OutputType([string])]
- param (
- [Parameter(Mandatory = $false)][string]$Header = '{"alg":"RS256","typ":"JWT"}',
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$PayloadJson,
- [Parameter(Mandatory = $false)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
- [Parameter(Mandatory = $false)]$Secret # Can be string or byte[] - checks in the code
- )
-
- process {
- Write-Verbose "Payload to sign: $PayloadJson"
-
- try {
- $Alg = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg
- } catch {
- throw "The supplied JWT header is not JSON: $Header"
- }
- Write-Verbose "Algorithm: $Alg"
-
- try {
- $null = ConvertFrom-Json -InputObject $PayloadJson -ErrorAction Stop
- } catch {
- throw "The supplied JWT payload is not JSON: $PayloadJson"
- }
-
- $encodedHeader = ConvertTo-Base64UrlString $Header
- $encodedPayload = ConvertTo-Base64UrlString $PayloadJson
- $jwt = $encodedHeader + '.' + $encodedPayload
- $toSign = [System.Text.Encoding]::UTF8.GetBytes($jwt)
-
- switch ($Alg) {
- 'RS256' {
- if (-not $PSBoundParameters.ContainsKey('Cert')) {
- throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
- }
- Write-Verbose "Signing certificate: $($Cert.Subject)"
- $rsa = $Cert.PrivateKey
- if ($null -eq $rsa) {
- throw "There's no private key in the supplied certificate - cannot sign"
- } else {
- try {
- $signature = $rsa.SignData(
- $toSign,
- [Security.Cryptography.HashAlgorithmName]::SHA256,
- [Security.Cryptography.RSASignaturePadding]::Pkcs1
- )
- $sig = ConvertTo-Base64UrlString $signature
- } catch {
- $message = "Signing with SHA256 and Pkcs1 padding failed using private key $($rsa): $_"
- throw [System.Exception]::new($message, $_.Exception)
- }
- }
- }
- 'HS256' {
- if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
- throw 'HS256 requires -Secret parameter'
- }
- try {
- $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
- if ($Secret -is [byte[]]) {
- $hmacsha256.Key = $Secret
- } elseif ($Secret -is [string]) {
- $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
- } else {
- throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
- }
- $sig = ConvertTo-Base64UrlString $hmacsha256.ComputeHash($toSign)
- } catch {
- throw [System.Exception]::new("Signing with HMACSHA256 failed: $_", $_.Exception)
- }
- }
- 'none' {
- $sig = $null
- }
- default {
- throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
- }
- }
-
- $jwt + '.' + $sig
- }
-}
-
-
-function Test-Jwt {
- <#
-.SYNOPSIS
-Tests cryptographic integrity of a JWT (JSON Web Token).
-
-.DESCRIPTION
-Verifies a digital signature of a JWT given the signing certificate (for RS256) or the secret (for HS256).
-
-.PARAMETER Cert
-Specifies the signing certificate of type System.Security.Cryptography.X509Certificates.X509Certificate2.
-Must be specified if the algorithm in the header is RS256. Doesn't have to, and generally shouldn't, contain the private key.
-
-.PARAMETER Secret
-Specifies the HMAC secret. Can be byte array, or a string, which will be converted to bytes.
-Must be specified if the algorithm in the header is HS256.
-
-.INPUTS
-You can pipe JWT as a string object to Test-Jwt.
-
-.OUTPUTS
-Boolean. Test-Jwt returns $true if the signature successfully verifies.
-
-.EXAMPLE
-
-PS Variable:> $jwt | Test-Jwt -Cert $cert
-True
-
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
-
-#>
-
- [CmdletBinding()]
- [OutputType([bool])]
- param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt,
- [Parameter(Mandatory = $false)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
- [Parameter(Mandatory = $false)]$Secret
- )
-
- process {
- Write-Verbose "Verifying JWT: $jwt"
-
- $parts = $jwt.Split('.')
- $header = ConvertFrom-Base64UrlString $parts[0]
- try {
- $Alg = (ConvertFrom-Json -InputObject $header -ErrorAction Stop).alg
- } catch {
- throw "The supplied JWT header is not JSON: $header"
- }
- Write-Verbose "Algorithm: $Alg"
-
- switch ($Alg) {
- 'RS256' {
- if (-not $PSBoundParameters.ContainsKey('Cert')) {
- throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
- }
- $bytes = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
- Write-Verbose "Using certificate with subject: $($Cert.Subject)"
- $SHA256 = New-Object Security.Cryptography.SHA256Managed
- $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
- $computed = $SHA256.ComputeHash($signedContent)
- $cert.PublicKey.Key.VerifyHash(
- $computed,
- $bytes,
- [Security.Cryptography.HashAlgorithmName]::SHA256,
- [Security.Cryptography.RSASignaturePadding]::Pkcs1
- )
- }
- 'HS256' {
- if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
- throw 'HS256 requires -Secret parameter'
- }
- $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
- if ($Secret -is [byte[]]) {
- $hmacsha256.Key = $Secret
- } elseif ($Secret -is [string]) {
- $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
- } else {
- throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
- }
- $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
- $signature = $hmacsha256.ComputeHash($signedContent)
- $encoded = ConvertTo-Base64UrlString $signature
- $encoded -eq $parts[2]
- }
- 'none' {
- -not $parts[2]
- }
- default {
- throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
- }
- }
- }
-
-}
-
-
-Set-Alias -Name 'Verify-JwtSignature' -Value 'Test-Jwt' -Description 'An alias, using non-standard verb'
-Export-Member -Function '*' -Alias '*'
diff --git a/src/functions/public/ConvertFrom-Base64UrlString.ps1 b/src/functions/public/ConvertFrom-Base64UrlString.ps1
new file mode 100644
index 0000000..45e1d7a
--- /dev/null
+++ b/src/functions/public/ConvertFrom-Base64UrlString.ps1
@@ -0,0 +1,53 @@
+function ConvertFrom-Base64UrlString {
+ <#
+.SYNOPSIS
+Base64url decoder.
+
+.DESCRIPTION
+Decodes base64url-encoded string to the original string or byte array.
+
+.PARAMETER Base64UrlString
+Specifies the encoded input. Mandatory string.
+
+.PARAMETER AsByteArray
+Optional switch. If specified, outputs byte array instead of string.
+
+.INPUTS
+You can pipe the string input to ConvertFrom-Base64UrlString.
+
+.OUTPUTS
+ConvertFrom-Base64UrlString returns decoded string by default, or the bytes if -AsByteArray is used.
+
+.EXAMPLE
+
+PS Variable:> 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' | ConvertFrom-Base64UrlString
+{"alg":"RS256","typ":"JWT"}
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+ [CmdletBinding()]
+ [OutputType([string], [byte[]])]
+ param (
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$Base64UrlString,
+ [Parameter(Mandatory = $false)][switch]$AsByteArray
+ )
+
+ process {
+ $base64String = $Base64UrlString.replace('-', '+').replace('_', '/')
+ switch ($base64String.Length % 4) {
+ 0 { $base64String = $base64String }
+ 1 { $base64String = $base64String.Substring(0, $base64String.Length - 1) }
+ 2 { $base64String = $base64String + '==' }
+ 3 { $base64String = $base64String + '=' }
+ }
+ if ($AsByteArray) {
+ [Convert]::FromBase64String($base64String)
+ } else {
+ [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($base64String))
+ }
+ }
+}
diff --git a/src/functions/public/ConvertTo-Base64UrlString.ps1 b/src/functions/public/ConvertTo-Base64UrlString.ps1
new file mode 100644
index 0000000..fb1daa1
--- /dev/null
+++ b/src/functions/public/ConvertTo-Base64UrlString.ps1
@@ -0,0 +1,46 @@
+function ConvertTo-Base64UrlString {
+ <#
+.SYNOPSIS
+Base64url encoder.
+
+.DESCRIPTION
+Encodes a string or byte array to base64url-encoded string.
+
+.PARAMETER in
+Specifies the input. Must be string, or byte array.
+
+.INPUTS
+You can pipe the string input to ConvertTo-Base64UrlString.
+
+.OUTPUTS
+ConvertTo-Base64UrlString returns the encoded string by default.
+
+.EXAMPLE
+
+PS Variable:> '{"alg":"RS256","typ":"JWT"}' | ConvertTo-Base64UrlString
+eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
+ [object]$in
+ )
+
+ process {
+ if ($in -is [string]) {
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($in)
+ [Convert]::ToBase64String($bytes) -replace '\+', '-' -replace '/', '_' -replace '='
+ } elseif ($in -is [byte[]]) {
+ [Convert]::ToBase64String($in) -replace '\+', '-' -replace '/', '_' -replace '='
+ } else {
+ throw "ConvertTo-Base64UrlString requires string or byte array input, received $($in.GetType())"
+ }
+ }
+}
diff --git a/src/functions/public/Get-JwtHeader.ps1 b/src/functions/public/Get-JwtHeader.ps1
new file mode 100644
index 0000000..b84b73d
--- /dev/null
+++ b/src/functions/public/Get-JwtHeader.ps1
@@ -0,0 +1,42 @@
+function Get-JwtHeader {
+ <#
+.SYNOPSIS
+Gets JSON payload from a JWT (JSON Web Token).
+
+.DESCRIPTION
+Decodes and extracts JSON header from JWT. Ignores payload and signature.
+
+.PARAMETER jwt
+Specifies the JWT. Mandatory string.
+
+.INPUTS
+You can pipe JWT as a string object to Get-JwtHeader.
+
+.OUTPUTS
+String. Get-JwtHeader returns decoded header part of the JWT.
+
+.EXAMPLE
+
+PS Variable:> $jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.' #gitleaks:allow
+PS Variable:> Get-JwtHeader $jwt
+{"alg":"none","typ":"JWT"}
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt
+ )
+
+ process {
+ Write-Verbose "Processing JWT: $jwt"
+ $parts = $jwt.Split('.')
+ ConvertFrom-Base64UrlString $parts[0]
+ }
+}
diff --git a/src/functions/public/Get-JwtPayload.ps1 b/src/functions/public/Get-JwtPayload.ps1
new file mode 100644
index 0000000..58cb9a2
--- /dev/null
+++ b/src/functions/public/Get-JwtPayload.ps1
@@ -0,0 +1,41 @@
+function Get-JwtPayload {
+ <#
+.SYNOPSIS
+Gets JSON payload from a JWT (JSON Web Token).
+
+.DESCRIPTION
+Decodes and extracts JSON payload from JWT. Ignores headers and signature.
+
+.PARAMETER jwt
+Specifies the JWT. Mandatory string.
+
+.INPUTS
+You can pipe JWT as a string object to Get-JwtPayload.
+
+.OUTPUTS
+String. Get-JwtPayload returns decoded payload part of the JWT.
+
+.EXAMPLE
+
+PS Variable:> $jwt | Get-JwtPayload
+{"token1":"value1","token2":"value2"}
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt
+ )
+
+ process {
+ Write-Verbose "Processing JWT: $jwt"
+ $parts = $jwt.Split('.')
+ ConvertFrom-Base64UrlString $parts[1]
+ }
+}
diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1
new file mode 100644
index 0000000..fc3ef73
--- /dev/null
+++ b/src/functions/public/New-Jwt.ps1
@@ -0,0 +1,153 @@
+function New-Jwt {
+ <#
+.SYNOPSIS
+Creates a JWT (JSON Web Token).
+
+.DESCRIPTION
+Creates signed JWT given a signing certificate and claims in JSON.
+
+.PARAMETER Payload
+Specifies the claim to sign in JSON. Mandatory string.
+
+.PARAMETER Header
+Specifies a JWT header. Optional. Defaults to '{"alg":"RS256","typ":"JWT"}'.
+
+.PARAMETER Cert
+Specifies the signing certificate of type System.Security.Cryptography.X509Certificates.X509Certificate2.
+Must be specified and contain the private key if the algorithm in the header is RS256.
+
+.PARAMETER Secret
+Specifies the HMAC secret. Can be byte array, or a string, which will be converted to bytes.
+Must be specified if the algorithm in the header is HS256.
+
+.INPUTS
+You can pipe a string object (the JSON payload) to New-Jwt.
+
+.OUTPUTS
+System.String. New-Jwt returns a string with the signed JWT.
+
+.EXAMPLE
+PS Variable:\> $cert = (Get-ChildItem Cert:\CurrentUser\My)[1]
+
+PS Variable:\> $jwt = New-Jwt -Cert $cert -PayloadJson '{"token1":"value1","token2":"value2"}'
+PS Variable:\> $jwt.Split('.').Count
+3
+
+.EXAMPLE
+$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("/mnt/c/PS/JWT/jwt.pfx","jwt")
+
+$now = (Get-Date).ToUniversalTime()
+$createDate = [Math]::Floor([decimal](Get-Date($now) -UFormat "%s"))
+$expiryDate = [Math]::Floor([decimal](Get-Date($now.AddHours(1)) -UFormat "%s"))
+$rawclaims = [Ordered]@{
+ iss = "examplecom:apikey:uaqCinPt2Enb"
+ iat = $createDate
+ exp = $expiryDate
+} | ConvertTo-Json
+
+$jwt = New-Jwt -PayloadJson $rawclaims -Cert $cert
+
+$apiendpoint = "https://api.example.com/api/1.0/systems"
+
+$splat = @{
+ Method="GET"
+ Uri=$apiendpoint
+ ContentType="application/json"
+ Headers = @{authorization="bearer $jwt"}
+}
+
+Invoke-WebRequest @splat
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseShouldProcessForStateChangingFunctions', '',
+ Justification = 'New-Jwt creates an in-memory token and does not change system state.'
+ )]
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [Parameter(Mandatory = $false)][string]$Header = '{"alg":"RS256","typ":"JWT"}',
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$PayloadJson,
+ [Parameter(Mandatory = $false)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
+ [Parameter(Mandatory = $false)]$Secret # Can be string or byte[] - checks in the code
+ )
+
+ process {
+ Write-Verbose "Payload to sign: $PayloadJson"
+
+ try {
+ $Alg = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg
+ } catch {
+ throw "The supplied JWT header is not JSON: $Header"
+ }
+ Write-Verbose "Algorithm: $Alg"
+
+ try {
+ $null = ConvertFrom-Json -InputObject $PayloadJson -ErrorAction Stop
+ } catch {
+ throw "The supplied JWT payload is not JSON: $PayloadJson"
+ }
+
+ $encodedHeader = ConvertTo-Base64UrlString $Header
+ $encodedPayload = ConvertTo-Base64UrlString $PayloadJson
+ $jwt = $encodedHeader + '.' + $encodedPayload
+ $toSign = [System.Text.Encoding]::UTF8.GetBytes($jwt)
+
+ switch ($Alg) {
+ 'RS256' {
+ if (-not $PSBoundParameters.ContainsKey('Cert')) {
+ throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
+ }
+ Write-Verbose "Signing certificate: $($Cert.Subject)"
+ $rsa = $Cert.PrivateKey
+ if ($null -eq $rsa) {
+ throw "There's no private key in the supplied certificate - cannot sign"
+ } else {
+ try {
+ $signature = $rsa.SignData(
+ $toSign,
+ [Security.Cryptography.HashAlgorithmName]::SHA256,
+ [Security.Cryptography.RSASignaturePadding]::Pkcs1
+ )
+ $sig = ConvertTo-Base64UrlString $signature
+ } catch {
+ $message = "Signing with SHA256 and Pkcs1 padding failed using private key $($rsa): $_"
+ throw [System.Exception]::new($message, $_.Exception)
+ }
+ }
+ }
+ 'HS256' {
+ if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
+ throw 'HS256 requires -Secret parameter'
+ }
+ try {
+ $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
+ if ($Secret -is [byte[]]) {
+ $hmacsha256.Key = $Secret
+ } elseif ($Secret -is [string]) {
+ $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
+ } else {
+ throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
+ }
+ $sig = ConvertTo-Base64UrlString $hmacsha256.ComputeHash($toSign)
+ } catch {
+ throw [System.Exception]::new("Signing with HMACSHA256 failed: $_", $_.Exception)
+ }
+ }
+ 'none' {
+ $sig = $null
+ }
+ default {
+ throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
+ }
+ }
+
+ $jwt + '.' + $sig
+ }
+}
diff --git a/src/functions/public/Test-Jwt.ps1 b/src/functions/public/Test-Jwt.ps1
new file mode 100644
index 0000000..a4479f3
--- /dev/null
+++ b/src/functions/public/Test-Jwt.ps1
@@ -0,0 +1,98 @@
+function Test-Jwt {
+ <#
+.SYNOPSIS
+Tests cryptographic integrity of a JWT (JSON Web Token).
+
+.DESCRIPTION
+Verifies a digital signature of a JWT given the signing certificate (for RS256) or the secret (for HS256).
+
+.PARAMETER Cert
+Specifies the signing certificate of type System.Security.Cryptography.X509Certificates.X509Certificate2.
+Must be specified if the algorithm in the header is RS256. Doesn't have to, and generally shouldn't, contain the private key.
+
+.PARAMETER Secret
+Specifies the HMAC secret. Can be byte array, or a string, which will be converted to bytes.
+Must be specified if the algorithm in the header is HS256.
+
+.INPUTS
+You can pipe JWT as a string object to Test-Jwt.
+
+.OUTPUTS
+Boolean. Test-Jwt returns $true if the signature successfully verifies.
+
+.EXAMPLE
+
+PS Variable:> $jwt | Test-Jwt -Cert $cert
+True
+
+.LINK
+https://github.com/SP3269/posh-jwt
+.LINK
+https://jwt.io/
+
+#>
+ [Alias('Verify-JwtSignature')]
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param (
+ [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt,
+ [Parameter(Mandatory = $false)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
+ [Parameter(Mandatory = $false)]$Secret
+ )
+
+ process {
+ Write-Verbose "Verifying JWT: $jwt"
+
+ $parts = $jwt.Split('.')
+ $header = ConvertFrom-Base64UrlString $parts[0]
+ try {
+ $Alg = (ConvertFrom-Json -InputObject $header -ErrorAction Stop).alg
+ } catch {
+ throw "The supplied JWT header is not JSON: $header"
+ }
+ Write-Verbose "Algorithm: $Alg"
+
+ switch ($Alg) {
+ 'RS256' {
+ if (-not $PSBoundParameters.ContainsKey('Cert')) {
+ throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
+ }
+ $bytes = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
+ Write-Verbose "Using certificate with subject: $($Cert.Subject)"
+ $SHA256 = New-Object Security.Cryptography.SHA256Managed
+ $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
+ $computed = $SHA256.ComputeHash($signedContent)
+ $cert.PublicKey.Key.VerifyHash(
+ $computed,
+ $bytes,
+ [Security.Cryptography.HashAlgorithmName]::SHA256,
+ [Security.Cryptography.RSASignaturePadding]::Pkcs1
+ )
+ }
+ 'HS256' {
+ if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
+ throw 'HS256 requires -Secret parameter'
+ }
+ $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
+ if ($Secret -is [byte[]]) {
+ $hmacsha256.Key = $Secret
+ } elseif ($Secret -is [string]) {
+ $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
+ } else {
+ throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
+ }
+ $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
+ $signature = $hmacsha256.ComputeHash($signedContent)
+ $encoded = ConvertTo-Base64UrlString $signature
+ $encoded -eq $parts[2]
+ }
+ 'none' {
+ -not $parts[2]
+ }
+ default {
+ throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
+ }
+ }
+ }
+
+}
From 8270d5b9881531259b83cdc46742e6524058d283 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sun, 10 May 2026 19:08:04 +0200
Subject: [PATCH 22/29] =?UTF-8?q?=E2=9C=A8=20[Feature]:=20Enhance=20JWT=20?=
=?UTF-8?q?functions=20with=20improved=20documentation=20and=20examples=20?=
=?UTF-8?q?for=20better=20usability?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../public/ConvertFrom-Base64UrlString.ps1 | 62 ++++----
.../public/ConvertTo-Base64UrlString.ps1 | 64 ++++----
src/functions/public/Get-JwtHeader.ps1 | 58 ++++----
src/functions/public/Get-JwtPayload.ps1 | 56 ++++---
src/functions/public/New-Jwt.ps1 | 137 +++++++++---------
src/functions/public/Test-Jwt.ps1 | 89 +++++++-----
6 files changed, 258 insertions(+), 208 deletions(-)
diff --git a/src/functions/public/ConvertFrom-Base64UrlString.ps1 b/src/functions/public/ConvertFrom-Base64UrlString.ps1
index 45e1d7a..52957ad 100644
--- a/src/functions/public/ConvertFrom-Base64UrlString.ps1
+++ b/src/functions/public/ConvertFrom-Base64UrlString.ps1
@@ -1,43 +1,51 @@
function ConvertFrom-Base64UrlString {
<#
-.SYNOPSIS
-Base64url decoder.
+ .SYNOPSIS
+ Decodes a base64url string.
-.DESCRIPTION
-Decodes base64url-encoded string to the original string or byte array.
+ .DESCRIPTION
+ Decodes a base64url-encoded string to UTF-8 text by default. Use AsByteArray to return the decoded bytes.
-.PARAMETER Base64UrlString
-Specifies the encoded input. Mandatory string.
+ .EXAMPLE
+ ```powershell
+ 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' | ConvertFrom-Base64UrlString
+ ```
-.PARAMETER AsByteArray
-Optional switch. If specified, outputs byte array instead of string.
+ Decodes the base64url value to `{"alg":"RS256","typ":"JWT"}`.
-.INPUTS
-You can pipe the string input to ConvertFrom-Base64UrlString.
+ .INPUTS
+ System.String
-.OUTPUTS
-ConvertFrom-Base64UrlString returns decoded string by default, or the bytes if -AsByteArray is used.
+ .OUTPUTS
+ System.String
+ System.Byte[]
-.EXAMPLE
+ .NOTES
+ Converts JWT-safe base64url text by restoring standard base64 characters and padding before decoding.
-PS Variable:> 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' | ConvertFrom-Base64UrlString
-{"alg":"RS256","typ":"JWT"}
+ .LINK
+ https://github.com/SP3269/posh-jwt
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
-
-#>
- [CmdletBinding()]
+ .LINK
+ https://jwt.io/
+ #>
[OutputType([string], [byte[]])]
- param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$Base64UrlString,
- [Parameter(Mandatory = $false)][switch]$AsByteArray
+ [CmdletBinding()]
+ param(
+ # The base64url-encoded string to decode.
+ [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Base64UrlString,
+
+ # Return decoded bytes instead of UTF-8 text.
+ [Parameter()]
+ [switch] $AsByteArray
)
+ begin {}
+
process {
- $base64String = $Base64UrlString.replace('-', '+').replace('_', '/')
+ $base64String = $Base64UrlString.Replace('-', '+').Replace('_', '/')
switch ($base64String.Length % 4) {
0 { $base64String = $base64String }
1 { $base64String = $base64String.Substring(0, $base64String.Length - 1) }
@@ -50,4 +58,6 @@ https://jwt.io/
[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($base64String))
}
}
+
+ end {}
}
diff --git a/src/functions/public/ConvertTo-Base64UrlString.ps1 b/src/functions/public/ConvertTo-Base64UrlString.ps1
index fb1daa1..c7e7d10 100644
--- a/src/functions/public/ConvertTo-Base64UrlString.ps1
+++ b/src/functions/public/ConvertTo-Base64UrlString.ps1
@@ -1,46 +1,56 @@
function ConvertTo-Base64UrlString {
<#
-.SYNOPSIS
-Base64url encoder.
+ .SYNOPSIS
+ Encodes text or bytes as a base64url string.
-.DESCRIPTION
-Encodes a string or byte array to base64url-encoded string.
+ .DESCRIPTION
+ Encodes a string or byte array using base64url encoding suitable for JWT headers, payloads, and signatures.
-.PARAMETER in
-Specifies the input. Must be string, or byte array.
+ .EXAMPLE
+ ```powershell
+ '{"alg":"RS256","typ":"JWT"}' | ConvertTo-Base64UrlString
+ ```
-.INPUTS
-You can pipe the string input to ConvertTo-Base64UrlString.
+ Encodes the JWT header JSON as `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9`.
-.OUTPUTS
-ConvertTo-Base64UrlString returns the encoded string by default.
+ .INPUTS
+ System.String
+ System.Byte[]
-.EXAMPLE
+ .OUTPUTS
+ System.String
-PS Variable:> '{"alg":"RS256","typ":"JWT"}' | ConvertTo-Base64UrlString
-eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
+ .NOTES
+ Converts standard base64 output to JWT-safe base64url text by replacing URL-sensitive characters and removing padding.
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
+ .LINK
+ https://github.com/SP3269/posh-jwt
-#>
- [CmdletBinding()]
+ .LINK
+ https://jwt.io/
+ #>
[OutputType([string])]
- param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
- [object]$in
+ [CmdletBinding()]
+ param(
+ # The string or byte array to encode.
+ [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
+ [ValidateNotNull()]
+ [Alias('in')]
+ [object] $InputObject
)
+ begin {}
+
process {
- if ($in -is [string]) {
- $bytes = [System.Text.Encoding]::UTF8.GetBytes($in)
+ if ($InputObject -is [string]) {
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($InputObject)
[Convert]::ToBase64String($bytes) -replace '\+', '-' -replace '/', '_' -replace '='
- } elseif ($in -is [byte[]]) {
- [Convert]::ToBase64String($in) -replace '\+', '-' -replace '/', '_' -replace '='
+ } elseif ($InputObject -is [byte[]]) {
+ [Convert]::ToBase64String($InputObject) -replace '\+', '-' -replace '/', '_' -replace '='
} else {
- throw "ConvertTo-Base64UrlString requires string or byte array input, received $($in.GetType())"
+ throw "ConvertTo-Base64UrlString requires string or byte array input, received $($InputObject.GetType())"
}
}
+
+ end {}
}
diff --git a/src/functions/public/Get-JwtHeader.ps1 b/src/functions/public/Get-JwtHeader.ps1
index b84b73d..3c60522 100644
--- a/src/functions/public/Get-JwtHeader.ps1
+++ b/src/functions/public/Get-JwtHeader.ps1
@@ -1,42 +1,50 @@
function Get-JwtHeader {
<#
-.SYNOPSIS
-Gets JSON payload from a JWT (JSON Web Token).
+ .SYNOPSIS
+ Gets the decoded header from a JWT.
-.DESCRIPTION
-Decodes and extracts JSON header from JWT. Ignores payload and signature.
+ .DESCRIPTION
+ Decodes and returns the JSON header segment from a JSON Web Token. The payload and signature are ignored.
-.PARAMETER jwt
-Specifies the JWT. Mandatory string.
+ .EXAMPLE
+ ```powershell
+ $jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.' #gitleaks:allow
+ Get-JwtHeader -Jwt $jwt
+ ```
-.INPUTS
-You can pipe JWT as a string object to Get-JwtHeader.
+ Gets the decoded header JSON from an unsigned JWT.
-.OUTPUTS
-String. Get-JwtHeader returns decoded header part of the JWT.
+ .INPUTS
+ System.String
-.EXAMPLE
+ .OUTPUTS
+ System.String
-PS Variable:> $jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.' #gitleaks:allow
-PS Variable:> Get-JwtHeader $jwt
-{"alg":"none","typ":"JWT"}
+ .NOTES
+ This command decodes only the header segment and does not validate the token signature.
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
+ .LINK
+ https://github.com/SP3269/posh-jwt
-#>
-
- [CmdletBinding()]
+ .LINK
+ https://jwt.io/
+ #>
[OutputType([string])]
- param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt
+ [CmdletBinding()]
+ param(
+ # The JWT to read.
+ [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Jwt
)
+ begin {}
+
process {
- Write-Verbose "Processing JWT: $jwt"
- $parts = $jwt.Split('.')
+ Write-Verbose "Processing JWT: $Jwt"
+ $parts = $Jwt.Split('.')
ConvertFrom-Base64UrlString $parts[0]
}
+
+ end {}
}
diff --git a/src/functions/public/Get-JwtPayload.ps1 b/src/functions/public/Get-JwtPayload.ps1
index 58cb9a2..58e920e 100644
--- a/src/functions/public/Get-JwtPayload.ps1
+++ b/src/functions/public/Get-JwtPayload.ps1
@@ -1,41 +1,49 @@
function Get-JwtPayload {
<#
-.SYNOPSIS
-Gets JSON payload from a JWT (JSON Web Token).
+ .SYNOPSIS
+ Gets the decoded payload from a JWT.
-.DESCRIPTION
-Decodes and extracts JSON payload from JWT. Ignores headers and signature.
+ .DESCRIPTION
+ Decodes and returns the JSON payload segment from a JSON Web Token. The header and signature are ignored.
-.PARAMETER jwt
-Specifies the JWT. Mandatory string.
+ .EXAMPLE
+ ```powershell
+ $jwt | Get-JwtPayload
+ ```
-.INPUTS
-You can pipe JWT as a string object to Get-JwtPayload.
+ Gets the decoded payload JSON from a JWT.
-.OUTPUTS
-String. Get-JwtPayload returns decoded payload part of the JWT.
+ .INPUTS
+ System.String
-.EXAMPLE
+ .OUTPUTS
+ System.String
-PS Variable:> $jwt | Get-JwtPayload
-{"token1":"value1","token2":"value2"}
+ .NOTES
+ This command decodes only the payload segment and does not validate the token signature.
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
+ .LINK
+ https://github.com/SP3269/posh-jwt
-#>
-
- [CmdletBinding()]
+ .LINK
+ https://jwt.io/
+ #>
[OutputType([string])]
- param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt
+ [CmdletBinding()]
+ param(
+ # The JWT to read.
+ [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Jwt
)
+ begin {}
+
process {
- Write-Verbose "Processing JWT: $jwt"
- $parts = $jwt.Split('.')
+ Write-Verbose "Processing JWT: $Jwt"
+ $parts = $Jwt.Split('.')
ConvertFrom-Base64UrlString $parts[1]
}
+
+ end {}
}
diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1
index fc3ef73..3c48297 100644
--- a/src/functions/public/New-Jwt.ps1
+++ b/src/functions/public/New-Jwt.ps1
@@ -1,92 +1,85 @@
function New-Jwt {
<#
-.SYNOPSIS
-Creates a JWT (JSON Web Token).
+ .SYNOPSIS
+ Creates a JSON Web Token.
-.DESCRIPTION
-Creates signed JWT given a signing certificate and claims in JSON.
+ .DESCRIPTION
+ Creates a JWT from JSON header and payload strings. Supports RS256 with a signing certificate, HS256 with a
+ shared secret, and the none algorithm.
-.PARAMETER Payload
-Specifies the claim to sign in JSON. Mandatory string.
+ .EXAMPLE
+ ```powershell
+ $payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}'
+ $secret = 'a-string-secret-at-least-256-bits-long'
-.PARAMETER Header
-Specifies a JWT header. Optional. Defaults to '{"alg":"RS256","typ":"JWT"}'.
+ New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson $payload -Secret $secret
+ ```
-.PARAMETER Cert
-Specifies the signing certificate of type System.Security.Cryptography.X509Certificates.X509Certificate2.
-Must be specified and contain the private key if the algorithm in the header is RS256.
+ Creates an HS256-signed JWT.
-.PARAMETER Secret
-Specifies the HMAC secret. Can be byte array, or a string, which will be converted to bytes.
-Must be specified if the algorithm in the header is HS256.
+ .EXAMPLE
+ ```powershell
+ $cert = (Get-ChildItem Cert:\CurrentUser\My)[1]
+ $jwt = New-Jwt -Cert $cert -PayloadJson '{"token1":"value1","token2":"value2"}'
+ $jwt.Split('.').Count
+ ```
-.INPUTS
-You can pipe a string object (the JSON payload) to New-Jwt.
+ Creates an RS256-signed JWT with a certificate private key and returns the number of JWT segments.
-.OUTPUTS
-System.String. New-Jwt returns a string with the signed JWT.
+ .INPUTS
+ System.String
-.EXAMPLE
-PS Variable:\> $cert = (Get-ChildItem Cert:\CurrentUser\My)[1]
+ .OUTPUTS
+ System.String
-PS Variable:\> $jwt = New-Jwt -Cert $cert -PayloadJson '{"token1":"value1","token2":"value2"}'
-PS Variable:\> $jwt.Split('.').Count
-3
+ .NOTES
+ RS256 requires a certificate with a private key. HS256 requires a string or byte array secret.
-.EXAMPLE
-$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("/mnt/c/PS/JWT/jwt.pfx","jwt")
-
-$now = (Get-Date).ToUniversalTime()
-$createDate = [Math]::Floor([decimal](Get-Date($now) -UFormat "%s"))
-$expiryDate = [Math]::Floor([decimal](Get-Date($now.AddHours(1)) -UFormat "%s"))
-$rawclaims = [Ordered]@{
- iss = "examplecom:apikey:uaqCinPt2Enb"
- iat = $createDate
- exp = $expiryDate
-} | ConvertTo-Json
-
-$jwt = New-Jwt -PayloadJson $rawclaims -Cert $cert
-
-$apiendpoint = "https://api.example.com/api/1.0/systems"
-
-$splat = @{
- Method="GET"
- Uri=$apiendpoint
- ContentType="application/json"
- Headers = @{authorization="bearer $jwt"}
-}
-
-Invoke-WebRequest @splat
-
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
-
-#>
+ .LINK
+ https://github.com/SP3269/posh-jwt
+ .LINK
+ https://jwt.io/
+ #>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '',
Justification = 'New-Jwt creates an in-memory token and does not change system state.'
)]
- [CmdletBinding()]
[OutputType([string])]
- param (
- [Parameter(Mandatory = $false)][string]$Header = '{"alg":"RS256","typ":"JWT"}',
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$PayloadJson,
- [Parameter(Mandatory = $false)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
- [Parameter(Mandatory = $false)]$Secret # Can be string or byte[] - checks in the code
+ [CmdletBinding()]
+ param(
+ # The JWT header JSON.
+ [Parameter()]
+ [ValidateNotNullOrEmpty()]
+ [string] $Header = '{"alg":"RS256","typ":"JWT"}',
+
+ # The JWT payload JSON.
+ [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
+ [ValidateNotNullOrEmpty()]
+ [string] $PayloadJson,
+
+ # The signing certificate to use for RS256 tokens.
+ [Parameter()]
+ [ValidateNotNull()]
+ [System.Security.Cryptography.X509Certificates.X509Certificate2] $Cert,
+
+ # The string or byte array secret to use for HS256 tokens.
+ [Parameter()]
+ [ValidateNotNull()]
+ [object] $Secret
)
+ begin {}
+
process {
Write-Verbose "Payload to sign: $PayloadJson"
try {
- $Alg = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg
+ $algorithm = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg
} catch {
throw "The supplied JWT header is not JSON: $Header"
}
- Write-Verbose "Algorithm: $Alg"
+ Write-Verbose "Algorithm: $algorithm"
try {
$null = ConvertFrom-Json -InputObject $PayloadJson -ErrorAction Stop
@@ -96,10 +89,10 @@ https://jwt.io/
$encodedHeader = ConvertTo-Base64UrlString $Header
$encodedPayload = ConvertTo-Base64UrlString $PayloadJson
- $jwt = $encodedHeader + '.' + $encodedPayload
- $toSign = [System.Text.Encoding]::UTF8.GetBytes($jwt)
+ $jwtContent = $encodedHeader + '.' + $encodedPayload
+ $contentBytes = [System.Text.Encoding]::UTF8.GetBytes($jwtContent)
- switch ($Alg) {
+ switch ($algorithm) {
'RS256' {
if (-not $PSBoundParameters.ContainsKey('Cert')) {
throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
@@ -111,11 +104,11 @@ https://jwt.io/
} else {
try {
$signature = $rsa.SignData(
- $toSign,
+ $contentBytes,
[Security.Cryptography.HashAlgorithmName]::SHA256,
[Security.Cryptography.RSASignaturePadding]::Pkcs1
)
- $sig = ConvertTo-Base64UrlString $signature
+ $encodedSignature = ConvertTo-Base64UrlString $signature
} catch {
$message = "Signing with SHA256 and Pkcs1 padding failed using private key $($rsa): $_"
throw [System.Exception]::new($message, $_.Exception)
@@ -133,21 +126,23 @@ https://jwt.io/
} elseif ($Secret -is [string]) {
$hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
} else {
- throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
+ throw "Expected Secret parameter as byte array or string, instead got $($Secret.GetType())"
}
- $sig = ConvertTo-Base64UrlString $hmacsha256.ComputeHash($toSign)
+ $encodedSignature = ConvertTo-Base64UrlString $hmacsha256.ComputeHash($contentBytes)
} catch {
throw [System.Exception]::new("Signing with HMACSHA256 failed: $_", $_.Exception)
}
}
'none' {
- $sig = $null
+ $encodedSignature = $null
}
default {
throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
}
}
- $jwt + '.' + $sig
+ $jwtContent + '.' + $encodedSignature
}
+
+ end {}
}
diff --git a/src/functions/public/Test-Jwt.ps1 b/src/functions/public/Test-Jwt.ps1
index a4479f3..68f2d2a 100644
--- a/src/functions/public/Test-Jwt.ps1
+++ b/src/functions/public/Test-Jwt.ps1
@@ -1,67 +1,85 @@
function Test-Jwt {
<#
-.SYNOPSIS
-Tests cryptographic integrity of a JWT (JSON Web Token).
+ .SYNOPSIS
+ Tests the cryptographic integrity of a JWT.
-.DESCRIPTION
-Verifies a digital signature of a JWT given the signing certificate (for RS256) or the secret (for HS256).
+ .DESCRIPTION
+ Verifies a JWT signature using the signing certificate for RS256 or a shared secret for HS256. Tokens using the
+ none algorithm are valid only when the signature segment is empty.
-.PARAMETER Cert
-Specifies the signing certificate of type System.Security.Cryptography.X509Certificates.X509Certificate2.
-Must be specified if the algorithm in the header is RS256. Doesn't have to, and generally shouldn't, contain the private key.
+ .EXAMPLE
+ ```powershell
+ $jwt | Test-Jwt -Secret 'a-string-secret-at-least-256-bits-long'
+ ```
-.PARAMETER Secret
-Specifies the HMAC secret. Can be byte array, or a string, which will be converted to bytes.
-Must be specified if the algorithm in the header is HS256.
+ Tests an HS256 JWT with a shared secret.
-.INPUTS
-You can pipe JWT as a string object to Test-Jwt.
+ .EXAMPLE
+ ```powershell
+ $jwt | Test-Jwt -Cert $cert
+ ```
-.OUTPUTS
-Boolean. Test-Jwt returns $true if the signature successfully verifies.
+ Tests an RS256 JWT with a public certificate.
-.EXAMPLE
+ .INPUTS
+ System.String
-PS Variable:> $jwt | Test-Jwt -Cert $cert
-True
+ .OUTPUTS
+ System.Boolean
-.LINK
-https://github.com/SP3269/posh-jwt
-.LINK
-https://jwt.io/
+ .NOTES
+ The Verify-JwtSignature alias is preserved for compatibility with the original module command surface.
-#>
+ .LINK
+ https://github.com/SP3269/posh-jwt
+
+ .LINK
+ https://jwt.io/
+ #>
+ [OutputType([bool])]
[Alias('Verify-JwtSignature')]
[CmdletBinding()]
- [OutputType([bool])]
- param (
- [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$jwt,
- [Parameter(Mandatory = $false)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
- [Parameter(Mandatory = $false)]$Secret
+ param(
+ # The JWT to test.
+ [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Jwt,
+
+ # The certificate to use for RS256 signature verification.
+ [Parameter()]
+ [ValidateNotNull()]
+ [System.Security.Cryptography.X509Certificates.X509Certificate2] $Cert,
+
+ # The string or byte array secret to use for HS256 signature verification.
+ [Parameter()]
+ [ValidateNotNull()]
+ [object] $Secret
)
+ begin {}
+
process {
- Write-Verbose "Verifying JWT: $jwt"
+ Write-Verbose "Verifying JWT: $Jwt"
- $parts = $jwt.Split('.')
+ $parts = $Jwt.Split('.')
$header = ConvertFrom-Base64UrlString $parts[0]
try {
- $Alg = (ConvertFrom-Json -InputObject $header -ErrorAction Stop).alg
+ $algorithm = (ConvertFrom-Json -InputObject $header -ErrorAction Stop).alg
} catch {
throw "The supplied JWT header is not JSON: $header"
}
- Write-Verbose "Algorithm: $Alg"
+ Write-Verbose "Algorithm: $algorithm"
- switch ($Alg) {
+ switch ($algorithm) {
'RS256' {
if (-not $PSBoundParameters.ContainsKey('Cert')) {
throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
}
$bytes = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
Write-Verbose "Using certificate with subject: $($Cert.Subject)"
- $SHA256 = New-Object Security.Cryptography.SHA256Managed
+ $sha256 = New-Object Security.Cryptography.SHA256Managed
$signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
- $computed = $SHA256.ComputeHash($signedContent)
+ $computed = $sha256.ComputeHash($signedContent)
$cert.PublicKey.Key.VerifyHash(
$computed,
$bytes,
@@ -79,7 +97,7 @@ https://jwt.io/
} elseif ($Secret -is [string]) {
$hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
} else {
- throw "Expected Secret parameter as byte array or string, instead got $($Secret.gettype())"
+ throw "Expected Secret parameter as byte array or string, instead got $($Secret.GetType())"
}
$signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
$signature = $hmacsha256.ComputeHash($signedContent)
@@ -95,4 +113,5 @@ https://jwt.io/
}
}
+ end {}
}
From 6b54221a50c3c0c379949c3cc303dc5e974e50f3 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sun, 10 May 2026 19:53:14 +0200
Subject: [PATCH 23/29] =?UTF-8?q?=E2=9C=A8=20[Feature]:=20Update=20links?=
=?UTF-8?q?=20in=20function=20documentation=20to=20point=20to=20new=20modu?=
=?UTF-8?q?le=20site=20and=20improve=20error=20handling=20in=20JWT=20funct?=
=?UTF-8?q?ions?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../public/ConvertFrom-Base64UrlString.ps1 | 4 +--
.../public/ConvertTo-Base64UrlString.ps1 | 4 +--
src/functions/public/Get-JwtHeader.ps1 | 2 +-
src/functions/public/Get-JwtPayload.ps1 | 2 +-
src/functions/public/New-Jwt.ps1 | 17 ++++++-----
src/functions/public/Test-Jwt.ps1 | 28 +++++++++----------
6 files changed, 28 insertions(+), 29 deletions(-)
diff --git a/src/functions/public/ConvertFrom-Base64UrlString.ps1 b/src/functions/public/ConvertFrom-Base64UrlString.ps1
index 52957ad..16f6418 100644
--- a/src/functions/public/ConvertFrom-Base64UrlString.ps1
+++ b/src/functions/public/ConvertFrom-Base64UrlString.ps1
@@ -24,7 +24,7 @@
Converts JWT-safe base64url text by restoring standard base64 characters and padding before decoding.
.LINK
- https://github.com/SP3269/posh-jwt
+ https://psmodule.io/Jwt/Functions/ConvertFrom-Base64UrlString/
.LINK
https://jwt.io/
@@ -47,7 +47,7 @@
process {
$base64String = $Base64UrlString.Replace('-', '+').Replace('_', '/')
switch ($base64String.Length % 4) {
- 0 { $base64String = $base64String }
+ 0 { }
1 { $base64String = $base64String.Substring(0, $base64String.Length - 1) }
2 { $base64String = $base64String + '==' }
3 { $base64String = $base64String + '=' }
diff --git a/src/functions/public/ConvertTo-Base64UrlString.ps1 b/src/functions/public/ConvertTo-Base64UrlString.ps1
index c7e7d10..ee4c9e0 100644
--- a/src/functions/public/ConvertTo-Base64UrlString.ps1
+++ b/src/functions/public/ConvertTo-Base64UrlString.ps1
@@ -24,7 +24,7 @@
Converts standard base64 output to JWT-safe base64url text by replacing URL-sensitive characters and removing padding.
.LINK
- https://github.com/SP3269/posh-jwt
+ https://psmodule.io/Jwt/Functions/ConvertTo-Base64UrlString/
.LINK
https://jwt.io/
@@ -48,7 +48,7 @@
} elseif ($InputObject -is [byte[]]) {
[Convert]::ToBase64String($InputObject) -replace '\+', '-' -replace '/', '_' -replace '='
} else {
- throw "ConvertTo-Base64UrlString requires string or byte array input, received $($InputObject.GetType())"
+ throw [System.ArgumentException]::new("ConvertTo-Base64UrlString requires string or byte array input, received $($InputObject.GetType())")
}
}
diff --git a/src/functions/public/Get-JwtHeader.ps1 b/src/functions/public/Get-JwtHeader.ps1
index 3c60522..9537bee 100644
--- a/src/functions/public/Get-JwtHeader.ps1
+++ b/src/functions/public/Get-JwtHeader.ps1
@@ -24,7 +24,7 @@
This command decodes only the header segment and does not validate the token signature.
.LINK
- https://github.com/SP3269/posh-jwt
+ https://psmodule.io/Jwt/Functions/Get-JwtHeader/
.LINK
https://jwt.io/
diff --git a/src/functions/public/Get-JwtPayload.ps1 b/src/functions/public/Get-JwtPayload.ps1
index 58e920e..4f3aa81 100644
--- a/src/functions/public/Get-JwtPayload.ps1
+++ b/src/functions/public/Get-JwtPayload.ps1
@@ -23,7 +23,7 @@
This command decodes only the payload segment and does not validate the token signature.
.LINK
- https://github.com/SP3269/posh-jwt
+ https://psmodule.io/Jwt/Functions/Get-JwtPayload/
.LINK
https://jwt.io/
diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1
index 3c48297..6452b80 100644
--- a/src/functions/public/New-Jwt.ps1
+++ b/src/functions/public/New-Jwt.ps1
@@ -36,7 +36,7 @@
RS256 requires a certificate with a private key. HS256 requires a string or byte array secret.
.LINK
- https://github.com/SP3269/posh-jwt
+ https://psmodule.io/Jwt/Functions/New-Jwt/
.LINK
https://jwt.io/
@@ -119,18 +119,17 @@
if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
throw 'HS256 requires -Secret parameter'
}
+ if ($Secret -isnot [byte[]] -and $Secret -isnot [string]) {
+ throw [System.ArgumentException]::new("Expected Secret parameter as byte array or string, instead got $($Secret.GetType())")
+ }
+ $hmacsha256 = [System.Security.Cryptography.HMACSHA256]::new()
try {
- $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
- if ($Secret -is [byte[]]) {
- $hmacsha256.Key = $Secret
- } elseif ($Secret -is [string]) {
- $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
- } else {
- throw "Expected Secret parameter as byte array or string, instead got $($Secret.GetType())"
- }
+ $hmacsha256.Key = if ($Secret -is [byte[]]) { $Secret } else { [System.Text.Encoding]::UTF8.GetBytes($Secret) }
$encodedSignature = ConvertTo-Base64UrlString $hmacsha256.ComputeHash($contentBytes)
} catch {
throw [System.Exception]::new("Signing with HMACSHA256 failed: $_", $_.Exception)
+ } finally {
+ $hmacsha256.Dispose()
}
}
'none' {
diff --git a/src/functions/public/Test-Jwt.ps1 b/src/functions/public/Test-Jwt.ps1
index 68f2d2a..df1ef5d 100644
--- a/src/functions/public/Test-Jwt.ps1
+++ b/src/functions/public/Test-Jwt.ps1
@@ -31,7 +31,7 @@ function Test-Jwt {
The Verify-JwtSignature alias is preserved for compatibility with the original module command surface.
.LINK
- https://github.com/SP3269/posh-jwt
+ https://psmodule.io/Jwt/Functions/Test-Jwt/
.LINK
https://jwt.io/
@@ -77,9 +77,8 @@ function Test-Jwt {
}
$bytes = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
Write-Verbose "Using certificate with subject: $($Cert.Subject)"
- $sha256 = New-Object Security.Cryptography.SHA256Managed
$signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
- $computed = $sha256.ComputeHash($signedContent)
+ $computed = [System.Security.Cryptography.SHA256]::HashData($signedContent)
$cert.PublicKey.Key.VerifyHash(
$computed,
$bytes,
@@ -91,18 +90,19 @@ function Test-Jwt {
if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
throw 'HS256 requires -Secret parameter'
}
- $hmacsha256 = New-Object System.Security.Cryptography.HMACSHA256
- if ($Secret -is [byte[]]) {
- $hmacsha256.Key = $Secret
- } elseif ($Secret -is [string]) {
- $hmacsha256.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
- } else {
- throw "Expected Secret parameter as byte array or string, instead got $($Secret.GetType())"
+ if ($Secret -isnot [byte[]] -and $Secret -isnot [string]) {
+ throw [System.ArgumentException]::new("Expected Secret parameter as byte array or string, instead got $($Secret.GetType())")
+ }
+ $hmacsha256 = [System.Security.Cryptography.HMACSHA256]::new()
+ try {
+ $hmacsha256.Key = if ($Secret -is [byte[]]) { $Secret } else { [System.Text.Encoding]::UTF8.GetBytes($Secret) }
+ $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
+ $signature = $hmacsha256.ComputeHash($signedContent)
+ $encoded = ConvertTo-Base64UrlString $signature
+ $encoded -eq $parts[2]
+ } finally {
+ $hmacsha256.Dispose()
}
- $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
- $signature = $hmacsha256.ComputeHash($signedContent)
- $encoded = ConvertTo-Base64UrlString $signature
- $encoded -eq $parts[2]
}
'none' {
-not $parts[2]
From 726ffa9ded74e13683f43e086fa7ed6135ee8788 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sun, 10 May 2026 21:08:52 +0200
Subject: [PATCH 24/29] Fix JWT hardening gaps from PR review
---
.github/linters/trivy.yaml | 3 --
.../public/ConvertFrom-Base64UrlString.ps1 | 2 +-
src/functions/public/Get-JwtHeader.ps1 | 8 ++-
src/functions/public/Get-JwtPayload.ps1 | 8 ++-
src/functions/public/New-Jwt.ps1 | 8 +--
src/functions/public/Test-Jwt.ps1 | 53 ++++++++++++++----
tests/Jwt.Tests.ps1 | 54 +++++++++++++++++++
7 files changed, 117 insertions(+), 19 deletions(-)
delete mode 100644 .github/linters/trivy.yaml
diff --git a/.github/linters/trivy.yaml b/.github/linters/trivy.yaml
deleted file mode 100644
index a1fbdd5..0000000
--- a/.github/linters/trivy.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-scan:
- skip-files:
- - src/JWT.psm1
diff --git a/src/functions/public/ConvertFrom-Base64UrlString.ps1 b/src/functions/public/ConvertFrom-Base64UrlString.ps1
index 16f6418..e6ae97e 100644
--- a/src/functions/public/ConvertFrom-Base64UrlString.ps1
+++ b/src/functions/public/ConvertFrom-Base64UrlString.ps1
@@ -48,7 +48,7 @@
$base64String = $Base64UrlString.Replace('-', '+').Replace('_', '/')
switch ($base64String.Length % 4) {
0 { }
- 1 { $base64String = $base64String.Substring(0, $base64String.Length - 1) }
+ 1 { throw [System.FormatException]::new('Invalid base64url string length.') }
2 { $base64String = $base64String + '==' }
3 { $base64String = $base64String + '=' }
}
diff --git a/src/functions/public/Get-JwtHeader.ps1 b/src/functions/public/Get-JwtHeader.ps1
index 9537bee..c38bf86 100644
--- a/src/functions/public/Get-JwtHeader.ps1
+++ b/src/functions/public/Get-JwtHeader.ps1
@@ -41,8 +41,14 @@
begin {}
process {
- Write-Verbose "Processing JWT: $Jwt"
+ Write-Verbose "Processing JWT with length $($Jwt.Length) characters"
$parts = $Jwt.Split('.')
+ if ($parts.Count -ne 3) {
+ throw [System.ArgumentException]::new('JWT must have exactly 3 segments.')
+ }
+ if (-not $parts[0]) {
+ throw [System.ArgumentException]::new('JWT header segment is missing.')
+ }
ConvertFrom-Base64UrlString $parts[0]
}
diff --git a/src/functions/public/Get-JwtPayload.ps1 b/src/functions/public/Get-JwtPayload.ps1
index 4f3aa81..78eb909 100644
--- a/src/functions/public/Get-JwtPayload.ps1
+++ b/src/functions/public/Get-JwtPayload.ps1
@@ -40,8 +40,14 @@
begin {}
process {
- Write-Verbose "Processing JWT: $Jwt"
+ Write-Verbose "Processing JWT with length $($Jwt.Length) characters"
$parts = $Jwt.Split('.')
+ if ($parts.Count -ne 3) {
+ throw [System.ArgumentException]::new('JWT must have exactly 3 segments.')
+ }
+ if (-not $parts[1]) {
+ throw [System.ArgumentException]::new('JWT payload segment is missing.')
+ }
ConvertFrom-Base64UrlString $parts[1]
}
diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1
index 6452b80..ab8268b 100644
--- a/src/functions/public/New-Jwt.ps1
+++ b/src/functions/public/New-Jwt.ps1
@@ -72,7 +72,7 @@
begin {}
process {
- Write-Verbose "Payload to sign: $PayloadJson"
+ Write-Verbose "Payload to sign length: $($PayloadJson.Length) characters"
try {
$algorithm = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg
@@ -98,7 +98,7 @@
throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
}
Write-Verbose "Signing certificate: $($Cert.Subject)"
- $rsa = $Cert.PrivateKey
+ $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Cert)
if ($null -eq $rsa) {
throw "There's no private key in the supplied certificate - cannot sign"
} else {
@@ -110,8 +110,10 @@
)
$encodedSignature = ConvertTo-Base64UrlString $signature
} catch {
- $message = "Signing with SHA256 and Pkcs1 padding failed using private key $($rsa): $_"
+ $message = "Signing with SHA256 and Pkcs1 padding failed using the certificate private key: $_"
throw [System.Exception]::new($message, $_.Exception)
+ } finally {
+ $rsa.Dispose()
}
}
}
diff --git a/src/functions/public/Test-Jwt.ps1 b/src/functions/public/Test-Jwt.ps1
index df1ef5d..dafe781 100644
--- a/src/functions/public/Test-Jwt.ps1
+++ b/src/functions/public/Test-Jwt.ps1
@@ -59,9 +59,18 @@ function Test-Jwt {
begin {}
process {
- Write-Verbose "Verifying JWT: $Jwt"
+ Write-Verbose "Verifying JWT with length $($Jwt.Length) characters"
$parts = $Jwt.Split('.')
+ if ($parts.Count -ne 3) {
+ throw [System.ArgumentException]::new('JWT must have exactly 3 segments.')
+ }
+ if (-not $parts[0]) {
+ throw [System.ArgumentException]::new('JWT header segment is missing.')
+ }
+ if (-not $parts[1]) {
+ throw [System.ArgumentException]::new('JWT payload segment is missing.')
+ }
$header = ConvertFrom-Base64UrlString $parts[0]
try {
$algorithm = (ConvertFrom-Json -InputObject $header -ErrorAction Stop).alg
@@ -79,12 +88,20 @@ function Test-Jwt {
Write-Verbose "Using certificate with subject: $($Cert.Subject)"
$signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
$computed = [System.Security.Cryptography.SHA256]::HashData($signedContent)
- $cert.PublicKey.Key.VerifyHash(
- $computed,
- $bytes,
- [Security.Cryptography.HashAlgorithmName]::SHA256,
- [Security.Cryptography.RSASignaturePadding]::Pkcs1
- )
+ $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($Cert)
+ if ($null -eq $rsa) {
+ throw "There's no RSA public key in the supplied certificate - cannot verify"
+ }
+ try {
+ $rsa.VerifyHash(
+ $computed,
+ $bytes,
+ [Security.Cryptography.HashAlgorithmName]::SHA256,
+ [Security.Cryptography.RSASignaturePadding]::Pkcs1
+ )
+ } finally {
+ $rsa.Dispose()
+ }
}
'HS256' {
if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
@@ -98,14 +115,30 @@ function Test-Jwt {
$hmacsha256.Key = if ($Secret -is [byte[]]) { $Secret } else { [System.Text.Encoding]::UTF8.GetBytes($Secret) }
$signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
$signature = $hmacsha256.ComputeHash($signedContent)
- $encoded = ConvertTo-Base64UrlString $signature
- $encoded -eq $parts[2]
+ if (-not $parts[2]) {
+ $false
+ } else {
+ try {
+ $providedSignature = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
+ } catch [System.FormatException] {
+ $providedSignature = $null
+ }
+ if ($null -eq $providedSignature -or $signature.Length -ne $providedSignature.Length) {
+ $false
+ } else {
+ $difference = 0
+ for ($index = 0; $index -lt $signature.Length; $index++) {
+ $difference = $difference -bor ($signature[$index] -bxor $providedSignature[$index])
+ }
+ $difference -eq 0
+ }
+ }
} finally {
$hmacsha256.Dispose()
}
}
'none' {
- -not $parts[2]
+ $parts[2] -eq ''
}
default {
throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
index d3bf754..80d3038 100644
--- a/tests/Jwt.Tests.ps1
+++ b/tests/Jwt.Tests.ps1
@@ -66,6 +66,10 @@ Describe 'Data-driven tests' {
[System.Text.Encoding]::UTF8.GetString($bytes) | Should -Be 'Hello'
}
+ It 'ConvertFrom-Base64UrlString - rejects invalid base64url length' {
+ { ConvertFrom-Base64UrlString 'A' } | Should -Throw '*Invalid base64url string length*'
+ }
+
It 'ConvertTo-Base64UrlString - throws for unsupported input types' {
{ ConvertTo-Base64UrlString ([pscustomobject]@{ Value = 'invalid' }) } | Should -Throw '*requires string or byte array input*'
}
@@ -80,5 +84,55 @@ Describe 'Data-driven tests' {
It 'New-Jwt - requires the payload to be valid JSON' {
{ New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson 'not-json' -Secret 'super-secret' } | Should -Throw '*payload is not JSON*'
}
+
+ It 'Get-JwtHeader - requires exactly three JWT segments' {
+ { Get-JwtHeader 'header.payload' } | Should -Throw '*JWT must have exactly 3 segments*'
+ }
+
+ It 'Get-JwtPayload - requires a payload segment' {
+ { Get-JwtPayload 'header..signature' } | Should -Throw '*JWT payload segment is missing*'
+ }
+
+ It 'Test-Jwt - requires exactly three JWT segments' {
+ { Test-Jwt 'header.payload' } | Should -Throw '*JWT must have exactly 3 segments*'
+ }
+
+ It 'Test-Jwt - rejects unsigned tokens without a third segment' {
+ $header = ConvertTo-Base64UrlString '{"alg":"none","typ":"JWT"}'
+ $payload = ConvertTo-Base64UrlString '{"sub":"joe","role":"admin"}'
+
+ { Test-Jwt "$header.$payload" } | Should -Throw '*JWT must have exactly 3 segments*'
+ }
+
+ It 'Test-Jwt - returns false for an invalid HS256 signature segment' {
+ $jwt = New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson '{"sub":"joe","role":"admin"}' -Secret 'super-secret'
+ $parts = $jwt.Split('.')
+ $parts[2] = 'A'
+
+ Test-Jwt -jwt ($parts -join '.') -Secret 'super-secret' | Should -BeFalse
+ }
+
+ It 'Verbose output does not include JWT or payload values' {
+ $payload = '{"sub":"joe","role":"admin"}'
+ $jwt = New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson $payload -Secret 'super-secret'
+
+ $newJwtVerbose = & { New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson $payload -Secret 'super-secret' -Verbose } 4>&1 |
+ Where-Object { $_.GetType().Name -eq 'VerboseRecord' } |
+ Out-String
+ $getHeaderVerbose = & { Get-JwtHeader $jwt -Verbose } 4>&1 |
+ Where-Object { $_.GetType().Name -eq 'VerboseRecord' } |
+ Out-String
+ $getPayloadVerbose = & { Get-JwtPayload $jwt -Verbose } 4>&1 |
+ Where-Object { $_.GetType().Name -eq 'VerboseRecord' } |
+ Out-String
+ $testJwtVerbose = & { Test-Jwt -jwt $jwt -Secret 'super-secret' -Verbose } 4>&1 |
+ Where-Object { $_.GetType().Name -eq 'VerboseRecord' } |
+ Out-String
+
+ $newJwtVerbose | Should -Not -Match ([regex]::Escape($payload))
+ $getHeaderVerbose | Should -Not -Match ([regex]::Escape($jwt))
+ $getPayloadVerbose | Should -Not -Match ([regex]::Escape($jwt))
+ $testJwtVerbose | Should -Not -Match ([regex]::Escape($jwt))
+ }
}
}
From 4ae210b40e87fcc617dcc00d435afd2f48b29705 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Sun, 10 May 2026 22:34:50 +0200
Subject: [PATCH 25/29] Restore trivy.yaml to prevent default exit-code:1
triggering on test data
Without a repo-level .github/linters/trivy.yaml, Super-Linter falls back
to its built-in /action/lib/.automation/trivy.yaml which has exit-code:1
enabled. JWT test token strings in tests/Data/TestCases.ps1 are flagged
by Trivy's secret scanner, causing the lint step to fail with no source-
level change needed. Restore the config to override the default: our
scanners list matches the default but without exit-code:1, so real build
artifacts continue to be skipped and false positives from test data do
not block CI.
---
.github/linters/trivy.yaml | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 .github/linters/trivy.yaml
diff --git a/.github/linters/trivy.yaml b/.github/linters/trivy.yaml
new file mode 100644
index 0000000..0646d5d
--- /dev/null
+++ b/.github/linters/trivy.yaml
@@ -0,0 +1,10 @@
+scan:
+ disable-telemetry: true
+ scanners:
+ - vuln
+ - misconfig
+ - secret
+ skip-files:
+ # src/JWT.psm1 is a build artifact generated by Process-PSModule and is
+ # not present in source. Skip it so lint runs against checked-in files only.
+ - src/JWT.psm1
From 243d072f385dcc72d3c03c04f5838fe9072109c5 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Mon, 11 May 2026 00:23:43 +0200
Subject: [PATCH 26/29] Fix reviewer-identified hardening gaps: typed
exceptions without token data, RS256 empty-sig guard
- Test-Jwt RS256: return false for empty third segment instead of binding exception
- Test-Jwt: throw typed FormatException for non-JSON header (no header content in message)
- New-Jwt: throw typed FormatException for non-JSON header and payload (no content in messages)
- Update test assertion wildcard to match new payload error message text
---
src/functions/public/New-Jwt.ps1 | 4 ++--
src/functions/public/Test-Jwt.ps1 | 5 ++++-
tests/Jwt.Tests.ps1 | 2 +-
3 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1
index ab8268b..8a9385d 100644
--- a/src/functions/public/New-Jwt.ps1
+++ b/src/functions/public/New-Jwt.ps1
@@ -77,14 +77,14 @@
try {
$algorithm = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg
} catch {
- throw "The supplied JWT header is not JSON: $Header"
+ throw [System.FormatException]::new("The supplied JWT header is not valid JSON. Header length: $($Header.Length) characters.")
}
Write-Verbose "Algorithm: $algorithm"
try {
$null = ConvertFrom-Json -InputObject $PayloadJson -ErrorAction Stop
} catch {
- throw "The supplied JWT payload is not JSON: $PayloadJson"
+ throw [System.FormatException]::new("The supplied JWT payload is not valid JSON. Payload length: $($PayloadJson.Length) characters.")
}
$encodedHeader = ConvertTo-Base64UrlString $Header
diff --git a/src/functions/public/Test-Jwt.ps1 b/src/functions/public/Test-Jwt.ps1
index dafe781..1d0b613 100644
--- a/src/functions/public/Test-Jwt.ps1
+++ b/src/functions/public/Test-Jwt.ps1
@@ -75,7 +75,7 @@ function Test-Jwt {
try {
$algorithm = (ConvertFrom-Json -InputObject $header -ErrorAction Stop).alg
} catch {
- throw "The supplied JWT header is not JSON: $header"
+ throw [System.FormatException]::new("The supplied JWT header segment is not valid JSON. Header length: $($header.Length) characters.")
}
Write-Verbose "Algorithm: $algorithm"
@@ -84,6 +84,9 @@ function Test-Jwt {
if (-not $PSBoundParameters.ContainsKey('Cert')) {
throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
}
+ if ([string]::IsNullOrEmpty($parts[2])) {
+ return $false
+ }
$bytes = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
Write-Verbose "Using certificate with subject: $($Cert.Subject)"
$signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
index 80d3038..70b3a9f 100644
--- a/tests/Jwt.Tests.ps1
+++ b/tests/Jwt.Tests.ps1
@@ -82,7 +82,7 @@ Describe 'Data-driven tests' {
}
It 'New-Jwt - requires the payload to be valid JSON' {
- { New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson 'not-json' -Secret 'super-secret' } | Should -Throw '*payload is not JSON*'
+ { New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson 'not-json' -Secret 'super-secret' } | Should -Throw '*payload is not valid JSON*'
}
It 'Get-JwtHeader - requires exactly three JWT segments' {
From 383fcf98972e9c1fe8393eab9f0ff74eece79fb9 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Mon, 11 May 2026 01:01:52 +0200
Subject: [PATCH 27/29] Fix PSAvoidLongLines on test line 85: split long Should
-Throw assertion
---
tests/Jwt.Tests.ps1 | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
index 70b3a9f..3ec9a7b 100644
--- a/tests/Jwt.Tests.ps1
+++ b/tests/Jwt.Tests.ps1
@@ -82,7 +82,9 @@ Describe 'Data-driven tests' {
}
It 'New-Jwt - requires the payload to be valid JSON' {
- { New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson 'not-json' -Secret 'super-secret' } | Should -Throw '*payload is not valid JSON*'
+ $header = '{"alg":"HS256","typ":"JWT"}'
+ { New-Jwt -Header $header -PayloadJson 'not-json' -Secret 'super-secret' } |
+ Should -Throw '*payload is not valid JSON*'
}
It 'Get-JwtHeader - requires exactly three JWT segments' {
From 942101a3181a3d8b688a71f0d358e4df632bfb7e Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Mon, 11 May 2026 01:18:45 +0200
Subject: [PATCH 28/29] Fix reviewer-identified issues: RS256 malformed-sig
guard, typed param exceptions, add src/manifest.psd1
- Test-Jwt RS256: catch FormatException from ConvertFrom-Base64UrlString \[2] and return \False
- Test-Jwt + New-Jwt: replace untyped string throws with ArgumentException/NotSupportedException
for missing -Cert, missing -Secret, no-RSA-key, and unsupported-algorithm cases
- Add src/manifest.psd1 with JWT gallery tags (JWT, JSON, Token, Authentication, Security, PSModule)
- Update test assertion wildcard for HS256-requires-Secret message to match new wording
---
src/functions/public/New-Jwt.ps1 | 8 ++++----
src/functions/public/Test-Jwt.ps1 | 14 +++++++++-----
src/manifest.psd1 | 14 ++++++++++++++
tests/Jwt.Tests.ps1 | 2 +-
4 files changed, 28 insertions(+), 10 deletions(-)
create mode 100644 src/manifest.psd1
diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1
index 8a9385d..47639c8 100644
--- a/src/functions/public/New-Jwt.ps1
+++ b/src/functions/public/New-Jwt.ps1
@@ -95,12 +95,12 @@
switch ($algorithm) {
'RS256' {
if (-not $PSBoundParameters.ContainsKey('Cert')) {
- throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
+ throw [System.ArgumentException]::new('RS256 requires a -Cert parameter of type X509Certificate2.', 'Cert')
}
Write-Verbose "Signing certificate: $($Cert.Subject)"
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Cert)
if ($null -eq $rsa) {
- throw "There's no private key in the supplied certificate - cannot sign"
+ throw [System.ArgumentException]::new('The supplied certificate has no RSA private key and cannot be used to sign.', 'Cert')
} else {
try {
$signature = $rsa.SignData(
@@ -119,7 +119,7 @@
}
'HS256' {
if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
- throw 'HS256 requires -Secret parameter'
+ throw [System.ArgumentException]::new('HS256 requires a -Secret parameter.', 'Secret')
}
if ($Secret -isnot [byte[]] -and $Secret -isnot [string]) {
throw [System.ArgumentException]::new("Expected Secret parameter as byte array or string, instead got $($Secret.GetType())")
@@ -138,7 +138,7 @@
$encodedSignature = $null
}
default {
- throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
+ throw [System.NotSupportedException]::new('The algorithm is not one of the supported: "RS256", "HS256", "none".')
}
}
diff --git a/src/functions/public/Test-Jwt.ps1 b/src/functions/public/Test-Jwt.ps1
index 1d0b613..8be3f21 100644
--- a/src/functions/public/Test-Jwt.ps1
+++ b/src/functions/public/Test-Jwt.ps1
@@ -82,18 +82,22 @@ function Test-Jwt {
switch ($algorithm) {
'RS256' {
if (-not $PSBoundParameters.ContainsKey('Cert')) {
- throw 'RS256 requires -Cert parameter of type System.Security.Cryptography.X509Certificates.X509Certificate2'
+ throw [System.ArgumentException]::new('RS256 requires a -Cert parameter of type X509Certificate2.', 'Cert')
}
if ([string]::IsNullOrEmpty($parts[2])) {
return $false
}
- $bytes = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
+ try {
+ $bytes = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
+ } catch [System.FormatException] {
+ return $false
+ }
Write-Verbose "Using certificate with subject: $($Cert.Subject)"
$signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
$computed = [System.Security.Cryptography.SHA256]::HashData($signedContent)
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($Cert)
if ($null -eq $rsa) {
- throw "There's no RSA public key in the supplied certificate - cannot verify"
+ throw [System.ArgumentException]::new('The supplied certificate has no RSA public key and cannot be used to verify.', 'Cert')
}
try {
$rsa.VerifyHash(
@@ -108,7 +112,7 @@ function Test-Jwt {
}
'HS256' {
if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
- throw 'HS256 requires -Secret parameter'
+ throw [System.ArgumentException]::new('HS256 requires a -Secret parameter.', 'Secret')
}
if ($Secret -isnot [byte[]] -and $Secret -isnot [string]) {
throw [System.ArgumentException]::new("Expected Secret parameter as byte array or string, instead got $($Secret.GetType())")
@@ -144,7 +148,7 @@ function Test-Jwt {
$parts[2] -eq ''
}
default {
- throw 'The algorithm is not one of the supported: "RS256", "HS256", "none"'
+ throw [System.NotSupportedException]::new('The algorithm is not one of the supported: "RS256", "HS256", "none".')
}
}
}
diff --git a/src/manifest.psd1 b/src/manifest.psd1
new file mode 100644
index 0000000..40bd8bb
--- /dev/null
+++ b/src/manifest.psd1
@@ -0,0 +1,14 @@
+@{
+ PrivateData = @{
+ PSData = @{
+ Tags = @(
+ 'JWT'
+ 'JSON'
+ 'Token'
+ 'Authentication'
+ 'Security'
+ 'PSModule'
+ )
+ }
+ }
+}
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
index 3ec9a7b..283ac90 100644
--- a/tests/Jwt.Tests.ps1
+++ b/tests/Jwt.Tests.ps1
@@ -55,7 +55,7 @@ Describe 'Data-driven tests' {
}
It 'New-Jwt - requires a secret' {
- { New-Jwt -Header $Header -PayloadJson $Payload } | Should -Throw '*HS256 requires -Secret parameter*'
+ { New-Jwt -Header $Header -PayloadJson $Payload } | Should -Throw '*HS256 requires*Secret*'
}
}
From dfc66452bc1264e8ec3df4e7b92823bafab0eb45 Mon Sep 17 00:00:00 2001
From: Marius Storhaug
Date: Mon, 11 May 2026 20:23:41 +0200
Subject: [PATCH 29/29] Fix missing alg validation and add paramName to
ArgumentException; add tests
- New-Jwt/Test-Jwt: throw FormatException when header JSON parses successfully
but contains no alg claim, giving a targeted error instead of a misleading
NotSupportedException (threads r3215702514 / r3215702516)
- New-Jwt/Test-Jwt: add paramName 'Secret' to the HS256 Secret-type
ArgumentException so callers can consistently identify the failing parameter
(threads r3220268341 / r3220268308)
- tests: add coverage for missing alg in New-Jwt and Test-Jwt
---
src/functions/public/New-Jwt.ps1 | 5 ++++-
src/functions/public/Test-Jwt.ps1 | 5 ++++-
tests/Jwt.Tests.ps1 | 13 +++++++++++++
3 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1
index 47639c8..1d5bcb6 100644
--- a/src/functions/public/New-Jwt.ps1
+++ b/src/functions/public/New-Jwt.ps1
@@ -79,6 +79,9 @@
} catch {
throw [System.FormatException]::new("The supplied JWT header is not valid JSON. Header length: $($Header.Length) characters.")
}
+ if ([string]::IsNullOrEmpty($algorithm)) {
+ throw [System.FormatException]::new('The JWT header is missing the required "alg" claim.')
+ }
Write-Verbose "Algorithm: $algorithm"
try {
@@ -122,7 +125,7 @@
throw [System.ArgumentException]::new('HS256 requires a -Secret parameter.', 'Secret')
}
if ($Secret -isnot [byte[]] -and $Secret -isnot [string]) {
- throw [System.ArgumentException]::new("Expected Secret parameter as byte array or string, instead got $($Secret.GetType())")
+ throw [System.ArgumentException]::new("Expected Secret parameter as byte array or string, instead got $($Secret.GetType())", 'Secret')
}
$hmacsha256 = [System.Security.Cryptography.HMACSHA256]::new()
try {
diff --git a/src/functions/public/Test-Jwt.ps1 b/src/functions/public/Test-Jwt.ps1
index 8be3f21..445c474 100644
--- a/src/functions/public/Test-Jwt.ps1
+++ b/src/functions/public/Test-Jwt.ps1
@@ -77,6 +77,9 @@ function Test-Jwt {
} catch {
throw [System.FormatException]::new("The supplied JWT header segment is not valid JSON. Header length: $($header.Length) characters.")
}
+ if ([string]::IsNullOrEmpty($algorithm)) {
+ throw [System.FormatException]::new('The JWT header is missing the required "alg" claim.')
+ }
Write-Verbose "Algorithm: $algorithm"
switch ($algorithm) {
@@ -115,7 +118,7 @@ function Test-Jwt {
throw [System.ArgumentException]::new('HS256 requires a -Secret parameter.', 'Secret')
}
if ($Secret -isnot [byte[]] -and $Secret -isnot [string]) {
- throw [System.ArgumentException]::new("Expected Secret parameter as byte array or string, instead got $($Secret.GetType())")
+ throw [System.ArgumentException]::new("Expected Secret parameter as byte array or string, instead got $($Secret.GetType())", 'Secret')
}
$hmacsha256 = [System.Security.Cryptography.HMACSHA256]::new()
try {
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
index 283ac90..d32242c 100644
--- a/tests/Jwt.Tests.ps1
+++ b/tests/Jwt.Tests.ps1
@@ -87,6 +87,19 @@ Describe 'Data-driven tests' {
Should -Throw '*payload is not valid JSON*'
}
+ It 'New-Jwt - rejects a header missing the alg claim' {
+ { New-Jwt -Header '{"typ":"JWT"}' -PayloadJson '{"sub":"joe"}' -Secret 'super-secret' } |
+ Should -Throw '*missing the required "alg" claim*'
+ }
+
+ It 'Test-Jwt - rejects a token with a header missing the alg claim' {
+ $header = ConvertTo-Base64UrlString '{"typ":"JWT"}'
+ $payload = ConvertTo-Base64UrlString '{"sub":"joe"}'
+ $sig = ConvertTo-Base64UrlString 'fakesig'
+ { Test-Jwt "$header.$payload.$sig" -Secret 'super-secret' } |
+ Should -Throw '*missing the required "alg" claim*'
+ }
+
It 'Get-JwtHeader - requires exactly three JWT segments' {
{ Get-JwtHeader 'header.payload' } | Should -Throw '*JWT must have exactly 3 segments*'
}