From 2bd386842a21d036d8e66ff04d005db891b249f4 Mon Sep 17 00:00:00 2001 From: Ricardo Martin Date: Mon, 23 Feb 2026 19:57:00 +0100 Subject: [PATCH] Step up authentication for saml - preview (#44185) Closes #10155 Signed-off-by: rmartinc --- .../java/org/keycloak/common/Profile.java | 1 + .../release_notes/topics/26_6_0.adoc | 3 + .../images/realm-oidc-map-acr-to-loa.png | Bin 9410 -> 14435 bytes .../images/realm-oidc-map-acr-uri-to-loa.png | Bin 0 -> 22330 bytes .../topics/authentication/flows.adoc | 84 +- .../saml/proc-creating-saml-client.adoc | 4 + .../login-settings/acr-to-loa-mapping.adoc | 6 + .../admin/messages/messages_en.properties | 12 + .../src/clients/advanced/AdvancedSettings.tsx | 150 +++- .../key-value-form/KeyValueInput.tsx | 33 +- .../realm-loa-mapping/RealmLoAMapping.tsx | 177 ++++ .../src/realm-settings/GeneralTab.tsx | 28 +- .../src/realm-settings/RealmSettingsTabs.tsx | 17 +- .../admin-ui/src/utils/useIsFeatureEnabled.ts | 1 + .../saml/SAML2ErrorResponseBuilder.java | 7 + .../keycloak/migration/MigrationProvider.java | 7 + .../java/org/keycloak/models/Constants.java | 1 + .../org/keycloak/protocol/LoginProtocol.java | 6 +- .../protocol/oidc/OIDCLoginProtocol.java | 1 + .../protocol/oidc/utils/AcrUtils.java | 45 +- .../keycloak/protocol/saml/SamlProtocol.java | 15 +- .../protocol/saml/SamlProtocolFactory.java | 25 + .../protocol/saml/SamlProtocolUtils.java | 73 ++ .../keycloak/protocol/saml/SamlService.java | 42 + .../mappers/AuthnContextClassRefMapper.java | 161 ++++ .../migration/DefaultMigrationProvider.java | 11 + .../DefaultClientValidationProvider.java | 56 +- .../org.keycloak.protocol.ProtocolMapper | 1 + .../testsuite/util/SamlClientBuilder.java | 5 + .../saml/CreateAuthnRequestStepBuilder.java | 24 + .../testsuite/util/saml/OtpLoginBuilder.java | 94 ++ .../forms/LevelOfAssuranceFlowTest.java | 14 +- .../saml/LevelOfAssuranceFlowSamlTest.java | 827 ++++++++++++++++++ .../adapter-test/keycloak-saml/testsaml.json | 21 + 34 files changed, 1880 insertions(+), 72 deletions(-) create mode 100644 docs/documentation/server_admin/images/realm-oidc-map-acr-uri-to-loa.png create mode 100644 js/apps/admin-ui/src/components/realm-loa-mapping/RealmLoAMapping.tsx create mode 100644 services/src/main/java/org/keycloak/protocol/saml/mappers/AuthnContextClassRefMapper.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/OtpLoginBuilder.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LevelOfAssuranceFlowSamlTest.java diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index b2af3d91c7b..217a623d363 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -95,6 +95,7 @@ public class Profile { CLIENT_SECRET_ROTATION("Client Secret Rotation", Type.PREVIEW), STEP_UP_AUTHENTICATION("Step-up Authentication", Type.DEFAULT), + STEP_UP_AUTHENTICATION_SAML("Step-up Authentication Saml", Type.PREVIEW, Feature.STEP_UP_AUTHENTICATION), CLIENT_AUTH_FEDERATED("Authenticates client based on assertions issued by identity provider", Type.PREVIEW), diff --git a/docs/documentation/release_notes/topics/26_6_0.adoc b/docs/documentation/release_notes/topics/26_6_0.adoc index ca16faf025c..0e2f37f7c46 100644 --- a/docs/documentation/release_notes/topics/26_6_0.adoc +++ b/docs/documentation/release_notes/topics/26_6_0.adoc @@ -54,6 +54,9 @@ When using the {project_name} Operator, set the update strategy to `Auto` to ben For more details on the Operator configuration, see the https://www.keycloak.org/operator/rolling-updates[Avoiding downtime with rolling updates] guide. += Step-up authentication for SAML (preview) + +The feature `step-up-authentication-saml` extends the step-up authentication to include the SAML protocol and clients. This feature is in preview mode. Additional information is available in the link:{adminguide_link}#_step-up-authentication-saml[{adminguide_name}]. = Java 25 support diff --git a/docs/documentation/server_admin/images/realm-oidc-map-acr-to-loa.png b/docs/documentation/server_admin/images/realm-oidc-map-acr-to-loa.png index 7bb56032f4c35e6e2d2b59ce691127b8f5380c25..d2d0c0b4e3cc831f8009dab103c36ce8d908a8d4 100644 GIT binary patch literal 14435 zcmdUWXH-*N*ku$IL=llDAWfw=r6UB8D!qnYrFW!v5aC6s3euZ&2qpB;yP)*mLg*a= zNJ%IHbEEI~&HR`jvu4fA`X-A7cO~3=p3|PapS@2$sVd77;8Ws*Kp+CSH&W^#&@C(A z`W){L@cX_A>pJj)7PNIibo4)2D;>f4c?D4jddM55>**oxqvJI#w={7dy}N(|~;ZKw zGVEpwr(whNXL`vJ)hvlSpdXL-!IXmac(+=-@(=wvsVLay_#JgOt%|I6@X*reTN!6_cNSR z3k#WdW~!_@&f?j$XS+TTqe|iPm34K3R6LfEH$WZwg{@2=P*5PARS;f&`D&_DONHe? zita!PACh(vh{iNqT)H|cIQjS45n{T+tW={w`R)|8Sisyr1XSx+CQf7dO4*2_x ziGn&I`1tb$R7KMV?8r1@rFL?i`IiTzBa2kW*_PB^7*qetlt&|r!G1$t&oMa+SK ziobgKdxZW}nIUqta5d!b(<7Ykd{hl1q^(uUk09Ue=7(LsZ+uU7`_AoL@&CK_J~;`)7}~v1@8k ztb>A1!aGo>SQELzcQnD2cizPdjCv1ysV4o=YMcrq@LWsG9!#m6UzPd0FT%!sB#G zUh73pM1@%xn``3Hpzx5tVY5%oJ*`m*Gs6>`5@kfKgDdW>SAy2#gpK)8#uqefZ9^Z_5F=IpYI0Io6IEWqkOZTXvdqfFXHR$9)&Tw6!wTmQSuoOFUrL+ z*8kH64U&rvrrOt-Uj51n>D_BOQuaMtOGIHejEd-47YJmf{N<_i+*GMPykXeDG@|F8 zW79Ar$Dbv7T}eIV^P$=xZ0m?O&->?5^Gn)J964;aIUN+eDC1zotQ^UgS^8Ccm`l$8#a;Q0`-~@9E z1Uew^O9{f`vFIhi;KHw_mCteEBy3v!{_4<&8^B$ji4$dph*0idzkZ?Ha0qpPja6#c z=&`4nC2`wxCIjp{cTzO+mC$K9E(He%#2u4z=CeN_r-E;2ez4fSXV)5VS>m+N!l|=A zS6_9{vaIFYR^TTK_|Jf}c3A4hiOy)tIcA7>mv|qo7t95$bVt=%k31D%)WOXF2C){{ zwoJ1!ne1CZJV}hC*3Irq9ian+c2i|Fo14}GLIpl2+X*wrPqyp7<}0P@0cl3rJ9TkG zF0=M_q8x52*thmVQhBHfUW-&ok@&Tl6Eju2Y{I$MB4KNNU`8hv7 z|5CFM3kO?#0*8LXLXV2rWp(rMsKOq%(pw+&j1{UKm)31#?BY-QU}8_;+oB(bLi&K& zS{uB+y4Yt)nBF!~HG96%pB%U8QrfBIy-%znVp{LI;gM+Q@{@tdVMDF%n@Z{|+{uYW zZAHWXJa-G%Hb0T#`1BZeT#^v{^{~?zzdbccG#`p&4i*#Qy zFGPj*)mA5vRUg)OWV={)M^bPm{Y({rQ42b^`G?2N#ooQVk=&#^P^x5~il0MEdqjUhN&SzSjdO4;TxG9SSVvL4Bn z&JX1&i0jA&@<t3}qaJydd%J*+!-cf0{P{IvjJ%A)`a8V$X7;Iv%qcf#@oW-pXL3 zI_p?oaS467d>f>9U7~`HQ-|HV&68*xP=C*sQ88)Jk;ipiQNQACXmN1;$)rBh%7{q833quqa~9GyvD>e`f^$kO_ozkB!2W5FL&pI?ffRUH7<)T2QI>z=8^ z4#N(hE2Bz=J%Fdx&e@+x1Qz_N^zm`AJV8A75S%u0ZhKNToHXydSKk9EVh&1v6jhGZrH$^MW2}f@zyNO zS@*RqDcHKp)pQV?`Zg|k)P>gQ$3(NQC%Nb+pATu_@Iix1{pU9Ek)B3ig2xce(2sN8K3K(CYL(S$~~sBjbxV zKE~J{gVH!#!gy@;3K#_S_;XK2=MS@TNjbJdpTZXceBUTJsz^`ps~4#=C=i~wj#RRO zId6mTaY!5sePsJ0xFz-WR)86L2jSB5EcGPgSR1%P;?En~do#4)1 zv&8Qv2{-gax-%hVAa87+7t-au{T}W(8{-J;?l78&hJG7gJOJ2;8zzhONvOc}6{gkj z&3dSO_OFat4}fZT%vdDWquj1CuYVWp5a%)u)9`6ey4yaW_R~jR$7>sHaZXumE96|L zJ+q8~hL68E`_)71*|&JSKVdfMTIOGP6F@P5PVls2oV)Feb|601K0!YDephE3wE!@L zpqg+lk3(3X{c7%Eo}-7%95+}s@+0l~Q=cHOhqEM+826pY8d%GDZ#s#(b;B3ReOucv z!FQA*)gJ<%;%eW`?(U+9pSRW>TU^baOU1m95;l^sqBVUK$Jvcs?}{0Sf5eUIZ! zHS(@PfzMY2Q2P-n7QSn6e7dk_!+~^+6)?J515U3iVZu#BLIl&Z6BVzQX~C9!l5_Lm#b7ntLV~ft!#0;Ia1cpDeMiQfbyz2 zB~+_3e4+_U*eHBmVJ#Gc@#=}BFr%Ojj!(D{^g8%NOJ3;_N?27%sf(T4jOOM)?~;#D6$&L;Cw#P zDv$&XY+@Xo#ZWmlEd{@9w$xhiZ2Nl!y$|(dumCJS;K7-+W4 zIC8Ln*H*V#cl;|n;PRYP$8G`|uP@J#b;|}n*c<@MBFBOY%uNxc5&3&uf`<~!``(S4 z*%5yWlGZbRdHsn;(B2>E!f@eJm2Ci*VZ~52Nds!BV%>76Wc7V{g)f>!U%n=kS$BCj zch2T&emdMPhRp)ZX#G4nmsduZuYvFv>q`+R`qzWsd~(dsu&Ki$G9$-jt(RfeNGbN@ z3G1eMV7zK!gY-~DM&>xwQfh*0TVRE66!IpwyCG|~(jwv_rMlhKr74IdXQ))beOvSW z!@naJ>=e(VmykpAT(4s_s{VnfO;M!R1P`=)ILZEmab%qKwm^Gzh|HyqW5ZUyCe@-X z>(>Mo%?+jXI9ezTIFNiMBEF_Xf5Q(4!k>?2j1p3gesJjC279;x zB!G8)pLvn85Q>y)O_&i_;OCoo0etUqK`jWwt4%GQ8KAn#^IE`XA5YSHmc&P?->ev$ zv)+ksJ)WjL@yb@SBJ4Ml35OJ5VLZIaasF-v@! zlSg@?oN`6RQNdK9g0`_cp`r!ZzebIw<0&tnSPo^NB%brpX^B0CjX0^N^yY;>QHlkK z$p^dflD_e4rmdAx9eWP5(N@EFG23o-=oO1WUAaZbO&ak0#a`nO%6Lc(Ap7wv92065|KtngNyY2~RgdQdmeCqcgeREU%zQnczW+pU_@qxQ&h+*h zdQVClRcB4Esb^M|x`GiFp(Sl`Upe=+a+m=oO1p<3rj_Q((-mFzaQ zg|@9#L}h;ew-1+*Xxh7>37UM4bAmHFn#rTNa>T;vgI)gSngB2&Sz-Y!Q|M{xDLuls zBnc`a;{LnLB&5Q4#C?inznoJS1;(7O7pnd0j-o=k>t)A}ZaV+%1J%-fW#QY@S<2<= zgpVEcfm|${PRVb-c}`r^6XQc?v;37(5@1dPMbQub&b0pMh4DD(RDcCzrb zf8#lnjUC{df90BopE~%{&W?}nqFa-zhzB?NzjOixuhqMEz8sbqM??)*0LJAv0he4WaX3hK{6hDvlbDF>@_T7 zEiQ;=!Bt4(J!SnzMmU!Il3MxIdd&-Tx_A~EoV(P#8YXWk=keJ_w| zcM<9OjS223m-u@&Wp%%~FVGifWK@kkqGx`=m(2*ie|yc+0gcPj-GZs z%8M_ae140EA)MT^z&KFf)TvpZ_W_N_Cg(m0p7rXKvG(IX3rYO>-QwP40xE%en9=vh z0<$oWy&clPe_mkbuRl7>H+`R!b}CQd8@!taZS3>howNCK6X?tHl8)pWyFgFloZyaR z;-x+zkAq?Q)FTvzjJjELso`*~w*dx_+;X4*pWZ`O{E1RrtJY%3RKGePaFN6*LZGX$ zk5X!_>K{_~UMGut8{AR}<@i>-PxVQvpVv4deW-Z$G%(Ex;2JwbFFqOjXJ`K`iO_#n zjP7i?E}|3@Ho{~my57^;LhED3(6}+G{?890ttG`8D8ymr&=?gXmR%ZveD$eJ+|9MUWUt0HpsNOKVm6@Zpd%;!|nbB91A7klHg=cYLH z;Tdk}lsGWG=2_lfMJPGsd?7 zq#jE^9O4JH&6q8ADe=2nq~#?#7W>mRg+c_M1@3!9U7YuguA%4dN$$c)nbbu?xheCI z6_OVr4q}&n!6#IwTo`|p9$$&>*b#yF?D6(QAE2>-*|bWMfT*XchD7aL*ZV7GYwV~% zi@W6Il#r-i={21tuRCGzn+prES0(WqYuy4A4B8j1?)vJTEPBFGP3Cy)10nJ>Nv zoO@?+7&LXI$M(jvB^oZ&L?Fo8evUI=00h*1z+byW$5bhmzb1mS;m0e_J^MWQ^v9)` zr7+srTj zN1gGsIZ(R-;4{YiH7 z#7XwYqYjC-{#2lJNyLnZor|+wDV4? zSYSZ-h3dDXAJ1Z@Pk*fdyvY&R28Blx+Qu_EF{0apv?5-^Wvy2}`YnF-$iVAMttv|; z?chM5ia-D|fO!lUt<&L<$f0j}cDUx+t^|lE4#VGGYc;spV7uhp@p}Qo@Cs&Pxfs7D z*?{UNefOz@0#&TSCYgkfaPHP9v#0R9`I#R}qv`tr<B1mxp zpVR!D==o+&Tt_lctM{-cn!h3Jn5 zYh3nOn7AzZ5(c7a#r6{T7?9*0LgAt$-r_vKj_7UkX; zrMq|&r%&nZ9!F0oH*kd{mQ&>s&>h{Uel^ZvH|mnRJ5$xT4{O|O2nk})w7oS4R9;xY z+U{n_^8kQ=lqE(@J=eLkY;uUpSyRG~03Hbn$#)F(OVH(ZHU8nJISA5COL%ab%TE1! z9}c0&Vn+h7h(nq(Qym+AhJxc5l`66FzD$Q++3Kh8a2JAroypQ2#uwPwek6^Cy37PA zuk9AerE)y0#_r*WUJAN<3T3}wQ!CP7v(aqZ3Fu_L64g|jHYxA9QKEOXeG#Ba@cW%Q zM?^*OVWXia*ixj-N;hh7C?w8Uqy=={h;x7*mKrttpm(c=7(;jwa3@bQ*u=QDM#8B4 z1P|cQxJ~f1LwGU(6rf4lLZ<;`rbIdY6?WCy0n#NY;8(PqE=Q2~cIM^-;<*+eIKoTh7MSu_R5kJy zD`cW5>+>{h#Y?tRt}ZVc8N2FT)a0?%dv4FKC8ZLph*)|jn1ef&)Iat;6Eno(UBj{T zN^2(2!mZv%Uk{Ery4_5YJ~_P@xVGs{zshU{j$= z05yHG!QGBZ*rVhIsIpA!gZ6)JR3NaYZFaC`OX^o{<$#hri*Ra1v68p`K2#{yD2P4* zv=Py>Rbm{FIT(k@u`-!W@n)$p3e#>3-o)~k{nqRG1WrR+2Kle$3Y_&_tXd`3Lt@uW7hN?#U4Iur z1NIr0iJ?LDu3)i}&u+3B$ngkj&pE8-F1qGlB)9gJMPqj(Gq6_d>a;UEJ9`&U*8!Jm zYo&SNCeWruD&g~23ze4ijn$UzpB)=ToDnhPUC(E=cVWoXx!^xlR<|4;(nmTx=iEt93$SQl zk~5Hvo`BVWT=yB8bLry%Du$=T0N92`JrNp>(j|Cc`CK;o$z-jAzGt)S`+-8W9D}t8 z@_w&yQnvN0&8bJssA9l3WM9ibgx3HX<@xwNcnDfN9vju-elVOkJOaSfZd<+gj+@<`3@n?u1^To z(gT5XGxK?DLOgu7YO|ERf&&42QjIw1BM~49NQaZ$wLt*%S|^dy&>kR_{3dN6;f*v` zgTP?gz~3YO%g|wqtKVm39Wubd1bb>>k7{m<-f6&(+)X*<)T{pCFJBx^89(G3&$AG4 zQHp>E(<SKMu${e2RE2c_8>RXq90&Na@_-XX;%J&so2uMv+ZkJ_^ zdoKGmKZW+2j&#;{^Uy22!_~cacn-(@=OlS zz=~>6&7zKKM$nHSz|BB3a}iSU_ov4m0S2B9kQeWRKA$j~Z}Iu0M$;Qu~h*^#%?8N z^_k}Gvm4^MM~yCbK%eWMo`AjnV!^ono%{a@1K(Wk2r8@&q)q|;_Gjk+!1PXF)8@qP zm`VgW;H}MknaNVu1?QarNm+bdeRDKVP3?ay)T#b^p+5FoU=(o9b<-$s0uJmLP)Y5k zpu>Io3XcuJ_o|Pvd`Wy&^>j2#naEROvaOntE&@?y6$2=55cq%iC=TFXFQYK`L5ndhgh;34PZ&4QfwpBU3}-7IEED|NtGuIjR$?lheyBgwQU7Od9tKoW6TScu zIsD5nG~pEgN-cjB9Xu`o5<+(VV;MXA$1=t?FMbNJ(x*EAxxuVvH|}{NLKqrRNPTEX z_2to6txm1IWsH&5q%u7HB6_;F6t~neSwt%vR179cu}ED;rKx+fJC*$r_RMd|(u;@K z@_AWQCc;Z}e{h!S4*QGQtr?}(b92rh2EFR?^S*!knTq;(H+6S;`?iDff?k5wwLX!R zG3NsDA)5B8yl?yKkd@5{dMzG3;PehH)#HMO4X$Y*J@n^@e2=RL_}#Z?5Dgp79c%75 z81D;dh7y&X(F7jf+K|4?TQzRqHFC=V9bIdv12ye_9-=%z)-5azm2W0pdp=e%zfUYAp5+*vjw@7f)2)S8WMmfa2akuGsZNoduW zXNW&87klWuTh$}FVBKl8U#)F$F*hdII5wu3yQ{?$u8)uzv1BZZQ>3%<^z+-b+`(f!c zNKo1{kj(2h=)o4t6=vtxWP>qHt>opOL6Iz1F{GF1@xj__STiL9AX^Enl8Jh=7x z3+ppjnEiWmMwP)`5BJV&^xXJB!f3~Ajqrnp@}2Wm(u*SxGOg_JwOTIP!q;&QK>X(T zK%v^fj(qHt%rNd>wB#H$>ivlI_WY$sL8!*d4>S=p8kqd=rLK$LTFu+j&|3KKU&9C| zXWn=A*iSyInfv%_Rg*cT@3sK==Ec4EU`j}=q7pqxuRQn4$-yyX&0(UJ_=cj(6`yz2 zJ@E!Uq2|6S>@sj}t7HTkMflN;oGsGu{G=EzGU&kc;lMAwUMpt%*OVDft5<>dJ}T)n zAu{S4f`wrg%$ZkD%rdzmQCXwckh0ZU|<1UD>o6>r#%7M zKQ&q?qL4gV;VsJy9(dz8Y$u_g!Ljs67GcL#`3@!wzE!6&aJ~Gakdd{8J z;q9uUM=k0z$I&VGb78^2F7+)T;-92WGOE@dPH&sgMuT`^!)rgznh@EXk%jAk0uF>* z8y`!{Qt&cbJe50v$!a|@CimoO^xWr-Tyxuy?$TguzI`oc)FUneb+K=R0n-*RBjcA@ zZyD=4WMW<~Ax_&?hGSU`Czu~rgQ>g}2cVZV_fZ?0m*}>-6ZE7~Jjd0+JF1H@PW@`p z`4c?bKqJ$}T$$x-0jI#mPSLwfWD}B>hg=n9=eA~xRujL|ZkCM}Xbq2YfSsUi4RDLC zkZl@dz;4V-{$DNIlYzfwQtO^iqG?3d_lmDc*XFK6bH~GMy2Aza>jGNi@k7EBCs^J> z6xM5d#1^b+=hY@|YXbB1;~aYij^`TV|7j2xlO)HwwZ?;k`@VfC5X>YNb9?)mK@GoEEBE^? z7%!;<2Y3rz&>tAIpj#dGz6Gv7;~OKT%zF;TBqmHry71BsQ$0GB>Z#n@5VNx|SaknA zb(Ak>n39sGszNWPp0lxEO>gW}gWzJ_3TO%36?u$j`xdoKu01{1QuF9$O46mQ!yvR7 zeY`qQYw5e_4yg|!?T1Xj$g|H7^LSDzRE)V}1hccxGzwfhtcTh9iO@Nx zI6JwMSJwr~F6R1sKG+soL0S()}cUTJinFgTnd={&VUPx($WJ3B!b~U=QH)%- z(?#UCXkXd+LNDay`#7|mguOMnw z9m9(InD8y76f{{A@f?K0C~x!)3=oUSa5#)svO)yR^NYjE-aTt<)_^bB84@qvNbf~7 z_1Nm~xp9I=k1*?LZAVijUJ|OyBS!@r1}opqR$}xl-wa*mt(`oC22l6a=UGywa=`qW z2RI0JklbrBjnII*4=klfMN4f-E8`K$5BOryrE0`0o=!!(=(yvT8-3|uwHME}8F-sy z8Q^KQ%dxAztG5>B?^%i%Qj)o2@-_x}+}1td{eW0Nbcu+Y7X$Wvr6`uAYcR=u&Z?Mh zU#a}y+yBNkEz#CBd*3mugwtq1NQ^-idV$ys0Kz(?T; zh3of)T0MoY^6kGEIBvap0Wqlylw3R~3q+F@X^Oz9@8;qa!)e|6zKxnq^%0cnT(z=V zU7dZGf!~+Cjt=%7&GhsgGDeX2fQrQWTs7czEw2ZPNy{VU{|*Fe6t{L>Tt+9xvGgr$ zV0xW3GWH*?b`tg?m655|?wNm*B(=Z?RhlnydcF}Mvwl!bw?f?m?pCm%LpMmOCEkOq zO3*a9=b+nr@{d|f+7S7k^4R5eO#>If(8ebePXS?Mf=cLlAl8@H zrHu#J>Il;TG+M6oDI*C`5XZ-UIr5kpX?wSsDgaU5y$A}FI8h%7@f~Q6p z7wxWMNT6{!)Jw|u%|R{aTV5EekZrN%{GC&Co3TvNe;16k%whanxlS7c5fJ;8>tqerY88E<>X zw;EcjTWCfl`l2%)XZApbL`GX8i&f=*o+n9`ogG$R7k{xn`<(C^c|5qkw?GWdoJ4t! zwM%3Lyh`<;b%DYAot2A3%5+}<-K4|L&YJ;sjTHP`aS4rNH@yrA4K*Zx-dzdR9rNQ@nTrgNUqJ5WRnv=<`Zb+56%ruxD=uB zdN2W#jek=#Vr|D_D0n`Of|NM*8tyan+e69d*`7kOWs|hj7Qr3koUx=Nu};}00T6ACna${P5yu$UxxQg zrdh`P4Lp1gRC~$Bw0Du(*1pljP%-(d2J-2A`QwB2Fr>~;2q81`$Rh@a>(_J-ZO-SM z99P|JW%_6KWF9Qcv7b~-3-EC)Q4(_L1!l{?jwj67FWlaPYh7EEweJ@@j{l-bB@;xV zuW0w`g`HQE;#nW-fmE}sIU8R(e})ka=~=q7Vi`q!F{U9d|Iq%F=f2lk12f#b@G?+q z=JY$0!~<|Xn~}c5JKX4*k2LWdBnkr5&O;XROisz#g_y+oDM>x+1$=9Ha`{UkCry^t zRJ@6eoRhNFNLCLL323Zh3|PoBCfcLMLO9a5$={6?1=|FYU-^b! zdZInZK>@8EnaJ~bTJ+DMm*@^YHBQz3`_SW&ugn}HKF=PKc*8Cm#Xdd|S_Otze6k&2 zroq&-^IbksM&x*`62g!7X0me$2$w*{7h`pc8j!oZcb^!r?mp(XYqVR2=5gQ45S;P} zeC#k4rL$8zvYIK2gNvL{*r*vIV|)+j#ZQDlhj;?9#5opJ8%DP$}! zfIc62eQlQM zz#u^RAh72%xyI9`)y=yy@{%lG&R5q~=%6=() z#}G0P-dj!Ji$duR@I{viJJ%PFs;{iKp!%j;UuF6QrquE-&AXOt6uNt;&@mG!J)m;; zg$R6Nui9J?K3dX4FZS#pcZ{i-WBvU7Y_J+`VQyeh}(-2@#lP! zP9>}7WoayW3DxxLV|yw&tYqL4BUxPc^>q?)0I+rdmVQydV_V zs~dQrk?ns)?YRq5SD^0vWEZf1Uq@76pO8a4BB zh)~6MCX_V9@qnV`hxFS0&$pS~)JVe<_N==qw87s#4F5gd!DAyNQ>gheN2Ad8)a{|3 z$iZX2i7iEqwXQRWI?boj{JH^dQ*)2P!Q)GdJA7uG31Y`v7SN;-^=#Fx>;B}^z?|^p z#i`(P9-A5C_}JtzW1uGL{s(dFnoyiVFK}9qU<><+oV2o3nS@F3e*=9q%|QSF literal 9410 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 element (Authentication Context Class Reference or ACR) to map the Level of Authentication (LoA). This element is a URI reference that identifies an authentication context declaration. The LoA is requested by the client in the SAML request via the `` element. + +[source,xml,subs=+attributes] +---- + + https://sp.example.com/ + + + urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + +---- + +The request asks for a set of authentication contexts that will be evaluated in order by {project_name} until one of them can be satisfied. This means that {project_name} knows that URI declaration and there is a level defined in the mapping that is sufficient for the request. If none of the specified contexts can be satisfied, then {project_name} returns a `` message with a second-level `` of `urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext`. + +[source,xml,subs=+attributes] +---- + + https://localhost:8543/auth/realms/demo + + + + + + +---- + +If {project_name} can satisfy the request, the step-up authentication flow is called to authenticate the user with the mapped level. The SAML response, using the `` element inside the successful response, will contain again the `` with the context finally applied. + +[source,xml,subs=+attributes] +---- + + https://localhost:8543/auth/realms/demo + + + + + https://localhost:8543/auth/realms/demo + + G-b1e11e8a-c002-47bc-8060-64f6f11fe267 + + + + + + + https://sp.example.com/ + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken + + + + +---- + +For more information about the processing rules defined in the SAML standard for the `` element, see Section 3.3.2.2.1 Element `` of the *Assertions and Protocols for the OASIS Security Assertion Markup Language (SAML) V2.0* (`saml-core-2.0-os.pdf` document). The complete SAML v2.0 OASIS Standard set (PDF format) and schema files are available in the link:https://www.oasis-open.org/standard/saml/[Security Assertion Markup Language (SAML) v2.0] page. Note that the spec also defines a comparison (exact, minimum, better and maximum), that complicates what Level of Authentication will be finally selected in {project_name}. + +When the `step-up-authentication-saml` feature is enabled, the <<_mapping-acr-to-loa-realm,ACR to Level of Authentication (LoA) Mapping>> is a table with three values: the OpenID Connect ACR, the SAML URI context and the Level of Authentication (LoA). The mapping can be defined for both client types. It can also be overridden at client level, but, in this case, only the URI and the LoA are present. The minimum ACR value which is allowed for the client can also be defined in the configuration. The step-up authentication options for SAML clients are placed in the **Advanced** tab, section **Advanced Settings**. See chapter <<_client-saml-configuration,Creating a SAML client>> for details. + +In summary, when the step-up authentication is configured for SAML, {project_name} will process the specified context level in the SAML request and the minimum ACR allowed for the client (if they are present) to obtain the LoA (integer level) that should be reached in the authentication. If there is no available level that satisfies the request, the error is returned per specification. If the URI/LoA mapping returns a level that satisfies the request, the authentication flow is started, enforcing that Level of Authentication to be reached. + +In order to maintain backwards compatibility, {project_name} does not return an error and continues adding the previous ACR `urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified` to the response in the following situations: + +* The new feature `step-up-authentication-saml` is not enabled in {project_name}. +* The SAML client does not define any mapping between context URI and LoA. Use the client mapping instead of the general realm mapping when you just need to apply the step-up for some specific SAML clients. +* The `AuthnContextClassRef mapper` is not executed. This mapper is provided by a new default client scope `AuthnContextClassRef` which is now added to SAML clients by default. It is in charge of adding the resulting `` to the response. ++ +NOTE: For new realms created with the `step-up-authentication-saml` feature enabled, the mapper and the client scope `AuthnContextClassRef` are automatically created and assigned to SAML clients. But, for exiting realms, if you want to use this preview feature, the client scope and the mapper should be created and assigned to the client manually. When the feature is promoted to supported, the migration will also create the client scope for existing realms if the feature is not disabled at startup. ++ +* The LoA calculated at request time is not achieved by the authentication flow. For example, if the authentication flow used for authentication is not a step-up flow, or there is a misconfiguration between the URI/LoA mapping and the final level reached in the step-up authentication flow. + [[_registration-rc-client-flows]] ==== Registration or Reset credentials requested by client diff --git a/docs/documentation/server_admin/topics/clients/saml/proc-creating-saml-client.adoc b/docs/documentation/server_admin/topics/clients/saml/proc-creating-saml-client.adoc index d308b73f65b..2617a37dd2b 100644 --- a/docs/documentation/server_admin/topics/clients/saml/proc-creating-saml-client.adoc +++ b/docs/documentation/server_admin/topics/clients/saml/proc-creating-saml-client.adoc @@ -180,3 +180,7 @@ This tab has many fields for specific situations. Some fields are covered in ot === Advanced settings *Assertion Lifespan*:: Specific client lifespan set in the SAML assertion conditions. After that time the assertion will be invalid. If not specified the realm *Access Token Lifespan* is used. The `SessionNotOnOrAfter` attribute is not modified and continue using the *SSO Session Max* time defined at realm level. + +*ACR to LoA Mapping*:: Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR for SAML is an URI, whereas the LoA must be numeric. This mapping overrides the <<_mapping-acr-to-loa-realm,ACR to Level of Authentication (LoA) Mapping>> defined at realm level. Only present if <<_step-up-authentication-saml,Step-up authentication for SAML>> feature is enabled. + +*Minimum ACR Value*:: Minimum ACR to be enforced by Keycloak. If the resulting authentication context for the request is as strong as this ACR the request is valid, otherwise Keycloak returns the `NoAuthnContext` status error. Only present if <<_step-up-authentication-saml,Step-up authentication for SAML>> feature is enabled. diff --git a/docs/documentation/server_admin/topics/login-settings/acr-to-loa-mapping.adoc b/docs/documentation/server_admin/topics/login-settings/acr-to-loa-mapping.adoc index c25056671cc..f0c3f09d4c1 100644 --- a/docs/documentation/server_admin/topics/login-settings/acr-to-loa-mapping.adoc +++ b/docs/documentation/server_admin/topics/login-settings/acr-to-loa-mapping.adoc @@ -6,6 +6,12 @@ The acr claim can be requested in the `claims` or `acr_values` parameter sent in Mapping can be also specified at the client level in case that particular client needs to use different values than realm. However, a best practice is to stick to realm mappings. +.ACR to LoA mapping image:images/realm-oidc-map-acr-to-loa.png[alt="ACR to LoA mapping"] For further details see <<_step-up-flow,Step-up Authentication>> and https://openid.net/specs/openid-connect-core-1_0.html#acrSemantics[the official OIDC specification]. + +If the feature for <<_step-up-authentication-saml,Step-up authentication for SAML>> is enabled, the ACR to LoA mapping is a table with three values. The new URI column is the URI that will map the SAML authentication context class reference to the numeric LoA. This new column is necessary if you want to use step-up authentication with the SAML protocol. + +.ACR/URI to LoA mapping +image:images/realm-oidc-map-acr-uri-to-loa.png[alt="ACR/URI to LoA mapping"] diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index f5b8c5e48e7..d250266d51a 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -1445,6 +1445,7 @@ deleteConfirmGroup_other=Are you sure you want to delete these groups? scopePermissions.users.manage-description=Policies that decide if an administrator can manage all users in the realm defaultACRValuesHelp=Default values to be used as voluntary ACR in case that there is no explicit ACR requested by 'claims' or 'acr_values' parameter in the OIDC request. minimumACRValueHelp=Minimum ACR to be enforced by Keycloak. Overrides lower ACRs explicitly requested by 'acr_values' or 'claims', unless they are marked as essential. +minimumACRValueSamlHelp=Minimum ACR to be enforced by Keycloak. If the resulting authentication context for the request is as strong as this ACR the request is valid, otherwise Keycloak returns the NoAuthnContext status error. membershipAttributeType=Membership attribute type eventTypes.PUSHED_AUTHORIZATION_REQUEST.name=Pushed authorization request included.client.audience.tooltip=The Client ID of the specified audience client will be included in the audience (aud) field of the token. If the token includes audiences, the specified value is added to them. It will not override existing audiences. @@ -1463,6 +1464,15 @@ prompts.select_account=Select account defaultACRValues=Default ACR Values minimumACRValue=Minimum ACR Value valueError=A value must be provided. +loa=LoA +uri=URI +acr=ACR +loaError=Invalid LoA +uriError=Invalid URI +acrError=Invalid ACR +loaPlaceholder=Type a LoA +uriPlaceholder=Type an URI +acrPlaceholder=Type an ACR noConsents=No consents orderChangeSuccessUserFed=Successfully changed the priority order of user federation providers noUsersEmptyStateDescriptionContinued=to find them. Users that already have this role as an effective role cannot be added here. @@ -2792,6 +2802,8 @@ key=Key email=Email groupDeleted_other=Groups deleted acrToLoAMappingHelp=Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR can be any value, whereas the LoA must be numeric. +acrToLoAMappingSamlHelp=Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR for SAML is an URI, whereas the LoA must be numeric. +acrToLoAMappingRealmSamlHelp=Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR can be any value and is used in OpenID Connect, the URI is the authentication context for SAML and it is an URI, finally the LoA must be numeric. uploadFile=Upload JSON file loginActionTimeoutHelp=Max time a user has to complete login related actions like update password or configure totp. This is recommended to be relatively long, such as 5 minutes or more. identityProviders=Identity providers diff --git a/js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx b/js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx index ce68caa540f..7360c1529d0 100644 --- a/js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx +++ b/js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx @@ -1,5 +1,14 @@ -import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared"; -import { ActionGroup, Button, FormGroup } from "@patternfly/react-core"; +import { + HelpItem, + TextControl, + SelectControl, +} from "@keycloak/keycloak-ui-shared"; +import { + ActionGroup, + Button, + FormGroup, + TextInput, +} from "@patternfly/react-core"; import { Controller, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { DefaultSwitchControl } from "../../components/SwitchControl"; @@ -11,6 +20,7 @@ import { useRealm } from "../../context/realm-context/RealmContext"; import { convertAttributeNameToForm } from "../../util"; import { FormFields } from "../ClientDetails"; import { TokenLifespan } from "./TokenLifespan"; +import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled"; type AdvancedSettingsProps = { save: () => void; @@ -29,39 +39,110 @@ export const AdvancedSettings = ({ const { realmRepresentation: realm } = useRealm(); - const { control } = useFormContext(); + const { control, watch, register } = useFormContext(); + + const acrUriMapRealm = realm?.attributes?.["acr.uri.map"] + ? Object.values(JSON.parse(realm.attributes["acr.uri.map"])) + : []; + + const acrLoAMapClient = watch( + convertAttributeNameToForm("attributes.acr.loa.map"), + [], + ); + + const validAcrLoAOptions = () => + acrLoAMapClient.length > 0 + ? acrLoAMapClient.map((i: any) => i?.key).filter((i: any) => i !== "") + : acrUriMapRealm; + + const acrLoAMapNamesOptions = () => [ + { key: "", value: t("choose") }, + ...validAcrLoAOptions().map((i: any) => ({ key: i, value: i })), + ]; + + const isFeatureEnabled = useIsFeatureEnabled(); + return ( - {protocol !== "openid-connect" && ( - - } - > - ( - "attributes.saml.assertion.lifespan", - )} - defaultValue="" - control={control} - render={({ field }) => ( - + - )} - /> - + } + > + ( + "attributes.saml.assertion.lifespan", + )} + defaultValue="" + control={control} + render={({ field }) => ( + + )} + /> + + {isFeatureEnabled(Feature.StepUpAuthenticationSaml) && ( + <> + + } + > + ( + Number.isInteger(parseInt(v)), + })} + /> + )} + /> + + + v === "" || validAcrLoAOptions().includes(v), + }, + }} + options={acrLoAMapNamesOptions()} + /> + + )} + )} {protocol === "openid-connect" && ( <> @@ -158,6 +239,19 @@ export const AdvancedSettings = ({ ( + Number.isInteger(parseInt(v)), + })} + /> + )} /> ; ValueComponent?: FunctionComponent; }; @@ -47,6 +50,8 @@ export const KeyValueInput = ({ name, label = "attributes", isDisabled = false, + keyLabel = "key", + valueLabel = "value", KeyComponent, ValueComponent, }: KeyValueInputProps) => { @@ -70,29 +75,36 @@ export const KeyValueInput = ({ defaultValue: [], }); + const getError = () => { + return name.split(".").reduce((record: any, key) => record?.[key], errors); + }; + return fields.length > 0 ? ( <> - {t("key")} + {t(keyLabel)} - {t("value")} + {t(valueLabel)} {fields.map((attribute, index) => { - const error = (errors as any)[name]?.[index]; + const error = getError()?.[index]; const keyError = !!error?.key; const valueErrorPresent = !!error?.value || !!error?.message; - const valueError = error?.message || t("valueError"); + const valueError = error?.message || t(`${valueLabel}Error`); return ( {KeyComponent ? ( - + ) : ( - {t("keyError")} + {t(`${keyLabel}Error`)} )} @@ -113,11 +125,12 @@ export const KeyValueInput = ({ ) : ( { + const { t } = useTranslation(); + const { + control, + register, + formState: { errors }, + } = useFormContext(); + + const spanAcr = uri ? 4 : 5; + const spanLoA = uri ? 2 : 5; + + const { fields, append, remove } = useFieldArray({ + control, + name, + }); + + const appendNew = () => append({ acr: "", uri: "", loa: "" }); + + const getError = () => { + return name.split(".").reduce((record: any, key) => record?.[key], errors); + }; + + return fields.length > 0 ? ( + <> + + + {t("acr")} + + {uri && ( + + {t("uri")} + + )} + + {t("loa")} + + {fields.map((attribute, index) => { + const error = getError()?.[index]; + return ( + + + + {error?.acr && ( + + + {t("acrError")} + + + )} + + {uri && ( + + + {error?.uri && ( + + + {t("uriError")} + + + )} + + )} + + Number.isInteger(parseInt(v)), + })} + validated={error?.loa ? "error" : "default"} + isRequired + /> + {error?.loa && ( + + + {t("loaError")} + + + )} + + + + + + ); + })} + + + + + + + + ) : ( + + {t("missingAttributes", { label })} + + + + + ); +}; diff --git a/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx b/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx index e9194da7eb6..a76aa731bc1 100644 --- a/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx @@ -27,7 +27,7 @@ import { DefaultSwitchControl } from "../components/SwitchControl"; import { FormattedLink } from "../components/external-link/FormattedLink"; import { FixedButtonsGroup } from "../components/form/FixedButtonGroup"; import { FormAccess } from "../components/form/FormAccess"; -import { KeyValueInput } from "../components/key-value-form/KeyValueInput"; +import { RealmLoAMapping } from "../components/realm-loa-mapping/RealmLoAMapping"; import { useRealm } from "../context/realm-context/RealmContext"; import { addTrailingSlash, @@ -37,6 +37,7 @@ import { import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; import { UIRealmRepresentation } from "./RealmSettingsTabs"; import { SIGNATURE_ALGORITHMS } from "../clients/add/SamlSignature"; +import type { RealmLoAMappingType } from "../components/realm-loa-mapping/RealmLoAMapping"; type RealmSettingsGeneralTabProps = { realm: UIRealmRepresentation; @@ -115,6 +116,9 @@ function RealmSettingsGeneralTabForm({ Feature.AdminFineGrainedAuthzV2, ); const isOpenid4vciEnabled = isFeatureEnabled(Feature.OpenId4VCI); + const isStepUpAuthenticationSaml = isFeatureEnabled( + Feature.StepUpAuthenticationSaml, + ); const setupForm = () => { convertToFormValues(realm, setValue); @@ -124,13 +128,18 @@ function RealmSettingsGeneralTabForm({ UNMANAGED_ATTRIBUTE_POLICIES[0], ); if (realm.attributes?.["acr.loa.map"]) { - const result = Object.entries( + const acrLoaMap = Object.entries( JSON.parse(realm.attributes["acr.loa.map"]), - ).flatMap(([key, value]) => ({ key, value })); - result.concat({ key: "", value: "" }); + ).flatMap(([acr, loa]) => ({ acr, loa }) as RealmLoAMappingType); + + if (isStepUpAuthenticationSaml && realm.attributes?.["acr.uri.map"]) { + const acrUriMap = JSON.parse(realm.attributes["acr.uri.map"]); + acrLoaMap.forEach((row) => (row.uri = acrUriMap?.[row?.acr])); + } + setValue( convertAttributeNameToForm("attributes.acr.loa.map") as any, - result, + acrLoaMap, ); } }; @@ -209,14 +218,19 @@ function RealmSettingsGeneralTabForm({ fieldId="acrToLoAMapping" labelIcon={ } > - { r.attributes?.["acr.loa.map"] && typeof r.attributes["acr.loa.map"] !== "string" ) { + if (isFeatureEnabled(Feature.StepUpAuthenticationSaml)) { + r.attributes["acr.uri.map"] = JSON.stringify( + Object.fromEntries( + (r.attributes["acr.loa.map"] as RealmLoAMappingType[]) + .filter(({ acr, uri }) => acr !== "" && uri && uri !== "") + .map(({ acr, uri }) => [acr, uri]), + ), + ); + } r.attributes["acr.loa.map"] = JSON.stringify( Object.fromEntries( - (r.attributes["acr.loa.map"] as KeyValueType[]) - .filter(({ key }) => key !== "") - .map(({ key, value }) => [key, value]), + (r.attributes["acr.loa.map"] as RealmLoAMappingType[]) + .filter(({ acr }) => acr !== "") + .map(({ acr, loa }) => [acr, loa]), ), ); } diff --git a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts index de6e614b882..c9708194151 100644 --- a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts +++ b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts @@ -20,6 +20,7 @@ export enum Feature { Passkeys = "PASSKEYS", ClientAuthFederated = "CLIENT_AUTH_FEDERATED", Workflows = "WORKFLOWS", + StepUpAuthenticationSaml = "STEP_UP_AUTHENTICATION_SAML", } export default function useIsFeatureEnabled() { diff --git a/saml-core/src/main/java/org/keycloak/saml/SAML2ErrorResponseBuilder.java b/saml-core/src/main/java/org/keycloak/saml/SAML2ErrorResponseBuilder.java index d6033a1be10..c138b8287d7 100755 --- a/saml-core/src/main/java/org/keycloak/saml/SAML2ErrorResponseBuilder.java +++ b/saml-core/src/main/java/org/keycloak/saml/SAML2ErrorResponseBuilder.java @@ -45,6 +45,7 @@ public class SAML2ErrorResponseBuilder implements SamlProtocolExtensionsAwareBui protected String statusMessage; protected String destination; protected NameIDType issuer; + protected String inResponseTo; protected final List extensions = new LinkedList<>(); public SAML2ErrorResponseBuilder status(String status) { @@ -71,6 +72,11 @@ public class SAML2ErrorResponseBuilder implements SamlProtocolExtensionsAwareBui return issuer(SAML2NameIDBuilder.value(issuer).build()); } + public SAML2ErrorResponseBuilder inResponseTo(String inResponseTo) { + this.inResponseTo = inResponseTo; + return this; + } + @Override public SAML2ErrorResponseBuilder addExtension(NodeGenerator extension) { this.extensions.add(extension); @@ -81,6 +87,7 @@ public class SAML2ErrorResponseBuilder implements SamlProtocolExtensionsAwareBui try { StatusResponseType statusResponse = new ResponseType(IDGenerator.create("ID_"), XMLTimeUtil.getIssueInstant()); + statusResponse.setInResponseTo(inResponseTo); StatusType statusType = JBossSAMLAuthnResponseFactory.createStatusTypeForResponder(status); statusType.setStatusMessage(statusMessage); diff --git a/server-spi-private/src/main/java/org/keycloak/migration/MigrationProvider.java b/server-spi-private/src/main/java/org/keycloak/migration/MigrationProvider.java index 27226c2abd6..bc0f4013d61 100755 --- a/server-spi-private/src/main/java/org/keycloak/migration/MigrationProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/migration/MigrationProvider.java @@ -93,4 +93,11 @@ public interface MigrationProvider extends Provider { * @return created or already existing client scope 'service_account' */ ClientScopeModel addOIDCServiceAccountClientScope(RealmModel realm); + + /** + * Add the SAML mapper for the step-up AuthnContextClassRef authentication to the realm. + * @param realm + * @return created, already existing client scope or null if not step-up not enabled + */ + ClientScopeModel addSamlAuthnContextClassRefClientScope(RealmModel realm); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/Constants.java b/server-spi-private/src/main/java/org/keycloak/models/Constants.java index ce809b6cd37..b46771f8b8e 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/Constants.java +++ b/server-spi-private/src/main/java/org/keycloak/models/Constants.java @@ -176,6 +176,7 @@ public final class Constants { public static final String REQUESTED_LEVEL_OF_AUTHENTICATION = "requested-level-of-authentication"; public static final String FORCE_LEVEL_OF_AUTHENTICATION = "force-level-of-authentication"; public static final String ACR_LOA_MAP = "acr.loa.map"; + public static final String ACR_URI_MAP = "acr.uri.map"; public static final String DEFAULT_ACR_VALUES = "default.acr.values"; public static final String MINIMUM_ACR_VALUE = "minimum.acr.value"; public static final int MINIMUM_LOA = 0; diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocol.java b/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocol.java index b58ee97a812..f5d687fe276 100755 --- a/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocol.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocol.java @@ -69,7 +69,11 @@ public interface LoginProtocol extends Provider { * Passive authentication mode requested, user is logged in, but some other user interaction is necessary (eg. some required login actions exist or Consent approval is necessary for logged in * user) */ - PASSIVE_INTERACTION_REQUIRED; + PASSIVE_INTERACTION_REQUIRED, + /** + * Level of Authentication invalid or minimum not reached. + */ + LOA_INVALID; } LoginProtocol setSession(KeycloakSession session); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java index 2eb53f2a446..29ea2afc869 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java @@ -450,6 +450,7 @@ public class OIDCLoginProtocol implements LoginProtocol { return new OAuth2ErrorRepresentation(OAuthErrorException.ACCESS_DENIED, "User cancelled application-initiated action."); case CANCELLED_BY_USER: case CONSENT_DENIED: + case LOA_INVALID: return new OAuth2ErrorRepresentation(OAuthErrorException.ACCESS_DENIED, errorMessage); case PASSIVE_INTERACTION_REQUIRED: return new OAuth2ErrorRepresentation(OAuthErrorException.INTERACTION_REQUIRED, null); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java index 4830d97c6ea..d207fdc25fe 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java @@ -26,6 +26,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import org.keycloak.authentication.authenticators.util.LoAUtil; import org.keycloak.models.ClientModel; @@ -147,6 +148,20 @@ public class AcrUtils { } } + public static Map getUriLoaMap(ClientModel client) { + Map result = getAcrLoaMapForClientOnly(client); + if (!result.isEmpty()) { + // client has always the correct maps uri or acr + return result; + } + + // Fallback to realm but using the two maps acr => uri => loa + Map acrLoaMap = getAcrLoaMap(client.getRealm()); + Map acrUriMap = getAcrUriMap(client.getRealm()); + return acrLoaMap.entrySet().stream() + .filter(e -> acrUriMap.containsKey(e.getKey())) + .collect(Collectors.toMap(e -> acrUriMap.get(e.getKey()), Map.Entry::getValue)); + } private static Map getAcrLoaMapForClientOnly(ClientModel client) { String map = client.getAttribute(Constants.ACR_LOA_MAP); @@ -154,13 +169,21 @@ public class AcrUtils { return Collections.emptyMap(); } try { - return JsonSerialization.readValue(map, new TypeReference>() {}); + return parseAcrLoaMap(map); } catch (IOException e) { LOGGER.warnf("Invalid client configuration (ACR-LOA map) for client '%s'. Error details: %s", client.getClientId(), e.getMessage()); return Collections.emptyMap(); } } + public static Map parseAcrLoaMap(String map) throws IOException { + return JsonSerialization.readValue(map, new TypeReference>() {}); + } + + public static Map parseAcrUriMap(String map) throws IOException { + return JsonSerialization.readValue(map, new TypeReference>() {}); + } + /** * @param realm * @return map corresponding to "acr-to-loa" realm attribute. @@ -171,13 +194,31 @@ public class AcrUtils { return Collections.emptyMap(); } try { - return JsonSerialization.readValue(map, new TypeReference>() {}); + return parseAcrLoaMap(map); } catch (IOException e) { LOGGER.warnf("Invalid realm configuration (ACR-LOA map). Details: %s", e.getMessage()); return Collections.emptyMap(); } } + /** + * Return the acr to uri map in the realm. + * @param realm + * @return Map corresponding to acr to uri map + */ + public static Map getAcrUriMap(RealmModel realm) { + String map = realm.getAttribute(Constants.ACR_URI_MAP); + if (map == null || map.isEmpty()) { + return Collections.emptyMap(); + } + try { + return parseAcrUriMap(map); + } catch (IOException e) { + LOGGER.warnf("Invalid realm configuration (ACR-URI map). Details: %s", e.getMessage()); + return Collections.emptyMap(); + } + } + public static String mapLoaToAcr(int loa, Map acrLoaMap, Collection acrValues) { String acr = null; if (!acrLoaMap.isEmpty() && !acrValues.isEmpty()) { diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java index a87a0cb54b5..cadfe34adcb 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java @@ -160,6 +160,7 @@ public class SamlProtocol implements LoginProtocol { public static final String SAML_LOGOUT_INITIATOR_CLIENT_ID = "SAML_LOGOUT_INITIATOR_CLIENT_ID"; public static final String USER_SESSION_ID = "userSessionId"; public static final String CLIENT_SESSION_ID = "clientSessionId"; + public static final String SAML_AUTHN_CONTEXT_CLASS_REF = "saml_authn_context_class_ref"; protected static final Logger logger = Logger.getLogger(SamlProtocol.class); @@ -241,7 +242,8 @@ public class SamlProtocol implements LoginProtocol { } else { return samlErrorMessage( authSession, new SamlClient(client), isPostBinding(authSession), - authSession.getRedirectUri(), translateErrorToSAMLStatus(error), authSession.getClientNote(GeneralConstants.RELAY_STATE) + authSession.getRedirectUri(), authSession.getClientNote(SAML_REQUEST_ID), + translateErrorToSAMLStatus(error), authSession.getClientNote(GeneralConstants.RELAY_STATE) ); } } finally { @@ -254,7 +256,7 @@ public class SamlProtocol implements LoginProtocol { public ClientData getClientData(AuthenticationSessionModel authSession) { String responseMode = isPostBinding(authSession) ? SamlProtocol.SAML_POST_BINDING : SamlProtocol.SAML_REDIRECT_BINDING; return new ClientData(authSession.getRedirectUri(), - null, + authSession.getClientNote(SAML_REQUEST_ID), responseMode, authSession.getClientNote(GeneralConstants.RELAY_STATE)); } @@ -274,15 +276,16 @@ public class SamlProtocol implements LoginProtocol { return samlErrorMessage( null, samlClient, postBinding, - validRedirectUri, translateErrorToSAMLStatus(error), clientData.getState() + validRedirectUri, clientData.getResponseType(), translateErrorToSAMLStatus(error), clientData.getState() ); } private Response samlErrorMessage( AuthenticationSessionModel authSession, SamlClient samlClient, boolean isPostBinding, - String destination, SAMLError samlError, String relayState) { + String destination, String inResponseTo, SAMLError samlError, String relayState) { JaxrsSAML2BindingBuilder binding = new JaxrsSAML2BindingBuilder(session).relayState(relayState); SAML2ErrorResponseBuilder builder = new SAML2ErrorResponseBuilder().destination(destination).issuer(getResponseIssuer(realm)) + .inResponseTo(inResponseTo) .status(samlError.error().get()) .statusMessage(samlError.errorDescription()); KeyManager keyManager = session.keys(); @@ -326,6 +329,8 @@ public class SamlProtocol implements LoginProtocol { return new SAMLError(JBossSAMLURIConstants.STATUS_NO_PASSIVE, null); case ALREADY_LOGGED_IN: return new SAMLError(JBossSAMLURIConstants.STATUS_AUTHNFAILED, Constants.AUTHENTICATION_EXPIRED_MESSAGE); + case LOA_INVALID: + return new SAMLError(JBossSAMLURIConstants.STATUS_NOAUTHN_CTX, null); default: logger.warn("Untranslated protocol Error: " + error.name() + " so we return default SAML error"); return new SAMLError(JBossSAMLURIConstants.STATUS_REQUEST_DENIED, null); @@ -542,7 +547,7 @@ public class SamlProtocol implements LoginProtocol { String nameId = getSAMLNameId(samlNameIdMappers, nameIdFormat, session, userSession, clientSession); if (nameId == null) { - return samlErrorMessage(null, samlClient, isPostBinding(authSession), redirectUri, + return samlErrorMessage(authSession, samlClient, isPostBinding(authSession), redirectUri, authSession.getClientNote(SAML_REQUEST_ID), new SAMLError(JBossSAMLURIConstants.STATUS_INVALID_NAMEIDPOLICY, null), relayState); } diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolFactory.java index 1028b54253f..911dbf2dddf 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolFactory.java @@ -38,6 +38,7 @@ import org.keycloak.protocol.AbstractLoginProtocolFactory; import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; import org.keycloak.protocol.saml.mappers.AttributeStatementHelper; +import org.keycloak.protocol.saml.mappers.AuthnContextClassRefMapper; import org.keycloak.protocol.saml.mappers.RoleListMapper; import org.keycloak.protocol.saml.mappers.UserPropertyAttributeStatementMapper; import org.keycloak.provider.ProviderConfigProperty; @@ -57,6 +58,7 @@ import org.keycloak.saml.validators.DestinationValidator; public class SamlProtocolFactory extends AbstractLoginProtocolFactory { public static final String SCOPE_ROLE_LIST = "role_list"; + public static final String SCOPE_AUTHN_CONTEXT_CLASS_REF = "AuthnContextClassRef"; private static final String ROLE_LIST_CONSENT_TEXT = "${samlRoleListScopeConsentText}"; private DestinationValidator destinationValidator; @@ -101,6 +103,11 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory { model = RoleListMapper.create("role list", "Role", AttributeStatementHelper.BASIC, null, false); builtins.put("role list", model); defaultBuiltins.add(model); + if (Profile.isFeatureEnabled(Profile.Feature.STEP_UP_AUTHENTICATION_SAML)) { + model = AuthnContextClassRefMapper.create(SCOPE_AUTHN_CONTEXT_CLASS_REF); + builtins.put(SCOPE_AUTHN_CONTEXT_CLASS_REF, model); + defaultBuiltins.add(model); + } if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { model = OrganizationMembershipMapper.create(); builtins.put("organization", model); @@ -132,6 +139,7 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory { roleListScope.setProtocol(getId()); roleListScope.addProtocolMapper(builtins.get("role list")); newRealm.addDefaultClientScope(roleListScope, true); + if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { ClientScopeModel organizationScope = newRealm.addClientScope("saml_organization"); organizationScope.setDescription("Organization Membership"); @@ -140,6 +148,8 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory { organizationScope.addProtocolMapper(builtins.get("organization")); newRealm.addDefaultClientScope(organizationScope, true); } + + addSamlAuthnContextClassRefClientScope(newRealm); } @Override @@ -227,4 +237,19 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory { .add() .build(); } + + public ClientScopeModel addSamlAuthnContextClassRefClientScope(RealmModel newRealm) { + if (Profile.isFeatureEnabled(Profile.Feature.STEP_UP_AUTHENTICATION_SAML)) { + ClientScopeModel authnContextClassRefScope = KeycloakModelUtils.getClientScopeByName(newRealm, SCOPE_AUTHN_CONTEXT_CLASS_REF); + if (authnContextClassRefScope == null) { + authnContextClassRefScope = newRealm.addClientScope(SCOPE_AUTHN_CONTEXT_CLASS_REF); + authnContextClassRefScope.setDescription("AuthnContextClassRef Level of Authentiation"); + authnContextClassRefScope.setProtocol(getId()); + authnContextClassRefScope.addProtocolMapper(builtins.get(SCOPE_AUTHN_CONTEXT_CLASS_REF)); + newRealm.addDefaultClientScope(authnContextClassRefScope, true); + } + return authnContextClassRefScope; + } + return null; + } } diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolUtils.java b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolUtils.java index 72c556c46ea..e33883167ce 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolUtils.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocolUtils.java @@ -25,6 +25,8 @@ import java.security.Key; import java.security.PublicKey; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.Map; +import java.util.Objects; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.UriInfo; @@ -36,8 +38,10 @@ import org.keycloak.crypto.KeyUse; import org.keycloak.dom.saml.v2.SAML2Object; import org.keycloak.dom.saml.v2.assertion.NameIDType; import org.keycloak.dom.saml.v2.protocol.ArtifactResponseType; +import org.keycloak.dom.saml.v2.protocol.AuthnContextComparisonType; import org.keycloak.dom.saml.v2.protocol.ExtensionsType; import org.keycloak.dom.saml.v2.protocol.RequestAbstractType; +import org.keycloak.dom.saml.v2.protocol.RequestedAuthnContextType; import org.keycloak.dom.saml.v2.protocol.StatusCodeType; import org.keycloak.dom.saml.v2.protocol.StatusResponseType; import org.keycloak.dom.saml.v2.protocol.StatusType; @@ -346,4 +350,73 @@ public class SamlProtocolUtils { writer.write(responseType); return DocumentUtil.getDocument(new ByteArrayInputStream(bos.toByteArray())); } + + private static String checkLoAExact(String current, Map acrLoaMap, int minLevel) { + // authentication context in the authentication statement MUST be the exact match of at least one of the authentication contexts specified + Integer level = acrLoaMap.get(current); + if (level == null) { + return null; + } + return level >= minLevel ? current : null; + } + + private static String checkLoAMinimum(String current, Map acrLoaMap, String minLoa, int minLevel) { + // authentication context in the authentication statement MUST be as strong as one of the authentication contexts specified + Integer level = acrLoaMap.get(current); + if (level == null) { + return null; + } + // check if current value is OK, if not return minLoa which is valid because is greater than current + return (level >= minLevel) ? current : minLoa; + } + + private static String checkLoAMaximum(String current, Map acrLoaMap, int minLevel) { + // authentication context in the authentication statement MUST be as strong as possible without exceeding the strength of at least one of the authentication contexts specified + Integer level = acrLoaMap.get(current); + if (level == null) { + return null; + } + // only valid if it is better than minLoa + return level >= minLevel ? current : null; + } + + private static String checkLoABetter(String current, Map acrLoaMap, String minLoa, int minLevel) { + // authentication context in the authentication statement MUST be stronger than any one of the authentication contexts specified + Integer level = acrLoaMap.get(current); + if (level == null) { + return null; + } + // if minLoa is valid return minLoa + if (minLevel > level) { + return minLoa; + } + // find any level that is better than level, get the min of them + return acrLoaMap.entrySet().stream() + .filter(e -> e.getValue() > level) + .min((Map.Entry e1, Map.Entry e2) -> e1.getValue().compareTo(e2.getValue())) + .map(Map.Entry::getKey) + .orElse(null); + } + + private static String checkLoa(AuthnContextComparisonType comparison, String current, Map acrLoaMap, String minLoa, int minLevel) { + if (comparison == null) { + comparison = AuthnContextComparisonType.EXACT; + } + return switch (comparison) { + case EXACT -> checkLoAExact(current, acrLoaMap, minLevel); + case MINIMUM -> checkLoAMinimum(current, acrLoaMap, minLoa, minLevel); + case MAXIMUM -> checkLoAMaximum(current, acrLoaMap, minLevel); + case BETTER -> checkLoABetter(current, acrLoaMap, minLoa, minLevel); + }; + } + + public static String getSelectedLoA(RequestedAuthnContextType requestedAuthnContext, Map acrLoaMap, String minLoa) { + Integer minLevel = minLoa != null ? acrLoaMap.get(minLoa) : null; + return requestedAuthnContext.getAuthnContextClassRef().stream() + .map(current -> checkLoa(requestedAuthnContext.getComparison(), current, acrLoaMap, + minLoa, minLevel != null ? minLevel : Integer.MIN_VALUE)) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } } diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlService.java b/services/src/main/java/org/keycloak/protocol/saml/SamlService.java index a34873fe4b8..1764c978b8d 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlService.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlService.java @@ -51,6 +51,7 @@ import jakarta.ws.rs.core.UriInfo; import org.keycloak.broker.saml.SAMLDataMarshaller; import org.keycloak.common.ClientConnection; +import org.keycloak.common.Profile; import org.keycloak.common.VerificationException; import org.keycloak.common.util.PemUtils; import org.keycloak.connections.httpclient.HttpClientProvider; @@ -79,6 +80,7 @@ import org.keycloak.http.HttpRequest; import org.keycloak.http.HttpResponse; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; import org.keycloak.models.KeyManager; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakUriInfo; @@ -89,6 +91,7 @@ import org.keycloak.protocol.AuthorizationEndpointBase; import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocolFactory; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.AcrUtils; import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessor; import org.keycloak.protocol.saml.profile.ecp.SamlEcpProfileService; @@ -176,6 +179,15 @@ public class SamlService extends AuthorizationEndpointBase { protected abstract Response error(KeycloakSession session, AuthenticationSessionModel authenticationSession, Response.Status status, String message, Object... parameters); + protected Response sendProtocolError(AuthenticationSessionModel authSession, LoginProtocol.Error error, String errorMessage) { + LoginProtocol protocol = session.getProvider(LoginProtocol.class, SamlProtocol.LOGIN_PROTOCOL); + protocol.setRealm(realm) + .setHttpHeaders(session.getContext().getRequestHeaders()) + .setUriInfo(session.getContext().getUri()) + .setEventBuilder(event); + return protocol.sendError(authSession, error, errorMessage); + } + protected Response basicChecks(String samlRequest, String samlResponse, String artifact) { logger.tracef("basicChecks(%s, %s, %s)%s", samlRequest, samlResponse, artifact, getShortStackTrace()); if (!checkSsl()) { @@ -524,6 +536,36 @@ public class SamlService extends AuthorizationEndpointBase { requestAbstractType = it.next().beforeProcessingLoginRequest(requestAbstractType, authSession); } + if (Profile.isFeatureEnabled(Profile.Feature.STEP_UP_AUTHENTICATION_SAML)) { + // step-up level of authentication + Map acrLoaMap = AcrUtils.getUriLoaMap(authSession.getClient()); + + if (!acrLoaMap.isEmpty()) { + // only process the requested authn context if LoA defined + String acrValue; + if (requestAbstractType.getRequestedAuthnContext() != null + && !requestAbstractType.getRequestedAuthnContext().getAuthnContextClassRef().isEmpty()) { + acrValue = SamlProtocolUtils.getSelectedLoA(requestAbstractType.getRequestedAuthnContext(), + acrLoaMap, AcrUtils.getMinimumAcrValue(client)); + if (acrValue == null) { + logger.debug("No AuthnContextClassRef is valid for the requested context."); + event.detail(Details.REASON, "Invalid RequestedAuthnContext"); + event.error(Errors.INVALID_REQUEST); + return sendProtocolError(authSession, LoginProtocol.Error.LOA_INVALID, null); + } + } else { + acrValue = AcrUtils.getMinimumAcrValue(client); + } + + if (acrValue != null) { + logger.tracef("SAML step-up authentication set to force using context '%s'", acrValue); + authSession.setClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION, "true"); + authSession.setClientNote(SamlProtocol.SAML_AUTHN_CONTEXT_CLASS_REF, acrValue); + authSession.setClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION, String.valueOf(acrLoaMap.get(acrValue))); + } + } + } + //If unset we fall back to default "false" final boolean isPassive = (null != requestAbstractType.isIsPassive() && requestAbstractType.isIsPassive().booleanValue()); return newBrowserAuthentication(authSession, isPassive, redirectToAuthentication); diff --git a/services/src/main/java/org/keycloak/protocol/saml/mappers/AuthnContextClassRefMapper.java b/services/src/main/java/org/keycloak/protocol/saml/mappers/AuthnContextClassRefMapper.java new file mode 100644 index 00000000000..425201c10a8 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/saml/mappers/AuthnContextClassRefMapper.java @@ -0,0 +1,161 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.saml.mappers; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.keycloak.Config; +import org.keycloak.authentication.authenticators.util.LoAUtil; +import org.keycloak.common.Profile; +import org.keycloak.dom.saml.v2.assertion.AssertionType; +import org.keycloak.dom.saml.v2.assertion.AuthnContextClassRefType; +import org.keycloak.dom.saml.v2.assertion.AuthnContextType; +import org.keycloak.dom.saml.v2.assertion.AuthnStatementType; +import org.keycloak.dom.saml.v2.protocol.ResponseType; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.utils.AcrUtils; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.sessions.AuthenticationSessionModel; + +import org.jboss.logging.Logger; + +/** + *

Mapper to assign the used AuthnContextClassRef in the AunthContext response.

+ * + * @author rmartinc + */ +public class AuthnContextClassRefMapper extends AbstractSAMLProtocolMapper implements SAMLLoginResponseMapper, EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "saml-authn-context-class-ref-mapper"; + public static final String AUTHN_CONTEXT_CLASS_REF_CATEGORY = "AuthnContextClassRef mapper"; + protected static final Logger logger = Logger.getLogger(AuthnContextClassRefMapper.class); + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return AUTHN_CONTEXT_CLASS_REF_CATEGORY; + } + + @Override + public String getDisplayCategory() { + return AUTHN_CONTEXT_CLASS_REF_CATEGORY; + } + + @Override + public String getHelpText() { + return "Add the AuthnContextClassRef to the AuthContext with the Level of Assurance if present."; + } + + @Override + public ResponseType transformLoginResponse(ResponseType response, ProtocolMapperModel mappingModel, + KeycloakSession session, UserSessionModel userSession, ClientSessionContext clientSessionCtx) { + int loa = LoAUtil.getCurrentLevelOfAuthentication(clientSessionCtx.getClientSession()); + AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); + String acrValue = authSession != null? authSession.getClientNote(SamlProtocol.SAML_AUTHN_CONTEXT_CLASS_REF) : null; + logger.tracef("Current level of authentication %d, requested level %s", loa, acrValue); + if (loa < Constants.MINIMUM_LOA) { + // if the authentication was not using a step-up flow, just return as before + return response; + } + + Map acrLoaMap = AcrUtils.getUriLoaMap(clientSessionCtx.getClientSession().getClient()); + if (acrValue == null) { + // no acr explicitly request in SAML, check if we have a specific name for this loa level + acrValue = acrLoaMap.entrySet().stream().filter(e -> loa == e.getValue()).map(Map.Entry::getKey).findAny().orElse(null); + } else { + // check the requested level was indeed achieved by the authentication flow, if not unspecified + Integer requestedLevel = acrLoaMap.get(acrValue); + if (requestedLevel == null || requestedLevel != loa) { + logger.warnf("Requested level '%s' (%d) was not reached after authentication flow, current level %d", + acrValue, requestedLevel, loa); + acrValue = null; + } + } + + URI authnContextClassRef = createUri(acrValue); + + if (authnContextClassRef == null) { + return response; + } + + Optional authStatementOptional = response.getAssertions().stream() + .map(ResponseType.RTChoiceType::getAssertion) + .map(AssertionType::getStatements) + .flatMap(s -> s.stream()) + .filter(AuthnStatementType.class::isInstance) + .map(AuthnStatementType.class::cast) + .findAny(); + + if (authStatementOptional.isPresent()) { + logger.tracef("Setting the authentication context to '%s'", acrValue); + AuthnStatementType authStatement = authStatementOptional.get(); + AuthnContextType authContext = new AuthnContextType(); + AuthnContextType.AuthnContextTypeSequence sequence = new AuthnContextType.AuthnContextTypeSequence(); + sequence.setClassRef(new AuthnContextClassRefType(authnContextClassRef)); + authContext.setSequence(sequence); + authStatement.setAuthnContext(authContext); + } + + return response; + } + + private URI createUri(String acrValue) { + if (acrValue == null) { + return null; + } + + try { + return new URI(acrValue); + } catch (URISyntaxException e) { + logger.warnf("Invalid URI syntax for AuthnContextClassRef in the Level of Authentication '%s'", acrValue); + return null; + } + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.STEP_UP_AUTHENTICATION_SAML); + } + + public static ProtocolMapperModel create(String name) { + ProtocolMapperModel mapper = new ProtocolMapperModel(); + mapper.setName(name); + mapper.setProtocolMapper(PROVIDER_ID); + mapper.setProtocol(SamlProtocol.LOGIN_PROTOCOL); + return mapper; + } +} diff --git a/services/src/main/java/org/keycloak/services/migration/DefaultMigrationProvider.java b/services/src/main/java/org/keycloak/services/migration/DefaultMigrationProvider.java index a115e0b7502..631a66c0824 100755 --- a/services/src/main/java/org/keycloak/services/migration/DefaultMigrationProvider.java +++ b/services/src/main/java/org/keycloak/services/migration/DefaultMigrationProvider.java @@ -32,6 +32,8 @@ import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocolFactory; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.protocol.saml.SamlProtocolFactory; import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.services.managers.RealmManager; @@ -86,6 +88,10 @@ public class DefaultMigrationProvider implements MigrationProvider { return (OIDCLoginProtocolFactory) session.getKeycloakSessionFactory().getProviderFactory(LoginProtocol.class, OIDCLoginProtocol.LOGIN_PROTOCOL); } + private SamlProtocolFactory getSamlProtocolFactory() { + return (SamlProtocolFactory) session.getKeycloakSessionFactory().getProviderFactory(LoginProtocol.class, SamlProtocol.LOGIN_PROTOCOL); + } + @Override public ClientScopeModel addOIDCRolesClientScope(RealmModel realm) { return getOIDCLoginProtocolFactory().addRolesClientScope(realm); @@ -117,6 +123,11 @@ public class DefaultMigrationProvider implements MigrationProvider { return getOIDCLoginProtocolFactory().addServiceAccountClientScope(realm); } + @Override + public ClientScopeModel addSamlAuthnContextClassRefClientScope(RealmModel realm) { + return getSamlProtocolFactory().addSamlAuthnContextClassRefClientScope(realm); + } + @Override public void close() { } diff --git a/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java b/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java index 514073abcca..3f58e5dae18 100644 --- a/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java +++ b/services/src/main/java/org/keycloak/validation/DefaultClientValidationProvider.java @@ -16,6 +16,7 @@ */ package org.keycloak.validation; +import java.io.IOException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; @@ -28,10 +29,12 @@ import java.util.Set; import org.keycloak.authentication.authenticators.util.LoAUtil; import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; import org.keycloak.models.RealmModel; import org.keycloak.protocol.ProtocolMapperConfigException; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.grants.ciba.CibaClientValidation; import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper; import org.keycloak.protocol.oidc.utils.AcrUtils; @@ -193,6 +196,7 @@ public class DefaultClientValidationProvider implements ClientValidationProvider validatePairwiseInClientModel(context); new CibaClientValidation(context).validate(); validateJwks(context); + validateAcrLoaMap(context); validateDefaultAcrValues(context); validateMinimumAcrValue(context); validateClientSessionTimeout(context); @@ -206,6 +210,7 @@ public class DefaultClientValidationProvider implements ClientValidationProvider validateUrls(context); validatePairwiseInOIDCClient(context); new CibaClientValidation(context).validate(); + validateAcrLoaMap(context); validateDefaultAcrValues(context); validateMinimumAcrValue(context); //context.getSession().getContext().getRealm(). @@ -394,11 +399,11 @@ public class DefaultClientValidationProvider implements ClientValidationProvider private void validateDefaultAcrValues(ValidationContext context) { ClientModel client = context.getObjectToValidate(); + if (!OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) { + return; + } List defaultAcrValues = AcrUtils.getDefaultAcrValues(client); Map acrToLoaMap = AcrUtils.getAcrLoaMap(client); - if (acrToLoaMap.isEmpty()) { - acrToLoaMap = AcrUtils.getAcrLoaMap(client.getRealm()); - } for (String configuredAcr : defaultAcrValues) { if (acrToLoaMap.containsKey(configuredAcr)) continue; if (LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm()) @@ -408,19 +413,48 @@ public class DefaultClientValidationProvider implements ClientValidationProvider } } + private void validateAcrLoaMap(ValidationContext context) { + ClientModel client = context.getObjectToValidate(); + if (!SamlProtocol.LOGIN_PROTOCOL.equals(client.getProtocol()) && !OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) { + return; + } + String value = client.getAttribute(Constants.ACR_LOA_MAP); + if (value != null && StringUtil.isNotBlank(value)) { + try { + Map map = AcrUtils.parseAcrLoaMap(value); + if (SamlProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) { + for (String uri : map.keySet()) { + new URI(uri); + } + } + } catch (IOException e) { + context.addError(Constants.ACR_LOA_MAP, "Invalid client configuration (ACR-LOA map) for client"); + } catch (URISyntaxException e) { + context.addError(Constants.ACR_LOA_MAP, "Invalid URI for ACR-LOA map: " + e.getInput()); + } + } + } + private void validateMinimumAcrValue(ValidationContext context) { ClientModel client = context.getObjectToValidate(); + if (!SamlProtocol.LOGIN_PROTOCOL.equals(client.getProtocol()) && !OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) { + return; + } String minimumAcrValue = AcrUtils.getMinimumAcrValue(client); if (minimumAcrValue != null) { - Map acrToLoaMap = AcrUtils.getAcrLoaMap(client); - if (acrToLoaMap.isEmpty()) { - acrToLoaMap = AcrUtils.getAcrLoaMap(client.getRealm()); - } + if (OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) { + Map acrToLoaMap = AcrUtils.getAcrLoaMap(client); - if(!acrToLoaMap.containsKey(minimumAcrValue)) { - if (LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm()) - .noneMatch(level -> minimumAcrValue.equals(String.valueOf(level)))) { - context.addError("minimumAcrValue", "Minimum ACR value needs to be value specified in the ACR-To-Loa mapping or number level from set realm browser flow"); + if (!acrToLoaMap.containsKey(minimumAcrValue)) { + if (LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm()) + .noneMatch(level -> minimumAcrValue.equals(String.valueOf(level)))) { + context.addError("minimumAcrValue", "Minimum ACR value needs to be value specified in the ACR-To-Loa mapping or number level from set realm browser flow"); + } + } + } else { + Map acrToLoaMap = AcrUtils.getUriLoaMap(client); + if (!acrToLoaMap.containsKey(minimumAcrValue)) { + context.addError("minimumAcrValue", "Minimum ACR value needs to be a URI specified in the ACR-To-Loa mapping"); } } } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper index c02a3e3c06d..de67c1b3f63 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper @@ -61,3 +61,4 @@ org.keycloak.protocol.oidc.mappers.SessionStateMapper org.keycloak.protocol.oidc.mappers.SubMapper org.keycloak.organization.protocol.mappers.saml.OrganizationMembershipMapper org.keycloak.organization.protocol.mappers.saml.OrganizationGroupMembershipMapper +org.keycloak.protocol.saml.mappers.AuthnContextClassRefMapper diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java index 6cae8f0471d..35a0c03b51d 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java @@ -38,6 +38,7 @@ import org.keycloak.testsuite.util.saml.HandleArtifactStepBuilder; import org.keycloak.testsuite.util.saml.IdPInitiatedLoginBuilder; import org.keycloak.testsuite.util.saml.LoginBuilder; import org.keycloak.testsuite.util.saml.ModifySamlResponseStepBuilder; +import org.keycloak.testsuite.util.saml.OtpLoginBuilder; import org.keycloak.testsuite.util.saml.RequiredConsentBuilder; import org.keycloak.testsuite.util.saml.UpdateProfileBuilder; @@ -206,6 +207,10 @@ public class SamlClientBuilder { return addStepBuilder(new LoginBuilder(this)); } + public OtpLoginBuilder otpLogin() { + return addStepBuilder(new OtpLoginBuilder(this)); + } + /** Handles update profile page after login */ public UpdateProfileBuilder updateProfile() { return addStepBuilder(new UpdateProfileBuilder(this)); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/CreateAuthnRequestStepBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/CreateAuthnRequestStepBuilder.java index 2bcd0a845ca..9cecaf3a3f1 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/CreateAuthnRequestStepBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/CreateAuthnRequestStepBuilder.java @@ -18,12 +18,16 @@ package org.keycloak.testsuite.util.saml; import java.net.URI; import java.util.Base64; +import java.util.LinkedList; +import java.util.List; import java.util.UUID; import java.util.function.Supplier; import jakarta.ws.rs.core.HttpHeaders; +import org.keycloak.dom.saml.v2.protocol.AuthnContextComparisonType; import org.keycloak.dom.saml.v2.protocol.AuthnRequestType; +import org.keycloak.dom.saml.v2.protocol.RequestedAuthnContextType; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.saml.common.exceptions.ConfigurationException; import org.keycloak.saml.common.exceptions.ParsingException; @@ -52,6 +56,8 @@ public class CreateAuthnRequestStepBuilder extends SamlDocumentStepBuilder authnContextClassRef = new LinkedList<>(); + private AuthnContextComparisonType comparison; private final Document forceLoginRequestDocument; @@ -93,6 +99,18 @@ public class CreateAuthnRequestStepBuilder extends SamlDocumentStepBuilder requestAuthContext.addAuthnContextClassRef(ref)); + loginReq.setRequestedAuthnContext(requestAuthContext); + } return SAML2Request.convert(loginReq); } catch (ConfigurationException | ParsingException | ProcessingException ex) { throw new RuntimeException(ex); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/OtpLoginBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/OtpLoginBuilder.java new file mode 100644 index 00000000000..06874686364 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/OtpLoginBuilder.java @@ -0,0 +1,94 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.util.saml; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.testsuite.util.SamlClient.Step; +import org.keycloak.testsuite.util.SamlClientBuilder; + +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; + +import static org.keycloak.testsuite.util.Matchers.statusCodeIsHC; + +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * + * @author rmartinc + */ +public class OtpLoginBuilder implements Step { + + private final SamlClientBuilder clientBuilder; + private String otpPassword; + + public OtpLoginBuilder(SamlClientBuilder clientBuilder) { + this.clientBuilder = clientBuilder; + } + + public SamlClientBuilder build() { + return this.clientBuilder; + } + + public OtpLoginBuilder otp(String otpPassword) { + this.otpPassword = otpPassword; + return this; + } + + @Override + public HttpUriRequest perform(CloseableHttpClient client, URI currentURI, CloseableHttpResponse currentResponse, HttpClientContext context) throws Exception { + assertThat(currentResponse, statusCodeIsHC(Response.Status.OK)); + String otpPageText = EntityUtils.toString(currentResponse.getEntity(), StandardCharsets.UTF_8); + return handleOtpLoginPage(otpPageText, otpPassword); + } + + public static HttpUriRequest handleOtpLoginPage(String loginPage, String otpPassword) { + org.jsoup.nodes.Document page = Jsoup.parse(loginPage); + Element form = page.getElementById("kc-otp-login-form"); + if (form == null) { + throw new IllegalArgumentException("Invalid OTP login form: " + loginPage); + } + + String action = form.attr("action"); + if (action == null) { + throw new IllegalArgumentException("Invalid OTP login form: " + loginPage); + } + + HttpPost res = new HttpPost(action); + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair("otp", otpPassword)); + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); + res.setEntity(formEntity); + + return res; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java index 41e5249a17c..6dbce959228 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java @@ -198,12 +198,20 @@ public class LevelOfAssuranceFlowTest extends AbstractChangeImportedUserPassword } public static void configureStepUpFlow(KeycloakTestingClient testingClient) { - configureStepUpFlow(testingClient, ConditionalLoaAuthenticator.DEFAULT_MAX_AGE, 0, 0); + configureStepUpFlow(TEST_REALM_NAME, testingClient, ConditionalLoaAuthenticator.DEFAULT_MAX_AGE, 0, 0); + } + + public static void configureStepUpFlow(String realmName, KeycloakTestingClient testingClient) { + configureStepUpFlow(realmName, testingClient, ConditionalLoaAuthenticator.DEFAULT_MAX_AGE, 0, 0); } private static void configureStepUpFlow(KeycloakTestingClient testingClient, int maxAge1, int maxAge2, int maxAge3) { - testingClient.server(TEST_REALM_NAME).run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(FLOW_ALIAS)); - testingClient.server(TEST_REALM_NAME) + configureStepUpFlow(TEST_REALM_NAME, testingClient, maxAge1, maxAge2, maxAge3); + } + + private static void configureStepUpFlow(String realmName, KeycloakTestingClient testingClient, int maxAge1, int maxAge2, int maxAge3) { + testingClient.server(realmName).run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(FLOW_ALIAS)); + testingClient.server(realmName) .run(session -> FlowUtil.inCurrentRealm(session).selectFlow(FLOW_ALIAS).inForms(forms -> forms.clear() // level 1 authentication .addSubFlowExecution("level1-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LevelOfAssuranceFlowSamlTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LevelOfAssuranceFlowSamlTest.java new file mode 100644 index 00000000000..61dc499a50a --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LevelOfAssuranceFlowSamlTest.java @@ -0,0 +1,827 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.saml; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import jakarta.ws.rs.core.Response.Status; + +import org.keycloak.common.Profile; +import org.keycloak.dom.saml.v2.assertion.AssertionType; +import org.keycloak.dom.saml.v2.assertion.AuthnContextClassRefType; +import org.keycloak.dom.saml.v2.assertion.AuthnContextType; +import org.keycloak.dom.saml.v2.assertion.AuthnStatementType; +import org.keycloak.dom.saml.v2.protocol.AuthnContextComparisonType; +import org.keycloak.dom.saml.v2.protocol.ResponseType; +import org.keycloak.models.Constants; +import org.keycloak.models.utils.DefaultAuthenticationFlows; +import org.keycloak.models.utils.TimeBasedOTP; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.admin.Users; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.forms.LevelOfAssuranceFlowTest; +import org.keycloak.testsuite.updaters.ClientAttributeUpdater; +import org.keycloak.testsuite.updaters.RealmAttributeUpdater; +import org.keycloak.testsuite.util.Matchers; +import org.keycloak.testsuite.util.SamlClient; +import org.keycloak.testsuite.util.SamlClientBuilder; +import org.keycloak.util.JsonSerialization; + +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.util.EntityUtils; +import org.hamcrest.CoreMatchers; +import org.hamcrest.MatcherAssert; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.junit.Test; + +/** + * + * @author rmartinc + */ +@EnableFeature(value = Profile.Feature.STEP_UP_AUTHENTICATION_SAML) +public class LevelOfAssuranceFlowSamlTest extends AbstractSamlTest { + + UserRepresentation otpUser; + + public LevelOfAssuranceFlowSamlTest() { + otpUser = createUserRepresentation("user-with-one-configured-otp", "otp1@redhat.com", null, null, true); + Users.setPasswordFor(otpUser, CredentialRepresentation.PASSWORD); + } + + @Override + public void addTestRealms(List testRealms) { + super.addTestRealms(testRealms); + RealmRepresentation testSaml = testRealms.iterator().next(); + testSaml.setOtpPolicyAlgorithm("HmacSHA1"); + testSaml.setOtpPolicyDigits(6); + testSaml.setOtpPolicyInitialCounter(0); + testSaml.setOtpPolicyLookAheadWindow(1); + testSaml.setOtpPolicyPeriod(30); + testSaml.setOtpPolicyType("totp"); + testSaml.setOtpPolicyCodeReusable(Boolean.TRUE); + } + + @Test + public void differentLevels() { + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + + // first request for level 1 password + SamlClient samlClient = new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .execute(this::assertResponsePassword); + + // request for level 1 password again, should be automatically done + samlClient.execute(new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .assertResponse(this::assertResponsePassword) + .getSteps()); + + // request for level 2, should enforce OTP login + samlClient.execute(new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .assertResponse(this::assertResponseTimeSyncToken) + .getSteps()); + + // request for level 3, by default max-age is 0 for otp, otp again and push button + samlClient.execute(new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:custom:authentication:pushbutton") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .addStep(new PushButtonStep()) + .assertResponse(this::assertResponsePushButton) + .getSteps()); + + } + + @Test + public void differentLevelsRedirect() { + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + + // first request for level 1 password + SamlClient samlClient = new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.REDIRECT) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build().doNotFollowRedirects() + .execute(response -> assertResponse(response, "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + SamlClient.Binding.REDIRECT)); + + // request for level 2, should enforce OTP login + samlClient.execute(new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.REDIRECT) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build().doNotFollowRedirects() + .assertResponse(response -> assertResponse(response, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken", + SamlClient.Binding.REDIRECT)) + .getSteps()); + + // request for level 3, by default max-age is 0 for otp, otp again and push button + samlClient.execute(new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.REDIRECT) + .addAuthnContextClassRef("urn:custom:authentication:pushbutton") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .addStep(new PushButtonStep()).doNotFollowRedirects() + .assertResponse(response -> assertResponse(response, "urn:custom:authentication:pushbutton", + SamlClient.Binding.REDIRECT)) + .getSteps()); + } + + @Test + public void invalidAuthnContextClassRef() { + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + + // request for an undefined authn context class ref + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard") + .relayState("0123456789") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .execute(this::assertErrorSamlResponsePost); + } + + @Test + public void invalidAuthnContextClassRefRedirect() { + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + + // request for an undefined authn context class ref + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.REDIRECT) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build().doNotFollowRedirects() + .execute(this::assertErrorSamlResponseRedirect); + } + + private void minimunAuthnContextClassRefTimeSyncTokenTest() { + // login with password is not enough because minimum is TimeSyncToken + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .execute(this::assertErrorSamlResponsePost); + + // login with TimeSyncToken should work as the minimum is OK + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + } + + @Test + public void minimunAuthnContextClassRefTimeSyncToken() throws IOException { + executeTest(this::minimunAuthnContextClassRefTimeSyncTokenTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken"); + } + + @Test + public void noAuthnContextClassRef() { + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .execute(this::assertResponsePassword); + } + + private void authnContextClassRefNotReachedTest() { + // ask for level 4 that will not be fullfilled + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:custom:authentication:level4") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .addStep(new PushButtonStep()) + .execute(this::assertErrorAuthenticationRequirementsNotFullfilled); + } + + @Test + public void authnContextClassRefNotReached() throws IOException { + Map loaMap = Map.of( + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "1", + "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken", "2", + "urn:custom:authentication:pushbutton", "3", + "urn:custom:authentication:level4", "4" + ); + executeTest(this::authnContextClassRefNotReachedTest, loaMap, ""); + } + + private void authnContextClassRefIncorrectMatchWithFlowTest() { + // ask password wich in flow is 1 but requesting is 0, it means that final level does not match with the requested level + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .execute(this::assertResponseUnspecified); + } + + @Test + public void authnContextClassRefIncorrectMatchWithFlow() throws IOException { + Map loaMap = Map.of( + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "0", + "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken", "1", + "urn:custom:authentication:pushbutton", "2" + ); + executeTest(this::authnContextClassRefIncorrectMatchWithFlowTest, loaMap, ""); + } + + @Test + public void authnContextClassRefOrder() { + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + + // first known authn context class ref is TimeSyncToken + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + + // first known authn context class ref is PasswordProtectedTransport + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .addAuthnContextClassRef("urn:custom:authentication:unknown") + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .execute(this::assertResponsePassword); + } + + @Test + public void invalidAuthnContextClassRefUri() throws IOException { + // change the realm, because in realn there is no check + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + + try (RealmAttributeUpdater realm = new RealmAttributeUpdater(adminClient.realm(REALM_NAME)) + .setAttribute(Constants.ACR_LOA_MAP, JsonSerialization.writeValueAsString(Map.of("invalid uri", "1"))) + .setAttribute(Constants.ACR_URI_MAP, JsonSerialization.writeValueAsString(Map.of("invalid uri", "invalid uri"))) + .update()) { + + // the name of the acr loa map is not a valid URI, check unspecified is used + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .execute(this::assertResponseUnspecified); + } + } + + @Test + public void loaMapNotDefinedForSaml() throws IOException { + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + + // define no map for saml + try (RealmAttributeUpdater realm = new RealmAttributeUpdater(adminClient.realm(REALM_NAME)) + .setAttribute(Constants.ACR_URI_MAP, "") + .update()) { + + // the name of the acr loa map is not a valid URI, check unspecified is used + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .execute(this::assertResponseUnspecified); + } + } + + @Test + public void noStepUpAuthentticationForSaml() throws IOException { + // change the realm to use the default browser flow - no step-up authentication + try (RealmAttributeUpdater realm = new RealmAttributeUpdater(adminClient.realm(REALM_NAME)) + .setBrowserFlow(DefaultAuthenticationFlows.BROWSER_FLOW) + .update()) { + + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(bburkeUser).build() + .execute(this::assertResponseUnspecified); + } + } + + private void exactComparisonWithMinTimeSyncTest() { + // request with a class ref less than min => error + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.EXACT) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .execute(this::assertErrorSamlResponsePost); + + // request with class equals to min => requested level + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.EXACT) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + + // request with class greater than min => requested level + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.EXACT) + .addAuthnContextClassRef("urn:custom:authentication:pushbutton") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .addStep(new PushButtonStep()) + .execute(this::assertResponsePushButton); + } + + private void exactComparisonNoMinTest() { + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.EXACT) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .execute(this::assertResponsePassword); + + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.EXACT) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + } + + @Test + public void exactComparison() throws IOException { + executeTest(this::exactComparisonWithMinTimeSyncTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken"); + executeTest(this::exactComparisonNoMinTest); + } + + private void minimumComparisonWithMinTimeSyncTest() { + // request with a class ref less than min => min level TimeSync + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MINIMUM) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + + // request with class equals to min => min level TimeSync + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MINIMUM) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + + // request with min greater than min => request level pushbutton + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MINIMUM) + .addAuthnContextClassRef("urn:custom:authentication:pushbutton") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .addStep(new PushButtonStep()) + .execute(this::assertResponsePushButton); + } + + private void minimumComparisonNoMinTest() { + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MINIMUM) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .execute(this::assertResponsePassword); + + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MINIMUM) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + } + + @Test + public void minimumComparison() throws IOException { + executeTest(this::minimumComparisonWithMinTimeSyncTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken"); + executeTest(this::minimumComparisonNoMinTest); + } + + private void maximumComparisonWithMinTimeSyncTest() { + // request with a class ref less than min => error + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MAXIMUM) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .execute(this::assertErrorSamlResponsePost); + + // request with a class ref equals or greater than min => that level + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MAXIMUM) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + + // request with a class ref equals or greater than min => that level + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MAXIMUM) + .addAuthnContextClassRef("urn:custom:authentication:pushbutton") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .addStep(new PushButtonStep()) + .execute(this::assertResponsePushButton); + } + + private void maximumComparisonWithNoMinTest() { + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MAXIMUM) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .execute(this::assertResponsePassword); + } + + @Test + public void maximumComparison() throws IOException { + executeTest(this::maximumComparisonWithMinTimeSyncTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken"); + executeTest(this::maximumComparisonWithNoMinTest); + } + + private void betterComparisonWithMinTimeSyncTest() { + // request with a class ref less than min => min is returned + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.BETTER) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + + // request with a class ref equals to min => next level is returned + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.BETTER) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .addStep(new PushButtonStep()) + .execute(this::assertResponsePushButton); + + // request with a class equals to max => error + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.BETTER) + .addAuthnContextClassRef("urn:custom:authentication:pushbutton") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .execute(this::assertErrorSamlResponsePost); + } + + private void betterComparisonNoMinTest() { + // always next level is returned + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.BETTER) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .addStep(new PushButtonStep()) + .execute(this::assertResponsePushButton); + + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.BETTER) + .addAuthnContextClassRef("urn:custom:authentication:pushbutton") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .execute(this::assertErrorSamlResponsePost); + } + + @Test + public void betterComparison() throws IOException { + executeTest(this::betterComparisonWithMinTimeSyncTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken"); + executeTest(this::betterComparisonNoMinTest); + } + + private void severalAuthnContextClassRefTest() { + // exact should return the first one that accepts min + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + + // minimum should return the first one that accepts min + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MINIMUM) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + + // maximum should return the first one that is min or greater + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.MAXIMUM) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + + // better should return the next one to first which is valid + new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG, + SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST) + .setComparison(AuthnContextComparisonType.BETTER) + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + .addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken") + .signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY) + .build() + .login().user(otpUser).build() + .otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build() + .execute(this::assertResponseTimeSyncToken); + } + + @Test + public void severalAuthnContextClassRef() throws IOException { + executeTest(this::severalAuthnContextClassRefTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken"); + } + + private void executeTest(Runnable test) throws IOException { + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + test.run(); + } + + private void executeTest(Runnable test, String minAcr) throws IOException { + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + + try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST_SIG) + .setAttribute(Constants.MINIMUM_ACR_VALUE, minAcr) + .update()) { + test.run(); + } + } + + private void executeTest(Runnable test, Map acrLoaMap, String minAcr) throws IOException { + LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient); + + try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST_SIG) + .setAttribute(Constants.MINIMUM_ACR_VALUE, minAcr) + .setAttribute(Constants.ACR_LOA_MAP, JsonSerialization.writeValueAsString(acrLoaMap)) + .update()) { + test.run(); + } + } + + private void assertErrorAuthenticationRequirementsNotFullfilled(CloseableHttpResponse response) { + assertErrorPage(response, "Authentication requirements not fulfilled"); + } + + private void assertErrorPage(CloseableHttpResponse response, String errorMessage) { + try { + MatcherAssert.assertThat(response, Matchers.statusCodeIsHC(Status.BAD_REQUEST)); + String page = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + MatcherAssert.assertThat(page, CoreMatchers.containsString(errorMessage)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void assertErrorSamlResponsePost(CloseableHttpResponse response) { + assertErrorSamlResponse(response, SamlClient.Binding.POST); + } + + private void assertErrorSamlResponseRedirect(CloseableHttpResponse response) { + assertErrorSamlResponse(response, SamlClient.Binding.REDIRECT); + } + + private void assertErrorSamlResponse(CloseableHttpResponse response, SamlClient.Binding binding) { + try { + SAMLDocumentHolder holder = binding.extractResponse(response); + MatcherAssert.assertThat(holder.getSamlObject(), Matchers.isSamlStatusResponse( + JBossSAMLURIConstants.STATUS_RESPONDER, JBossSAMLURIConstants.STATUS_NOAUTHN_CTX)); + ResponseType responseType = (ResponseType) holder.getSamlObject(); + Assert.assertNotNull(responseType.getInResponseTo()); + Assert.assertNotNull(responseType.getID()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void assertResponseUnspecified(CloseableHttpResponse response) { + assertResponse(response, "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified", SamlClient.Binding.POST); + } + + private void assertResponsePassword(CloseableHttpResponse response) { + assertResponse(response, "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", SamlClient.Binding.POST); + } + + private void assertResponseTimeSyncToken(CloseableHttpResponse response) { + assertResponse(response, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken", SamlClient.Binding.POST); + } + + private void assertResponsePushButton(CloseableHttpResponse response) { + assertResponse(response, "urn:custom:authentication:pushbutton", SamlClient.Binding.POST); + } + + private void assertResponse(CloseableHttpResponse response, String classRef, SamlClient.Binding binding) { + try { + SAMLDocumentHolder holder = binding.extractResponse(response); + MatcherAssert.assertThat(holder.getSamlObject(), Matchers.isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + ResponseType responseType = (ResponseType) holder.getSamlObject(); + Assert.assertNotNull(responseType.getInResponseTo()); + Assert.assertNotNull(responseType.getID()); + Optional authContextClassRefOpt = responseType.getAssertions().stream() + .map(ResponseType.RTChoiceType::getAssertion) + .map(AssertionType::getStatements) + .flatMap(s -> s.stream()) + .filter(AuthnStatementType.class::isInstance) + .map(AuthnStatementType.class::cast) + .findAny() + .map(AuthnStatementType::getAuthnContext) + .filter(Objects::nonNull) + .map(AuthnContextType::getSequence) + .filter(Objects::nonNull) + .map(AuthnContextType.AuthnContextTypeSequence::getClassRef) + .filter(Objects::nonNull) + .map(AuthnContextClassRefType::getValue); + Assert.assertTrue(authContextClassRefOpt.isPresent()); + Assert.assertEquals(classRef, authContextClassRefOpt.get().toString()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static class PushButtonStep implements SamlClient.Step { + + @Override + public HttpUriRequest perform(CloseableHttpClient client, URI currentURI, CloseableHttpResponse currentResponse, HttpClientContext context) throws Exception { + MatcherAssert.assertThat(currentResponse, Matchers.statusCodeIsHC(Status.OK)); + String pageContent = EntityUtils.toString(currentResponse.getEntity(), StandardCharsets.UTF_8); + org.jsoup.nodes.Document page = Jsoup.parse(pageContent); + Elements forms = page.getElementsByTag("form"); + Assert.assertEquals(1, forms.size()); + Element form = forms.get(0); + HttpPost res = new HttpPost(form.attr("action")); + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(Collections.emptyList(), StandardCharsets.UTF_8); + res.setEntity(formEntity); + return res; + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/testsaml.json b/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/testsaml.json index 25edd1cc99a..b1a797cd19c 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/testsaml.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/testsaml.json @@ -18,6 +18,10 @@ "eventsEnabled" : true, "eventsListeners" : [ "jboss-logging" ], "enabledEventTypes" : [ ], + "attributes": { + "acr.loa.map": "{\"copper\":\"1\",\"silver\":\"2\",\"gold\":\"3\"}", + "acr.uri.map": "{\"copper\":\"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport\",\"silver\":\"urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken\",\"gold\":\"urn:custom:authentication:pushbutton\"}" + }, "users" : [ { "username" : "bburke", @@ -112,6 +116,23 @@ "clientRoles" : { "realm-management" : [ "impersonation", "view-users" ] } + }, + { + "username" : "user-with-one-configured-otp", + "enabled": true, + "email" : "otp1@redhat.com", + "credentials" : [ + { + "type" : "password", + "value" : "password" + }, + { + "id" : "unique", + "type" : "otp", + "secretData" : "{\"value\":\"DJmQfC73VGFhw7D4QJ8A\"}", + "credentialData" : "{\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\",\"subType\":\"totp\"}" + } + ] } ], "clients": [