From 2d9cc0ff22624599e42d3754b5da270d78e7e4f3 Mon Sep 17 00:00:00 2001 From: Garth <244253+xgp@users.noreply.github.com> Date: Fri, 16 Feb 2024 19:33:44 +0100 Subject: [PATCH] Magic link extension (#68) * Magic link extension * updated the handler overriding the startFreshAuthenticationSession method Signed-off-by: Garth <244253+xgp@users.noreply.github.com> * Create logic for magic link continuation * Authentication email improvements * Fix code review comments --------- Signed-off-by: Garth <244253+xgp@users.noreply.github.com> Co-authored-by: razvantufisi --- README.md | 19 ++ .../magic-link-continuation-authenticator.png | Bin 0 -> 52581 bytes .../assets/magic-link-continuation-config.png | Bin 0 -> 16389 bytes .../magic-link-continuation-expiration.png | Bin 0 -> 23631 bytes .../io/phasetwo/keycloak/magic/MagicLink.java | 97 +++++++- .../magic/auth/MagicLinkAuthenticator.java | 49 +--- .../MagicLinkContinuationAuthenticator.java | 209 ++++++++++++++++++ ...cLinkContinuationAuthenticatorFactory.java | 86 +++++++ .../auth/model/MagicLinkContinuationBean.java | 20 ++ .../MagicLinkContinuationActionToken.java | 75 +++++++ ...ContinuationActionTokenHandlerFactory.java | 33 +++ ...inkContinuationLinkActionTokenHandler.java | 107 +++++++++ .../magic/auth/util/MagicLinkConstants.java | 10 + .../messages/messages_en.properties | 9 + .../templates/email-confirmation-error.ftl | 6 + .../templates/email-confirmation.ftl | 9 + .../html/magic-link-continuation-email.ftl | 4 + .../text/magic-link-continuation-email.ftl | 2 + .../templates/view-email-continuation.ftl | 23 ++ 19 files changed, 712 insertions(+), 46 deletions(-) create mode 100644 docs/assets/magic-link-continuation-authenticator.png create mode 100644 docs/assets/magic-link-continuation-config.png create mode 100644 docs/assets/magic-link-continuation-expiration.png create mode 100644 src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkContinuationAuthenticator.java create mode 100644 src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkContinuationAuthenticatorFactory.java create mode 100644 src/main/java/io/phasetwo/keycloak/magic/auth/model/MagicLinkContinuationBean.java create mode 100644 src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationActionToken.java create mode 100644 src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationActionTokenHandlerFactory.java create mode 100644 src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationLinkActionTokenHandler.java create mode 100644 src/main/java/io/phasetwo/keycloak/magic/auth/util/MagicLinkConstants.java create mode 100644 src/main/resources/theme-resources/templates/email-confirmation-error.ftl create mode 100644 src/main/resources/theme-resources/templates/email-confirmation.ftl create mode 100644 src/main/resources/theme-resources/templates/html/magic-link-continuation-email.ftl create mode 100644 src/main/resources/theme-resources/templates/text/magic-link-continuation-email.ftl create mode 100644 src/main/resources/theme-resources/templates/view-email-continuation.ftl diff --git a/README.md b/README.md index a49acad..24046cf 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ # keycloak-magic-link Magic link implementation. Inspired by the [experiment](https://github.com/stianst/keycloak-experimental/tree/main/magic-link) by [@stianst](https://github.com/stianst). +It comes in two types: magic link and magic link continuation; There is also a simple Email OTP authenticator implementation here. This extension is used in the [Phase Two](https://phasetwo.io) cloud offering, and is released here as part of its commitment to making its [core extensions](https://phasetwo.io/docs/introduction/open-source) open source. Please consult the [license](COPYING) for information regarding use. @@ -27,6 +28,24 @@ The authenticator can be configured to create a user with the given email addres ![Configure Magic Link Authenticator with options](docs/assets/magic-link-config.png) +## Magic link continuation + +This Magic link continuation authenticator is similar to the Magic Link authenticator in implementation, but has a different behaviour. Instead of creating a session on the device where the link is clicked, the flow continues the login on the initial login page. The login page is polling the authentication page each 5 seconds until the session is confirmed or the authentication flow expires. The default expiration for the Magic link continuation flow is 10 minutes. + + +### Authenticator + +![Install Magic Link continuation Authenticator in Browser Flow](docs/assets/magic-link-continuation-authenticator.png) + +The authenticator can be configured to set the expiration of the authentication flow. + +![Configure Magic Link continuation Authenticator with options](docs/assets/magic-link-continuation-config.png) + +When the period is exceeded the authentication flow will reset. + +![Magic Link continuation expired](docs/assets/magic-link-continuation-expiration.png) + + ### Resource A Resource you can call with `manage-users` role, which allows you to specify the email, clientId, redirectUri, tokenExpiry and optionally if the email is sent, or the link is just returned to the caller. diff --git a/docs/assets/magic-link-continuation-authenticator.png b/docs/assets/magic-link-continuation-authenticator.png new file mode 100644 index 0000000000000000000000000000000000000000..8ba6ae1345e75ff6d1951948c3abb9cdc3e454b0 GIT binary patch literal 52581 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 literal 0 HcmV?d00001 diff --git a/docs/assets/magic-link-continuation-config.png b/docs/assets/magic-link-continuation-config.png new file mode 100644 index 0000000000000000000000000000000000000000..bb40e64d0d4df10d3cafd750de7a6052eb224e09 GIT binary patch literal 16389 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 literal 0 HcmV?d00001 diff --git a/docs/assets/magic-link-continuation-expiration.png b/docs/assets/magic-link-continuation-expiration.png new file mode 100644 index 0000000000000000000000000000000000000000..6800db51b4a7258b85912ea08ec6713f7b43067c GIT binary patch literal 23631 zcmeFZcT|&G_dgi*D)&mecIhf0(vd1%Md?LKK)NWs3Lzlf2H5BXq#1fAK}vv7q9UCT zS_nNL5FnvQ36M}S5BI*a)^FB#X8xO5v(~)N%1S-YdCuNv@6Z11ea@5jj|{a~&vKpx zfk3P}+7C=Xpg*V}&`IGx&j8zMrs9HDcj~i+DIXU>d`FLM5bMtZn>H1u|EqBe?>FG6T zDQVejx22S%<&@;4u8B%XOG!Cz2kV1C*FZWC?wP&FT$u`d0izx=HV9$xq@>IwgHe95 zdr^O1yfPAbL8~?vb*I>>0$gTZQ88{+fkI*2Yl%vwBWUAHXf5>ViI2G_Z)ik$YM*=? zAMZ<24Ufpo)Hq*t%VBaa)P~UD*QvN@r#RcPrL>M)^r}Ztj%5dqmoA?}f=~VX9<*}x zzUsekzdidCP{=<=-1XuA{H_x9i2dL1e*d@Of7SD^DX6q#iWC4l3X7qjrCBtFii`*p zBt~LLcHxd;w4KM*D1k0n1KXel7^ljM=fOe8C&fPn>~C#31_XLy{|k-C9ViZ`55e8H zNZ2Yb>qI5)Ud7nFPoG#~xD`zTCw-%B2rxGjn`6s+!gX{X1j@dk7|Y)(wN2;pDXywA zDZ%yGaQF!*>PCfQePSI1d~~BCV&`1HzdlW$PcjN99y~tto5HCaUJxit0BmewY@DM& ze4&Bz>Va}AB1AD+@#?%Vb>5^ov>$iTEU6pHO`~`$AdxW1b4KbCI+9JlU!Q;fN2|{X zpdZMXV=LRA=M!jqluti%TEpe?Bu|Yu37RI zsY3)UqvUQ8M>8+;{Pn*p)aeu52x95mgz}Lx1Y)E|5+l}AbumWsQe4NSk8OjjcYrzc znyBb&ObssIfB<57O)OiJH-@oQVme(Q;Mo#2_4J`l>1(NkN=HlcBrhozjT9$0%1A|N zaq%N3r-Ix8SEVIAoWc?s&^ho}?c@2(`D;aRe0nyz)eCWK52Kfs!kK}IEGa7+N)fjPh6;Pmy|8b9 zEiE6~EkY)EXyw1+&-Xq|7SdYZDL0c;C^dqWyl5_l;x75|`6J>p-RVpm2H31d= ze3!tf7Wy~?|NP?>$&uWLA8VX?Eo!y<-LPIrH`ju?Q{0q=@iqx9D? zj90k>?>4u#v{jI9f8z&CAFVFP+^HmfiF=_D$YT+!6j zbn}w88eEd+=fk`K|4a6zgIFmRFc@6&bm(T=obQ68i>Q#Y^=TT7^%As;-10)@OZiWa z;wl+VrPcJp5;{E?x;i3fTbrsaP!oPg-83VsiqZ*OzQJfPnl& zDF1#o8m$z0|2o(LEX*AvOz@X=8nD7XZqU*E7C+$G>qpq>h*{8qb=lW zYehswVRLu7S^f}c?f>@b)X)Ua`?r_aqG8$sgFX7JwXA=f1RQd6|8SEQN+x13`a@=S zxJT0EJO(MH6%}C1paypj4*&(^+_!IWMCBN%(H_h`_oH!ea7Y`#0V})25+%^^p)D5} z0)3?#$9eJr+*6rDtdP|05&ns_wZAt_rq+F-+h1WOGt+Yw!;oU-Q8LSUq#fmO>+(mp z*&nBYOPMQ$`g03e*4F#x>FVhXO#t&oil8|qLA}b@WG}vdkp4%(fU6mB>3n&gX|ai_ z}t?!?JMpq1FyMf>$luwFbj&ALW$1WxjeJ=*u9)^dfIigLxuatNgHBf!0 z28kxxCNcV_LlLNPMAX&WwpKGkZ(#S*jIGap{=lVncs;#L=sv6z%90k9ubE|MO;0ba z{HU2{Rp*^0CaP#NytwP}?d1uBsA@pZy}kOUPo9Xmdi!Z(seFg;#XtWL&}k0YHFt9> zIsg7`s2Y3(VOw^%Ghx(|BA!IK<7aDOi}p9(U7MN?Q{f>6`(fCZSYPd5+E!X2)% z&gP1`S?9$G!9z`e&ZP}Z)6!1Z`gI9*_uz_rkNNY?pT~|rs)NT{Ut&3v6m!8-YpP0m zd&$Dd%8nz2L&|BJM?hhX+|O<8vS3wOX>gjwBE_kvQrrt=U~78SxbCGmL3&=3CrwR@ z@hMuCKcV18Yr=vjbF-DZtV4Pspc=8B!ETsnfo7JA=_|Xr8H6v`{#rcuk90JkQD8^~ zWgvs0;hFDm3ZkyJ1D}ueKZ3MJKEESIFz72dc71nzslVVPV9a0>liWwQ1avlO`qY&% z(+=K{ayHfNN7U7cA_w@m&(7E9v-kgHBge?w!57g4-~o4sjyb=xGx_HQBeU@ES1hgZ z#C5^}ZHsG!VhWhPg?$(dakm$X)#0lit9DUPM3w9pE+G9zjToRj2#9WMkLk6) z9|@cv04!nQPVD`h_+TozATyKOYYHtlHD#U{#me=?&f)_g)R{MA0@6M>!({*I#7IDn zoxAobok{`FK|m)@E6xyu*!bHKkagx2Q@5uI zr60Ez0D)HZ&vO9S9lG?PYt?@c{MM(Ln&RC1`Fc-Pa>BWO#l_4#ht{Y9!f^y&`OD`0 zNl@hl09*je23lM+dVF^Dufy;@PH^O$bd|K@HkW{c110lyee9nYX;!|7#+_&6}BO0&|L;y9KBXU=iop z0OfTrZuQ8rE|5qlB^6oTxU!ZOJG~UKfnRMOYaMl}%2rCjqP70ZCW6s$?`mSW1^_!3 zKejne1OzW_{GM$EAnEDX&(hM;j#s0qssMQsxH%{8?CD0n#cH(AW9s;VMFpcr+UGX> z_beVginlX|01mICaPqI{*5#b$lAOB2<)ITy+}W>AF^OOtmONE>^(?4zY@7x2sFc*y zqG(#w{d+My%H{~YNWhFhJ7UMFT!5wq-d2_qEOt4`Nx=-)mS+OLXR>==nDEFPB`lQi zC_l%vVosjdFyj+0R1I^`L(;L=Pfd}8d14%OV?`Tc`Fl{w?}Pv`P++>8Y?rpc`Q@{B zF+}2{dshkVFUp$6O!sS7YrFQtG4`Ssh(Qt-#@gzCN@?chDFGcV9%wQUL2fuK15DIo zu((x9pF?xgB{3b@@5_xJ8CeF{P^!b{iPK4dkl)rG+#(pv=y{BPXsXc?BFKdI@vK`4E;z7Po}Yzt4Xo2geC@_$F%$9Fv}dgV1B3X>Dpp zZtWj(N;`H2T+amhoa=5Fp3{h88TiB@GpJ$8#LAg$=k4t5jDC$oAPm1g7Hgh1n@L4W zmb5(*lf&4}bpVd_`n#Cm`L4rhfL?s~@FCsono=x399P83)y);frQF@umm+{old+Dv zZkbX5;DRc3*tBbIsNdjmqP*95K1|fYeW^bu28D&WX`}MjRD)eLf*QCxMw%o^%Uh?^ z2-l;zfBbE8>@c@&B99#gm?FRhlarG8TcKbdMRJ0}QhIB27gV3a*IM(3PG8&=QP(|L zY&=VsuPty%ebZ9I(cmY(kt{z8ZTJ8?;(im7C>MbuJs_?to!lfeyy43;udXi*1?@t0{9cD zF#3jdN&#)_iY!^6Z2ORvOWu|C;S%=s0|nJM+qnfU*6%JzyXHa2slE&aNn3DgX|~?! z3<#;$SnP7DqwDXPd!NbL_ot>FUrlm zGyXbymQDdcAE3>bVjfa~h*^q8qjjJ+00mVlpCg|DRVJbRmvglRa_>49R)rSI-^}M* z=#ahkq|2A30idLA9w`?F8v{^iPLvoS-c8%L1N@5EuhAp#@yttKQ_7S)W$!&a_w1yZX|FctgWoZ^Gn4!V+wt`ZeBu_ zM=2fw{4v+T)dy0tE{8PKgBJ40y2c;l1rW7@sUl%Vv_*hb!JI5hkx0T|fgW|OaoxRr zo1;sjIVOd2rz>8DGo}a{(`5Mn>7>q|L(oyllFKB!afJSeE`^^7Hw2TjAD$uz(!H-_Z*YTg%w8NfuL4@?7xKAecUezm5RncopCKSl`0M(+u33 zh#Qr^h}y`6mh_5SmXLh*?4+Lnqc<@yh;es!2O!qW%Bu9>b?AcQqHLO}O&CB4 z6(J#JU*hM14jX6DQjVE{(%a+QJm9_vw{B!G z?eyvHzy7Gf^9=_*jB0`Tbk-VhoU<`VxxOnZDyj#hSj!XV z=Gt|1&qT<$^qB!ME_o7X)|y~eL>$P|^I!Se(JjRS@MP7kUKt>tqe_IQ9+z+?@=Vx4 zFAbkujXKNB3?y19^>* z(?3pDm@oHQ0HdRz$ASC=?Jum~XOtTHluqm7xxKKJ@!{b!z*Iea)epllfXBT4{v_!` z-SA;~xfl;n{jx%Nrlm;Q=Kz_xtY@4NkV6A?w8|)UJ_(F^5XX5I=PoIh$opfuJ0}d! zxW^`~C}qIyui3XkI%2qrfJy^4Yd>-XVPHVoYnx^E;Ve*d>*f8+GvZrEXe&+Me#V6ZYwjj*gynE04txuChQh0NlTM z3St6#m6)8CHb%nXP^(eb26Un$Qk-;j&E-)3+_?jv^<^3kzdxzOE*uxcvts_OnC0sv z)jawB+A+|)6+D_+a*j`>BBr&Yt84X!q8iWk2oc5+SE^Q~Jw=iC==^}A;&;7MGLnjB zqAqd%-ry=_CUvbV`LSx84hmA*&4d87Fo6xhsRv%Hy-BS{8cX3#t`fE%ibpioUPIC# z`ChYDw=TMGz7PQ!sN!S|PM>^*sw*0(GKFh7JD*8TYgmQGEOI3e|N814gIkF^Png#z=ol+ z1v{cBrHJA>8;b`3k_kPPMh$15Ma zjtwE;^fB>N(h>O^DBbnf670$V-)%s6>TtaO>}Z|nAabr#7w43kcrH#Lb(XzzljbBZ zTXai8Vx)O@S`*Le3bBuu;w(+ji)#1}uY_bXV5w?YHg^iV{ zBHyidA0^Q5t^)%DfvkRJ1|PoyI_;23IgWn7@!wUE|1KK+cd6|E*RH_$G7SO|RgRap z3;;z`NU=OK@&P#95(q%WM(*nX`c4lVG#PX<0ZT6LAF+Rv(D=**dbwNBxA-;$$kA*8 z5ArXK{UfMk@ozeijO=Ph22?}_#^5IY2nBu)it4e)1K_I1$dWzcWlVuUGdwYG8dL78 zyaqsbM+_*ut<<1?s1TsmtXqH5fGS*wEc>z*XTZdsTC3j)&}cQ#{{e3NEQK>Vi07|8 zr79pZtlUyM|AQmytL;m|_-~sZ#~rZ%qs{8}<<>EH(>!vr1@-p|fTgr?=IL~%z>??4 zpl>eeB*cU^2-JW5Z7?X{aY(D1EdeMJ8=Pz&#+aJS5@W^qfZ;ZwUN^TpWI0@wHmKIA zOr;0TbvIuW*#U;td@SdOkb5mP3d{i5zwWfxl>ck%&)>k$Tg4enl^Wxe=NYA) z5bh$ZyUMYoC0oH{ZusCd6bNObP@spE&Bkak;&JD(vyKeo|8$X|6A-S;U`)SLst%Y z2s&qR{KByBfs9_Udn5=s2LzX+Xn*Ul9@R4CjeE`?a=|i zJROg%rnKVY&IqTYEC#5q?Xa$9Q3B`DfTCAa{;}!lwn{+W7DcO!DE>C7Pg5;`d50z$ zmoQ~(D4idXi(vbHJhUht#SRG1abX4+TDED8|5scaFm?M;DWu+oV~M{!EM|IAwAz`e z1T<>Avv>+H@oT_rxuxU2wnnNQHyt7Mp9aJ}R%X@@A;4HdtDmnifu5}21(sg_IbN{* zqv*ju#>82h7m@(K*56$tp6$j2<9txfQ|lhyzDq81^*np z8o&UDq*l&=UizHr13YCV=>MYCzwM8&`R}`#+y75C+}qs-hP(syg+=V77dFvfIg8tr zuv*V;F8Ui2yj9Iqem&xWuDsxZbUwM1-={s28a(rQqK5Xtg_^MuB)zkQQZPqyV4Cl; zFH9G@AeFUWsWvMfkGo}{7+5u0QR=%K7D{wV-=+kbMSR=!-#Sd9j$8Ye$gm*V$W`^$SNi2ePcs^CCk zqgs&kS-DvYqmrLQ`y5;J4L#8-5f!8Hw&?TOtW8Y7L^VA@(nJ0NRZ6}me`JK-*j?K4 zANj_^QBF3UeR+?Jx)STd>d%#b6xm{nW>Y}fL9QhCOc!{Zu<8i;8!#`fe@B$^Q>;2<1@(-dmP5_5}~PJBF-~&rQs~{;I5JC&g;THdt|!yW9V!>w}te{29a)DqJtxO z$+~dqiq;>AJ!$@_2X*QN%rfeO%(x>BQy0krG?wZo&5UMAYiuN-Q4lsw#?)jN#~GU97_v*D#z3tzTgpzG7NlIPEf_$&< zQgOgoNO1TjSGxSJ2MpXX-?N(YDlcw$dC_RFPVYndVXv1sWB(!AZ)$-~;L)m+=5w%- ziP-m=vfp-5jllm|kZXD@5p~GVR&haz_3=T~TEvoI*fzMt{g(-Ri&1!m>rl+fZsoFg z7PVzM%m?ip(QL=1;BDNTh79L#U*6UWFMt26v83(uNC}?6*OV>nV?fAsDi}9e7ZJs1 zhedH0Ul2Ncuox%qDND>N=swLDNxniH>6J_^YZAHPbfr{_cnIxZRv(sgiE%ZVas&4f z)Zrm^47zKJxp%qojdc!)f~tsQa!+bp+9H)ra+I_vr=5OqMZJRJ9cs}m z+4wN-z&RQ1gg%>!#Zz_}cI-Hw{IV}i)}V%r`s`cLOI;k3YmuK!Xvw%vVG*lbToS(Y024y^_$O zExgi;Uy~I&erhk;a+g7Oq%N0>*6`4IMqlk-12Ays4nS+ZN&FhBtd<+Z3^$G*CsxIX6>lvC_eu7m9M6u8c0s zU_yfz_twngEa~ZCul3#${Ij_Jo!Ym?*pPj7zYZee4ObP92hH)2zJ~Ol$%%vmE-KCVp0-cC3{R>PAAJ)$@<15x5@eIFco^1%8AJH;XoW;>6jM{HCFG)m_M*<^w zq`m9c>JQlI*pC|2&eNx?5!WV-8!3UuajKE$*)8Dt)7X~T6s;etWY>f;(1?6)v&?uaAw zQ|;aF;rn*RSeLT5Bu1_WiT~lMnGmbw{fT@zv;^8`h|o+dM{ajRwrYuO1j=wqcf+T6 zO?|YTi%VE`7emBiqKw6L`0a&@=`@5|h0p9P9k;z6SLJ{3c7WBv@o{xwQ+}1N`Uqa@ za_#eT4#hM5qEPAvH29F_R=QTc(>8L37Ta`xk!5N0?sDu#2{DJM%$4@E!do^dvwGG` zm6noI?S0@dbQdYuclL1ZJqMZg#(pW}%|w}=f9*o+;x=xt1Wl^a1Xj7l$-b|{gK77y zs6Uv~d8TH0+}qaZ0Mi1fmL|Z9MhmLWWENBS!oiZ$VXvY_di&p4WR(ozFvbz#3lNwO z{6}r)m`RSfh)JD9;})#w1Qp#cmPaKCu5oMs01;zZ2Fy5%W9fATq2xiC5MB9k%$wQLKb{p&KT5Gn z4=w8p2~U=2K9sk8oJb%|`e=G1{3a_(OMUZ6Tcj@h2-#|Zx>7O|z`Rjbc?&~z;wlU+ z6rULDy4!lo%-De|y3_~0MS&Y?DYY$y(VE*njUrfLL_ED+eHeXKOIbB7 zS0GIlU(~@sb6bNyP7jqKBeIQ1lK62}eCbC^zsY&_)=tF&W4XKvYS~CP&-2m`%2F5c zR&~0RDkk?w#!M*eSU$8Mg&rg94S8H~j*PrFC0gqj_JA{M;>d5JHY5C)JXcNtj2+0n zPEN^g{^TRa3fcFEU2xy-jj1EoQoT|u)27%5a@zE$--k`cuza5+2e#wHc@)DPnc-$M z*jHt>7U_fTrQU=bugW}QOUrwwCluefI7ZKZjJo8ZP<9)?xR)g7_`NaR{OjuCjOW3l zb+J+VqRdg{u&*=iH$I5uudEIY-Td@K`})I6t4{=-8(A>YN2y(ZPAOp{e!1Je*y1YX z3`HYot#iQ(s|;6?Q1$k55u7~NmFoUH=`9wVuU6W-@+ex3TGQ$v_;AB z^jS4_NH{NLd5YZ>@>@|PgN{f^&CK115#f({qJ1wC%;%=;?bqrM2`eb)SxHKGS4Wc= zb=aWAQR;A9>Dx1>=(L|3oHE7Hn;IO_rX4>~8O`e~PR>rZF2~j!hI`KRbSFv<9^q~@ zEM)`o+&S+S@l$0#o_o^gqIFP=~Ug!bRlGYA)nn?N(kn zqq^6VV72{?HQk@omHvF(61|7AsM+YZU;Yk{(DRB3pGw`^XM03MgRN={DqK59(qSG2 zYHj4H1es>z;yj^G`0?GMn;AiOJmjG6C2We+TbE>&5SjsB>49U#urhuxi9W09`;=2* z!;(CIa!Q94ayQ1ns~W-9rAjiNt7=0@9i>;p(hbGE8$Jz|PL`M6j2Xy<}orx9<0^N*!rZCSWD-lw?a@7L3HAADVy4dnI7&jnyjF-$e5ho zA0F#X#tB)b;Gb$g+uQ%*8_<>DP|BasdFq7W+`sOUx^UrLns0V!d z;$B5Pl>$zs^VUqlM_p!E*18s~wR328kRlW1cg{q6Z#wOCcSYGFCy&;&EL|+T4yA#Q z=3lcXZ>Mk+>coEZkYMrE$oJN>|iW39oi?qB+vW=1t%<>ve_79=;WXc$QP z2p@fL-C)MK1{Vc9p_UM{kE*WVhAbu`9T4uA(ED+H`nQI~e#?BsROrl6sNKy*?%}o} z6>qf2lgzp`^t49m;D?=?f6FFE^hPR)E{3W89<{;ORJw-DG0`y{E&bo#FD}n$=5=9P z1Ua)*jrjUJOE_`h)s-SyCvO*nC&fF&H-KhFMWvw9peKaNmSGvpVcm_^4%fZ-r&N({ z3&VcNHuoA|{Ee`K9lc=cTrpeoteOGm;Pi5*S)D0jFrVG0|DlljEsidMcB#05^pc+==36C#(bfD9T;b+({(?&?Y7y?z*=y*8k9 z>Lm*gnxJ~WTQoZu=V@_F$hDBFB`#`xBd2!{%!u7n3Aek_l%D_i`eAD8?U(0bJiBEB z?j;J5V~j@hVWA*;2hjsoVsr6PjQAJt+E@S z{LqcMXd%h^ll;)-EDJXp6w|-Jg&Pti@*oqBBD>$2yV4fdml}koAJJYZP$Vxf$+9+_ zVsif_*a}^4Rv&_sk^N(HQN*)~+Y8$fuA3Nl=)o@A05h{PWwdse7g!_$wycZF z07Wo|osrJj>&=Q3ut7+U*(I=CX`G_;sWFiy#k;NSYT!E6FjOq>=P7RuP|?#uUi6`H zSTj+OKJ}0vUw3Qt`AJA}@kB24W8rc^G;&0ai+A{0YTPi}j>~AzRdR5*IND>VIruk% zZ>a?9>omWMe~)?bpzf2THm_*j$M&h;#b561Jybm$)Ie__J-|G;M~1s6IH5n6>4!ru z%kf{7rzPa)UjD`DW1s7Cm`d(wwQhXyyM*w*j`IG*`f@`NBS+mf1ljHGd3rO%?Ri%M zdPY)VdHa;rRYR|fZU;zSa}p$CsG8jdiGi65J3E$nU>ub8_C8Wm;d}*q^JDbl3tBUr z4bjVVCPub5yLySb{Z_sF@aWk9zWjlX%rbkmZ7d`J&@ zaF0>%@}`s@ak60wtg`YXD=5-uB4lKxkUwIl_>0m3*dUzBaYz~iY84k?uOrEdPu{XI zroS7M3Z8m{!>bytBNgCT<*DJjiEARrr2CO%8UONj#=P+q^hbF2l*DIw{H`*Fz&~b0 z{(9x8xa`R1jk_4euW^^q*=jga%N}9DlzL>zup4&MLLB}lH*1C37V4eo&uwR@=C3s( zCVpX2UcikW9lpQ~0rZg~Be-fUZPelpg*u2&rKoGEl{59N6Ujx!P`*QRA|op=gII5? z6(++u8=zKXBWVL|DdeS=^EPKoLi|D6qT=GfrW7M0usO{w9oVQW&63xak-tqK?VbRA z>zfPa0aaQ+bc>d=kY|?mF6`G@+VOHqn;kyos&VjOQcB2u*hgpIKH5Cx-~GP1;W+$) zcKmFrPl0yay~8SgbelP6ubyRh89D>rV+PkBjz08%R{>!CfxYB@^c^~S0hT_)J3bEd=&cy5^!h`1o zVpi{;Q-8TSaO)Hx9Xn8&{WoL-iyOe_b9^2DSrI!fxE|{bU@reJl~Vr?wch_vUwV(R zIu-Q72Aadz!08W$6^Wm=(tY*XDZwDX_;1TUE}nQ8NqP9NUsMF~uGsVJjazh$_=ok` z?}}GCNoQ;l9tNBh&v@9K`1JZzU5$=&ctpPN^?WZYs~^{qgstABw64sg^?aO+OwjJW zDii3|RpaB`v!Jt@|J>F0^7XlYZe+`T@<0_djlO)yFSNQfHHj)NV*-j|zejG6PfS@4 zX((>-+xNmuMUf4Svq)kelVKCTmB|&?dgY?9Uq2_6ipKH$fQi58DP09<+Bc8waWOts zXV)mIsGxzZ>Q%t_>uwseKT%9CnoOxSY>)~(EV^z0v*S3No!gwld8SRn=m%T5rjQW> zM&CjD368*-+q7w+y55()y{sMFsq|Aj*pC<$^$7cVi*Ua#dD`4;MBjMGu;Z^idM{i~ zroccwYc6g~CeJ;Q{Aqs^Q*?AAB0RwGvP5auXLgHBNcOa z+7QX9g8k5FCnExa2z3l58DwPL3yBaVs(XIimK4M*#*sFM!V^V>`gBWh!DAFrq4_Ok z){v>174nJ}gvOzcm5ak_KP!ot-pE0c>T#Z_a@{bMz~w8OAJyPkdG;e&Ot9-+Hbutk zQ1>_TA&*I0N4o7kRZvnaJefnLsK9sSI_;njw=hnDcb7K|RX>^6f*kzC)<^G zSEAl5Z`9}7nfs~JTZ?hce!}ZPcT60qj<|0+9dcMQc4%;z3M>=cV(alaky0W&7ZGUp zg*aDk6NIg8IQp=)ImPasX;(52%P_*l33Z;!WU#AAUU8OdP7F%tJOan5=+;{h9KNEcGx=iAH+5O<>pT za>yZrl)|VH`wKBl%qSY6KMfDw32NA5@WcmQ;ZD^H;r;^84D*&i&%9%)C;d9`3YIJB z!%n$`sn^9EIfp~0sI=C&fWDu649uij*_8AMIdgKOItjABcZBlepZH! zG?>|C^+heoMp?=A2=k-8MP_@h?Uel{I5rGAF+bswmBcKeCfKL~(Tv47>9+)rC@QW; z;JQyhB4B#!mhl41&7=|9URUaTsRyEGp`#q$7!l-kgl`%@x(=A@wD|+omrf!sSYr=T z*%Gr_uXU)yB-p| zwnb+4S|tTBc=HDNb=)=QdALR#$$Ah|gT(RL>SYrDpr}qzOhpxb$udp;neV5vDY3>D zmj~TC-5#H?n*8m~skR;YE{P_XuVXKMZtGVd1wQij5!X1fPN{A5vD^|=B}~Yy^HHC# zeHry;rTmD7OoTo2`_`e*^U}nG+-R(}`Bi-DX9YuFk5N__Frnc$-^V{#J-kNvI=MKd zV7-pgIFp($)_nDKZu3 z0a#G_zG4Vxn7MB7#qWPfF2HHuN&Ct)-g6;bblu zDiJ$Y0DV*tzDz_y8~t<_rk5ugv3or*vr--C(Y8KZSq-DIIY-0(idN_ob?S*~&leQD z=@vBE%hJUeX3t|ACl}@Pt$aBRY@l1x)n7ZJkBbfG!7r$;w=5HP@eD~3!f<}z`m|m+ zdqKL12+TdP{b2D6ap70lIxabKVc0SFrkr=?VJdwckom54E^mJ`jLEtGWm*Z`&Oozf z+Olh#p2ktrK9gb@u<(kbj+pA#-V*Jy8B;{!A~M3-?e=C_IHd2aE0e3Z>%tljz*%FP zwTEJLvFo}bxcNGvqZ?30m|ZxkBYoL8=r-o?$L+MQO{?SUjktIg-b&k+(5|>D3x<;l zg2>5{xSS@BOivMp>dbA^dpT;n&%hGLbWE(ejl@{3N*1T=oXD)SS+}_PW*5etjtYWu zfnwc!2K>bn8W#Z3@jrnHYZQ&^LNxC5*-JLvxZo@+SmM}+@$PIO^kNYqpY*&$($s=X z$X5h~so3!h&-m{K9|u3*8*|(J8uYu)g7jWd+(VPgMHP-t6a5-qBP~=-+Uc%5_hyhE z*8Al-#2a37o+*`53*8x=O|$V`)<~O91~#NCFSMn6t;0hyR+{x}5&KhlX{I{T5hjR+ zUkOcfVam?^o3Cx$E~&25s%45~1^7pnU?$M;FAX%wv>E;59aLG)g}nV(yyJ?H5tbmK zB2yH$86g98P;A!nObElg2#y^scS$7aV|JTDcdLYk`tt(a_bcFGn9V6qGgCu1%=-Mq zP+zATyh#ixYi7b*rNgGW(R)D1zQlchl|<`wk?-4WfjnYck-6jHwe^0bc4B52xnwHL z+9_eLtZ0f;k%3QPB(^g2I4ZxNP~7Q&_f(>oIz*|sSS@%m8h&sGuJ}BdQ}3{9prwN} z!Dl10AST%dt0sxOm|mdnV2g#5C}>MglAAkt+|h=9xR1$??^ zmqx~0RBG&y=4Cu~=@}WOC1xfiW`o1SHS^Mb^PLDzHBjY;VLZM7g$n2@u#qSN9)*QB z3rtE{^%4d9QeuGJ6WcvGo+ssve4U+Zr;@odVJ#eDvB=t^jSQlPkXHyBFFaUKO(<;eT&Km3-qpbYG>IQ zWc}@2W*53fS)cU$n2czUEAa3(ke|qfOn^Y;H)2be9{s%_x7*c~x;BJw1ucaG{fy~( zr;=bL;uA_Oyr4?X&ub_8|H9KlAOm0Fz0=BG312C(-DlP(#TkdK7-wC`1QQnfwkNjh zm2wk*o>m1M8+f>$>DQyDOgrjpRx}z< z*W!#DW{!XrN=pp)%en^g+YQ?)anQFaK>1n5rMQpP3Vk_3_L*Gk^{N>4fNFhu+m)>; zN}*(|3rxRxt&H}w&IdPRl$DWjw2q`r?e2DNLt2vb>|MiGTSTZ0^a=G&OaR03XlHN! zfU@r5*yz&Nov^=>fINCO#2qokAGAYcCs{CwcNW;INmyX2KB-ug;P6D1o4cOjHi+Qg zX)3$(_I}-$FF?rWh&V5Z#oYR-Y~?hS5)agA(Bm`4?AaG|HFkS+7PI1wR%^Z5D=@|= z^=)Ovr~9Xa=D!OK0RY43yR^sCgPX22-Q zvdG84on8TN#%z{&D$gM%H-1*totj}yS3qK+jHHvGrUq_P_9srs(JnuKr;B+$9+fur z`Ti_usf2fI?W1x*L`F1q^hLC1ou;BubHJQuC9Qtj)~_-7#G5mP&U^KrxTZ-At68d^%pv_F;tu#L;YhchlZ zgXedZ{){NV*4a$8o&uqTkEvvy(i|yt>sy|mE$!RE61h=2{MS$Y%ojd}A;hv1$lz;w=gp`ttNME@(E0G+6~r5UcCpwql+Khk_oyu<@J#^cyR zp|xIB83pWhtq56bzc>sZ^*OlzT1%;TOD2R~Ya_7RD} zFSsmjCZT#hE{4m8tkvkL9n=zwX5*Rt2KR%Zvr8u)I|UGx3!WL-b(>7KMa8)W^{$0r zg62+w&;=&7=wANHQ&W#8Qlm#Gc=y^?JqQFM0@DQ^>FMbyHo(pGW8l{HWXo!2r~d4F zwyG?k52R^j(<~`9z=0NaK7SP#YJ2>CEa>42N#KrgJuk1-M7;(ZL*QXxU;yx~=u`o{ z78HgJ)a2U-{Ko-E)EyAEs_8Ol>3#~Z%M9Q<-;XgGfN`nZf3`GT{Pb^TewzQE%~Aj3 zXMHaVc2Kq`wvnNRV;vcni3lCcn-+KldzhAPSx~0WgP<)Vr;IuE&n!2>{+$@Cs?eIo8q>{+l4tSe3dL$4sA^%Wf$ll5y>4ek}j4I4`t(p#I7i!FLL z9lO6OFOqoFQi|X<$w3i!Zn7T)nDpp3lZ4a!ejPQqy`lQ8F0n#-BC=rgG;zqDNMMAF zLfBHh{=o_pgt8xq6}Mjdtc;70#qzLvv`+;cJe!zDQ`_g!*quK9h+tv7S@le}##YBJ zL!~;ys$Fd^Im@qg(Yx=5ZrGQxi2Z}DVdUa^$Z7&+trF}_8^xKZdu-ssSAI+e{$O=0 zuK6^Vf;&<&HKgA~@X4#x*>9Ve1PPlOj!G(gvXmrv*MpD3+ifjtUXUHAZCsoEyz8BE z6rW74>9EINf~rdL&H*Ky-!}n@aCtJpg&{BTY=-U@X?N8B>scFEdOZ=h&=NA8 zlqIC!rB$Q?ROmzP&+r8YphiPt%ERbWW5Cw_s*U<|dF3+4={7A=%l=MhBVULFlVOYc zy4CvTBsC^u=&?bfd%E|&b$TpaV>*IDtj5{5d?xXI$-LrRV_k2Z1w;FUE8%=X4R^9s z6#3BZ@ZpFJN>I4f;3B_^^T*l6#qWAIi)dYt&2Q`SSoxyWB)b9ugA39NUA9%N@S_w} zMv&ID9G%dx$h7PKH*1-t|6B>pB5vJzJ=LynKlWD2J+yTnGVX_<`%?!C^_9R3Tl z9E^yqByvyM0Zftvn+i`ogoi2d9n855{;q3OVmtmfh9==-hUIZUaSC#3WOOUN=~>>I z)fi$JVGIs`2;k{W{@A_ppzSNw?b&`NYZ-PL`w}58AL}?a3ixH}?GraSEw?C)q*rwZ z*G;*scumF+zI_6gB70(Tterd4p=tXjjdB>)!1)GsugsVhv}tfkV!AK9Gx!D z@=f$|{Rsfam4E6x{w3s^VOx4fZ`15>OvY&Q)(7oNA}jnwc{8qTtn=p*-qLNP&q-Y5w@pop4BDp#v$4gRy2H?Yr?z z6w+-8Im#7KHRTL?BpdB~|HlX^M9oU4#2VRX!@+F?`}S{CV3e z%(Xy`+3rgkTGA&;ows^#w_i%A;M8J~QlQmke5^M7M8zA4B0^=3aPuV19sop8SxPaJ zzYn899%o4R+)3Y`W3iu{iZ+#tN!}y*WIuC*ZnfZH=Fm)HBR(dl{S5oKhj2-NV{dm-gCz%;$q+Y zb#>1uptnhGnkff2-#f&)yDl-Q2xs3hCcIr}eT0(?<4bgJ^EZqrG>aUa;X`vfdk@L-a?%8+6AaXOh~%Jrx7OO-rX%}`9r?|+NZX2e8wRql}^`%tc$`MGK9)KZVw0st6JC;Ilbn~S z9bIh@9m*h=c~Yva7bad+$ee9EO&Vzx4vD5?xXeqA;wy_Cl26%$g(tmn3v|UD@3Lx6PIVPqRnomHFR)*kZf(m>N$YM}!sVvJLl#e!`9>%9mXGjVWLII8IrsHY z)XA%k?8TLqP_L^a01mIya`_Qcf;pq2O;FChj^%jJqKNpA5GSe&3Jh1*j+EwK?&(z% z7R1gB8-83svg2w)YR_amGGmnpNgjct%bnfbM(niaAuu{SJ0G1p5J^Nm8w^yA1KPME zojQ?YSKB1e`91{zc)|7MP8@d~vHDB(<|K2QRQ2lEwE{zF)W6@H!L;8*2LG^k|8G8a z6?LgX5(iPf;^Jh!H5~*yN6ney`(*ai^DAqSS%F_mr)m}a7%ZV|nDa*ntMPFW)knxp ze5%-&8#h`WJveI0YJCyTY(hW2*{loGNl|Y`;RjxODeG^|+SX(Ee4S?GZWX^l3ZJR;bcD?PL(F%Sa5>L6Z^fe_wTOnifPWNaq*t? zd)8rZ#>?Uz8iFMzk4zgQP0F41<<8E$v)p*gdGpd*Ul3{3Xk$UxDn1c;L5KV_Tbrem zb{5`DS-4B<=21mPO#;dSwqchHtEevA7nD9)K#UHGbd zwXySDlve6iv$2UKp$k*&PYWWhax0{$t&AE62(-xV!!B=TThqS!)%jKx>N}-0*pp zR{e`TKLf&i^nG?}9)lVY zRn}LES2LyY_}I>KrwL7?qZc*7>N&2ho>h7$(n{~KuOfnJK{{z>{k1rb%v0Pc9{yqf z$mIpI`e(1|TwEsa!6qyYckRDC)aGX3lbxMOny$I@q=-Gc-VCTtNXrcqz}y4buf5ha z0Ri1w=NyJP3X&XFd*7d>m1(=n_OR=)`QypaV#@`grBoM`AR<&iJ*nz@op{+jZBh|A z%q}mtEA2XxbV=YXXia`_d8FeI#@C#}y-b78+6MBQa8C4QZMBr)qTv>gH*h;3eIInE zYC=e1ntC5QiX3-z>gJf6!=+>X`&I%^)`lt4dMkJ9Gpx#e@NXY>%02eGidBDuYGIw+zsV-B#ZZzG>5lcxSpOtXDH-WzR{(2DE-N!)Fc@f~p)gA@z<Ztd6iwDrZ)f_-g3oF)BdF>suDtZ@Y1`^6DMb_Ab>=z`{9! zY98VYZ*V5EhZ5)Q1_LRVBw^^oQPqEu<{f3NIkU9{t;%xh1XS1b^%{s+{lU3?Y|{3h zbo?cb#plMx_CQeRAEp+e*Ydw@aepsyVjOT8Vr;f56bkE`R~ew$cdBBr;f>qCmZlX?OMy$MSv-y$P{*%c?b{Fs5lON>7!<1hP^Y}9 za`}v}QKqXA>4r)ID^W2u{uQu4B;|@~C{{_e7z*q8%nKrF;p2TnY)4TZH7R&6vZsTu$S zds%@50-|?4Btd{`g+A3qzl#vrD4-(u;oTns8dhFpQ7lFIM6kqRD!0!-eqHWD6isN) zQCy|NCmDSLvM*X``A`=s^#NbdE+k_^)xQQh%;WLs8%Z?d@^c3aZ^Y09o-FS^N90u) zgz0T}BDC2^_KqHwFx2B|$LPyXR8iVAc(~;zwVE% zmd<;!f|ZQrNXTy}B4Sq!)$k{2K!cBHJecJ!*&CYAlZ< z{jxG5y*SJ}?sKPpWURNY$JOLYH3Lf7Mpncx%kxxn;7jn2Y|^_VDqTBa^U9LM(f?H>lr zZILIOZ%FZgh)Qe^wF;_xUy+u*a&Pmmw9r(8&k0k#R-1B3cJ{$hM? ze~Va#S50-%j+cGqckR!sH?S)J literal 0 HcmV?d00001 diff --git a/src/main/java/io/phasetwo/keycloak/magic/MagicLink.java b/src/main/java/io/phasetwo/keycloak/magic/MagicLink.java index e92981e..a5f6a3e 100644 --- a/src/main/java/io/phasetwo/keycloak/magic/MagicLink.java +++ b/src/main/java/io/phasetwo/keycloak/magic/MagicLink.java @@ -1,9 +1,15 @@ package io.phasetwo.keycloak.magic; +import static org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME; + import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.Maps; +import io.phasetwo.keycloak.magic.auth.MagicLinkAuthenticatorFactory; import io.phasetwo.keycloak.magic.auth.token.MagicLinkActionToken; +import io.phasetwo.keycloak.magic.auth.token.MagicLinkContinuationActionToken; +import jakarta.mail.internet.AddressException; +import jakarta.mail.internet.InternetAddress; import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriInfo; import java.net.URI; @@ -14,7 +20,9 @@ import java.util.stream.Collectors; import lombok.extern.jbosslog.JBossLog; import org.keycloak.Config; +import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.actiontoken.DefaultActionToken; import org.keycloak.common.util.Time; import org.keycloak.email.EmailException; import org.keycloak.email.EmailTemplateProvider; @@ -104,6 +112,26 @@ public static MagicLinkActionToken createActionToken( return createActionToken(user, clientId, validity, rememberMe, authSession, true); } + public static MagicLinkContinuationActionToken createExpandedActionToken( + UserModel user, String clientId, int validityInSecs, AuthenticationSessionModel authSession) { + log.infof( + "Attempting MagicLinkContinuationAuthenticator for %s, %s, %s, %s", + user.getEmail(), clientId, authSession.getParentSession().getId(), authSession.getTabId()); + + String nonce = authSession.getClientNote(OIDCLoginProtocol.NONCE_PARAM); + int absoluteExpirationInSecs = Time.currentTime() + validityInSecs; + MagicLinkContinuationActionToken token = + new MagicLinkContinuationActionToken( + user.getId(), + absoluteExpirationInSecs, + clientId, + nonce, + authSession.getParentSession().getId(), + authSession.getTabId(), + authSession.getRedirectUri()); + return token; + } + public static MagicLinkActionToken createActionToken( UserModel user, String clientId, @@ -176,7 +204,7 @@ public static MagicLinkActionToken createActionToken( } public static String linkFromActionToken( - KeycloakSession session, RealmModel realm, MagicLinkActionToken token) { + KeycloakSession session, RealmModel realm, DefaultActionToken token) { UriInfo uriInfo = session.getContext().getUri(); // This is a workaround for situations where the realm you are using to call this (e.g. master) @@ -241,6 +269,33 @@ public static boolean sendMagicLinkEmail(KeycloakSession session, UserModel user return false; } + public static boolean sendMagicLinkContinuationEmail( + KeycloakSession session, UserModel user, String link) { + RealmModel realm = session.getContext().getRealm(); + try { + EmailTemplateProvider emailTemplateProvider = + session.getProvider(EmailTemplateProvider.class); + String realmName = getRealmName(realm); + List subjAttr = ImmutableList.of(realmName); + Map bodyAttr = Maps.newHashMap(); + bodyAttr.put("realmName", realmName); + bodyAttr.put("magicLink", link); + emailTemplateProvider + .setRealm(realm) + .setUser(user) + .setAttribute("realmName", realmName) + .send( + "magicLinkContinuationSubject", + subjAttr, + "magic-link-continuation-email.ftl", + bodyAttr); + return true; + } catch (EmailException e) { + log.error("Failed to send magic link continuation email", e); + } + return false; + } + public static boolean sendOtpEmail(KeycloakSession session, UserModel user, String code) { RealmModel realm = session.getContext().getRealm(); try { @@ -272,8 +327,7 @@ public static String getRealmName(RealmModel realm) { public static final String IDP_REDIRECTOR_PROVIDER_ID = org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticatorFactory .PROVIDER_ID; - public static final String MAGIC_LINK_PROVIDER_ID = - io.phasetwo.keycloak.magic.auth.MagicLinkAuthenticatorFactory.PROVIDER_ID; + public static final String MAGIC_LINK_PROVIDER_ID = MagicLinkAuthenticatorFactory.PROVIDER_ID; public static void realmPostCreate( KeycloakSessionFactory factory, RealmModel.RealmPostCreateEvent event) { @@ -384,4 +438,41 @@ private static void addExecutionToFlow( execution = realm.addAuthenticatorExecution(execution); } } + + public static boolean isValidEmail(String email) { + try { + InternetAddress a = new InternetAddress(email); + a.validate(); + return true; + } catch (AddressException e) { + return false; + } + } + + public static String getAttemptedUsername(AuthenticationFlowContext context) { + if (context.getUser() != null && context.getUser().getEmail() != null) { + return context.getUser().getEmail(); + } + String username = + trimToNull(context.getAuthenticationSession().getAuthNote(ATTEMPTED_USERNAME)); + if (username != null) { + if (MagicLink.isValidEmail(username)) { + return username; + } + UserModel user = context.getSession().users().getUserByUsername(context.getRealm(), username); + if (user != null && user.getEmail() != null) { + return user.getEmail(); + } + } + return null; + } + + public static String trimToNull(final String s) { + if (s == null) { + return null; + } + String trimmed = s.trim(); + if ("".equalsIgnoreCase(trimmed)) trimmed = null; + return trimmed; + } } diff --git a/src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkAuthenticator.java b/src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkAuthenticator.java index 3db11ce..5711f4b 100644 --- a/src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkAuthenticator.java +++ b/src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkAuthenticator.java @@ -4,8 +4,6 @@ import io.phasetwo.keycloak.magic.MagicLink; import io.phasetwo.keycloak.magic.auth.token.MagicLinkActionToken; -import jakarta.mail.internet.AddressException; -import jakarta.mail.internet.InternetAddress; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; import java.util.Map; @@ -36,7 +34,7 @@ public class MagicLinkAuthenticator extends UsernamePasswordForm { @Override public void authenticate(AuthenticationFlowContext context) { log.debug("MagicLinkAuthenticator.authenticate"); - String attemptedUsername = getAttemptedUsername(context); + String attemptedUsername = MagicLink.getAttemptedUsername(context); if (attemptedUsername == null) { super.authenticate(context); } else { @@ -53,11 +51,11 @@ public void action(AuthenticationFlowContext context) { MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - String email = trimToNull(formData.getFirst(AuthenticationManager.FORM_USERNAME)); + String email = MagicLink.trimToNull(formData.getFirst(AuthenticationManager.FORM_USERNAME)); // check for empty email if (email == null) { // - first check for email from previous authenticator - email = getAttemptedUsername(context); + email = MagicLink.getAttemptedUsername(context); } log.debugf("email in action is %s", email); // - throw error if still empty @@ -83,7 +81,9 @@ public void action(AuthenticationFlowContext context) { MagicLink.registerEvent(event)); // check for no/invalid email address - if (user == null || trimToNull(user.getEmail()) == null || !isValidEmail(user.getEmail())) { + if (user == null + || MagicLink.trimToNull(user.getEmail()) == null + || !MagicLink.isValidEmail(user.getEmail())) { context.getEvent().event(EventType.LOGIN_ERROR).error(Errors.INVALID_EMAIL); Response challengeResponse = challenge(context, getDefaultChallengeMessage(context), FIELD_USERNAME); @@ -153,43 +153,6 @@ private boolean is(AuthenticationFlowContext context, String propName, boolean d return v.trim().toLowerCase().equals("true"); } - private static boolean isValidEmail(String email) { - try { - InternetAddress a = new InternetAddress(email); - a.validate(); - return true; - } catch (AddressException e) { - return false; - } - } - - private String getAttemptedUsername(AuthenticationFlowContext context) { - if (context.getUser() != null && context.getUser().getEmail() != null) { - return context.getUser().getEmail(); - } - String username = - trimToNull(context.getAuthenticationSession().getAuthNote(ATTEMPTED_USERNAME)); - if (username != null) { - if (isValidEmail(username)) { - return username; - } - UserModel user = context.getSession().users().getUserByUsername(context.getRealm(), username); - if (user != null && user.getEmail() != null) { - return user.getEmail(); - } - } - return null; - } - - private static String trimToNull(final String s) { - if (s == null) { - return null; - } - String trimmed = s.trim(); - if ("".equalsIgnoreCase(trimmed)) trimmed = null; - return trimmed; - } - @Override protected boolean validateForm( AuthenticationFlowContext context, MultivaluedMap formData) { diff --git a/src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkContinuationAuthenticator.java b/src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkContinuationAuthenticator.java new file mode 100644 index 0000000..db9764f --- /dev/null +++ b/src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkContinuationAuthenticator.java @@ -0,0 +1,209 @@ +package io.phasetwo.keycloak.magic.auth; + +import static io.phasetwo.keycloak.magic.auth.util.MagicLinkConstants.SESSION_CONFIRMED; +import static io.phasetwo.keycloak.magic.auth.util.MagicLinkConstants.SESSION_EXPIRATION; +import static io.phasetwo.keycloak.magic.auth.util.MagicLinkConstants.SESSION_INITIATED; +import static io.phasetwo.keycloak.magic.auth.util.MagicLinkConstants.TIMEOUT; +import static org.keycloak.services.validation.Validation.FIELD_USERNAME; + +import io.phasetwo.keycloak.magic.MagicLink; +import io.phasetwo.keycloak.magic.auth.token.MagicLinkContinuationActionToken; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import java.time.ZonedDateTime; +import java.util.Map; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordForm; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.services.messages.Messages; +import org.keycloak.utils.StringUtil; + +@JBossLog +public class MagicLinkContinuationAuthenticator extends UsernamePasswordForm { + + @Override + public void authenticate(AuthenticationFlowContext context) { + log.debug("MagicLinkContinuationAuthenticator.authenticate"); + + if (sessionExpired(context)) { + AuthenticationSessionManager manager = new AuthenticationSessionManager(context.getSession()); + manager.removeTabIdInAuthenticationSession( + context.getRealm(), context.getAuthenticationSession()); + + context.getEvent().error(Errors.SESSION_EXPIRED); + Response challengeResponse = + challenge(context, Messages.EXPIRED_ACTION_TOKEN_NO_SESSION, FIELD_USERNAME); + context.failureChallenge( + AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, challengeResponse); + return; + } + + String attemptedUsername = MagicLink.getAttemptedUsername(context); + String sessionConfirmed = context.getAuthenticationSession().getAuthNote(SESSION_CONFIRMED); + if (StringUtil.isNotBlank(sessionConfirmed)) { + UserModel user; + if (MagicLink.isValidEmail(attemptedUsername)) { + user = context.getSession().users().getUserByEmail(context.getRealm(), attemptedUsername); + } else { + user = + context.getSession().users().getUserByUsername(context.getRealm(), attemptedUsername); + } + context.setUser(user); + context.getAuthenticationSession().setAuthenticatedUser(user); + context.success(); + } else if (attemptedUsername == null) { + super.authenticate(context); + } else { + String sessionInitiated = context.getAuthenticationSession().getAuthNote(SESSION_INITIATED); + if (StringUtil.isBlank(sessionInitiated)) { + log.debugf( + "Found attempted username %s from previous authenticator, skipping login form", + attemptedUsername); + action(context); + } else { + context.challenge(context.form().createForm("view-email-continuation.ftl")); + } + } + } + + private boolean sessionExpired(AuthenticationFlowContext context) { + String expiration = context.getAuthenticationSession().getAuthNote(SESSION_EXPIRATION); + if (StringUtil.isNotBlank(expiration)) { + ZonedDateTime expirationTime = ZonedDateTime.parse(expiration); + return expirationTime.isBefore(ZonedDateTime.now()); + } + return false; + } + + @Override + public void action(AuthenticationFlowContext context) { + log.debug("MagicLinkAuthenticator.action"); + + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + + String email = MagicLink.trimToNull(formData.getFirst(AuthenticationManager.FORM_USERNAME)); + // check for empty email + if (email == null) { + // - first check for email from previous authenticator + email = MagicLink.getAttemptedUsername(context); + } + log.debugf("email in action is %s", email); + // - throw error if still empty + if (email == null) { + context.getEvent().error(Errors.USER_NOT_FOUND); + Response challengeResponse = + challenge(context, getDefaultChallengeMessage(context), FIELD_USERNAME); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + + String clientId = context.getSession().getContext().getClient().getClientId(); + + EventBuilder event = context.newEvent(); + + UserModel user = + MagicLink.getOrCreate( + context.getSession(), + context.getRealm(), + email, + false, + false, + false, + MagicLink.registerEvent(event)); + + // check for no/invalid email address + if (user == null + || MagicLink.trimToNull(user.getEmail()) == null + || !MagicLink.isValidEmail(user.getEmail())) { + context.getEvent().event(EventType.LOGIN_ERROR).error(Errors.INVALID_EMAIL); + Response challengeResponse = + challenge(context, getDefaultChallengeMessage(context), FIELD_USERNAME); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + + log.debugf("user is %s %s", user.getEmail(), user.isEnabled()); + + // check for enabled user + if (!enabledUser(context, user)) { + return; // the enabledUser method sets the challenge + } + + int timeout = getTimeout(context, 10); + + int validityInSecs = 60 * timeout; + MagicLinkContinuationActionToken token = + MagicLink.createExpandedActionToken( + user, clientId, validityInSecs, context.getAuthenticationSession()); + String link = MagicLink.linkFromActionToken(context.getSession(), context.getRealm(), token); + boolean sent = MagicLink.sendMagicLinkContinuationEmail(context.getSession(), user, link); + log.debugf("sent email to %s? %b. Link? %s", user.getEmail(), sent, link); + + context + .getAuthenticationSession() + .setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, email); + context.getAuthenticationSession().setAuthNote(SESSION_INITIATED, "true"); + + String sessionExpiration = + ZonedDateTime.now() + .plusMinutes(timeout) + .plusSeconds(2) // clock skew + .toString(); + context.getAuthenticationSession().setAuthNote(SESSION_EXPIRATION, sessionExpiration); + + context.challenge(context.form().createForm("view-email-continuation.ftl")); + } + + @Override + protected boolean validateForm( + AuthenticationFlowContext context, MultivaluedMap formData) { + log.debug("validateForm"); + return validateUser(context, formData); + } + + @Override + protected Response challenge( + AuthenticationFlowContext context, MultivaluedMap formData) { + log.debug("challenge"); + LoginFormsProvider forms = context.form(); + if (!formData.isEmpty()) forms.setFormData(formData); + return forms.createLoginUsername(); + } + + @Override + protected Response createLoginForm(LoginFormsProvider form) { + log.debug("createLoginForm"); + return form.createLoginUsername(); + } + + @Override + protected String getDefaultChallengeMessage(AuthenticationFlowContext context) { + log.debug("getDefaultChallengeMessage"); + return context.getRealm().isLoginWithEmailAllowed() + ? Messages.INVALID_USERNAME_OR_EMAIL + : Messages.INVALID_USERNAME; + } + + private int getTimeout(AuthenticationFlowContext context, int defaultValue) { + AuthenticatorConfigModel authenticatorConfig = context.getAuthenticatorConfig(); + if (authenticatorConfig == null) return defaultValue; + + Map config = authenticatorConfig.getConfig(); + if (config == null) return defaultValue; + try { + return Integer.parseInt(config.get(TIMEOUT)); + } catch (NumberFormatException e) { + return defaultValue; + } + } +} diff --git a/src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkContinuationAuthenticatorFactory.java b/src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkContinuationAuthenticatorFactory.java new file mode 100644 index 0000000..b7a00b5 --- /dev/null +++ b/src/main/java/io/phasetwo/keycloak/magic/auth/MagicLinkContinuationAuthenticatorFactory.java @@ -0,0 +1,86 @@ +package io.phasetwo.keycloak.magic.auth; + +import com.google.auto.service.AutoService; +import io.phasetwo.keycloak.magic.auth.util.MagicLinkConstants; +import java.util.List; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +@JBossLog +@AutoService(AuthenticatorFactory.class) +public class MagicLinkContinuationAuthenticatorFactory implements AuthenticatorFactory { + public static final String PROVIDER_ID = "magic-link-continuation-form"; + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.ALTERNATIVE, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + @Override + public String getDisplayType() { + return "Magic Link continuation"; + } + + @Override + public String getHelpText() { + return "Sign in with a magic link that will be sent to your email."; + } + + @Override + public String getReferenceCategory() { + return "alternate-auth"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public List getConfigProperties() { + ProviderConfigProperty timeout = new ProviderConfigProperty(); + timeout.setType(ProviderConfigProperty.STRING_TYPE); + timeout.setName(MagicLinkConstants.TIMEOUT); + timeout.setLabel("Expiration time"); + timeout.setHelpText( + "Magic link authenticator expiration time in minutes. Default expiration period 10 minutes."); + timeout.setDefaultValue("10"); + + return List.of(timeout); + } + + @Override + public Authenticator create(KeycloakSession session) { + return new MagicLinkContinuationAuthenticator(); + } + + @Override + public void init(Config.Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/src/main/java/io/phasetwo/keycloak/magic/auth/model/MagicLinkContinuationBean.java b/src/main/java/io/phasetwo/keycloak/magic/auth/model/MagicLinkContinuationBean.java new file mode 100644 index 0000000..817fe33 --- /dev/null +++ b/src/main/java/io/phasetwo/keycloak/magic/auth/model/MagicLinkContinuationBean.java @@ -0,0 +1,20 @@ +package io.phasetwo.keycloak.magic.auth.model; + +public class MagicLinkContinuationBean { + + private final boolean sameBrowser; + private final String url; + + public MagicLinkContinuationBean(boolean sameBrowser, String url) { + this.sameBrowser = sameBrowser; + this.url = url; + } + + public boolean isSameBrowser() { + return sameBrowser; + } + + public String getUrl() { + return url; + } +} diff --git a/src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationActionToken.java b/src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationActionToken.java new file mode 100644 index 0000000..244c513 --- /dev/null +++ b/src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationActionToken.java @@ -0,0 +1,75 @@ +package io.phasetwo.keycloak.magic.auth.token; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.UUID; +import org.keycloak.authentication.actiontoken.DefaultActionToken; + +public class MagicLinkContinuationActionToken extends DefaultActionToken { + + public static final String TOKEN_TYPE = "magic-link-continuation"; + + private static final String JSON_FIELD_SESSION_ID = "sid"; + private static final String JSON_FIELD_TAB_ID = "tid"; + private static final String JSON_FIELD_REDIRECT_URI = "rdu"; + + @JsonProperty(value = JSON_FIELD_SESSION_ID) + private String sessionId; + + @JsonProperty(value = JSON_FIELD_TAB_ID) + private String tabId; + + @JsonProperty(value = JSON_FIELD_REDIRECT_URI) + private String redirectUri; + + public MagicLinkContinuationActionToken( + String userId, + int absoluteExpirationInSecs, + String clientId, + String nonce, + String sessionId, + String tabId, + String redirectUri) { + super(userId, TOKEN_TYPE, absoluteExpirationInSecs, nonce(nonce)); + this.issuedFor = clientId; + this.sessionId = sessionId; + this.tabId = tabId; + this.redirectUri = redirectUri; + } + + private MagicLinkContinuationActionToken() { + // Note that the class must have a private constructor without any arguments. This is necessary + // to deserialize the token class from JWT. + } + + static UUID nonce(String nonce) { + try { + return UUID.fromString(nonce); + } catch (Exception ignore) { + } + return null; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getTabId() { + return tabId; + } + + public void setTabId(String tabId) { + this.tabId = tabId; + } + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } +} diff --git a/src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationActionTokenHandlerFactory.java b/src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationActionTokenHandlerFactory.java new file mode 100644 index 0000000..b2920e4 --- /dev/null +++ b/src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationActionTokenHandlerFactory.java @@ -0,0 +1,33 @@ +package io.phasetwo.keycloak.magic.auth.token; + +import com.google.auto.service.AutoService; +import org.keycloak.Config; +import org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +@AutoService(ActionTokenHandlerFactory.class) +public class MagicLinkContinuationActionTokenHandlerFactory + implements ActionTokenHandlerFactory { + + public static final String PROVIDER_ID = "magic-link-continuation"; + + @Override + public void close() {} + + @Override + public MagicLinkContinuationLinkActionTokenHandler create(KeycloakSession session) { + return new MagicLinkContinuationLinkActionTokenHandler(); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public void init(Config.Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} +} diff --git a/src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationLinkActionTokenHandler.java b/src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationLinkActionTokenHandler.java new file mode 100644 index 0000000..d6af72b --- /dev/null +++ b/src/main/java/io/phasetwo/keycloak/magic/auth/token/MagicLinkContinuationLinkActionTokenHandler.java @@ -0,0 +1,107 @@ +package io.phasetwo.keycloak.magic.auth.token; + +import static io.phasetwo.keycloak.magic.auth.util.MagicLinkConstants.SESSION_CONFIRMED; +import static org.keycloak.services.util.CookieHelper.getCookie; + +import io.phasetwo.keycloak.magic.auth.model.MagicLinkContinuationBean; +import io.phasetwo.keycloak.magic.auth.util.MagicLinkConstants; +import jakarta.ws.rs.core.Cookie; +import jakarta.ws.rs.core.Response; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.authentication.actiontoken.AbstractActionTokenHandler; +import org.keycloak.authentication.actiontoken.ActionTokenContext; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.messages.Messages; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.AuthenticationSessionProvider; +import org.keycloak.sessions.RootAuthenticationSessionModel; + +/** Handles the magic link continuation action token */ +@JBossLog +public class MagicLinkContinuationLinkActionTokenHandler + extends AbstractActionTokenHandler { + + public MagicLinkContinuationLinkActionTokenHandler() { + super( + MagicLinkContinuationActionToken.TOKEN_TYPE, + MagicLinkContinuationActionToken.class, + Messages.INVALID_REQUEST, + EventType.EXECUTE_ACTION_TOKEN, + Errors.INVALID_REQUEST); + } + + @Override + public Response handleToken( + MagicLinkContinuationActionToken token, + ActionTokenContext tokenContext) { + log.infof("HandleToken for iss:%s, user:%s", token.getIssuedFor(), token.getUserId()); + UserModel user = tokenContext.getAuthenticationSession().getAuthenticatedUser(); + + final AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); + final ClientModel client = authSession.getClient(); + + user.setEmailVerified(true); + KeycloakSession session = tokenContext.getSession(); + AuthenticationSessionProvider provider = session.authenticationSessions(); + RootAuthenticationSessionModel rootAuthenticationSession = + provider.getRootAuthenticationSession(tokenContext.getRealm(), token.getSessionId()); + LoginFormsProvider loginFormsProvider = session.getProvider(LoginFormsProvider.class); + + if (rootAuthenticationSession != null) { + AuthenticationSessionModel authenticationFlowSession = + rootAuthenticationSession.getAuthenticationSession(client, token.getTabId()); + if (authenticationFlowSession != null) { + authenticationFlowSession.setAuthNote(SESSION_CONFIRMED, "true"); + + Cookie cookie = + getCookie( + session.getContext().getRequestHeaders().getCookies(), + MagicLinkConstants.AUTH_SESSION_ID); + + boolean sameBrowser = cookie != null && cookie.getValue().equals(token.getSessionId()); + MagicLinkContinuationBean magicLinkContinuationBean = + new MagicLinkContinuationBean(sameBrowser, token.getRedirectUri()); + tokenContext.getEvent().success(); + + return loginFormsProvider + .setAttribute("magicLinkContinuation", magicLinkContinuationBean) + .createForm("email-confirmation.ftl"); + } + } + + tokenContext.getEvent().error("Expired magic link continuation session!"); + return loginFormsProvider.createForm("email-confirmation-error.ftl"); + } + + @Override + public AuthenticationSessionModel startFreshAuthenticationSession( + MagicLinkContinuationActionToken token, + ActionTokenContext tokenContext) { + log.infof("startFreshAuthenticationSession %s", token.getIssuedFor()); + + ClientModel client = + tokenContext + .getSession() + .clients() + .getClientByClientId(tokenContext.getRealm(), token.getIssuedFor()); + AuthenticationSessionProvider provider = tokenContext.getSession().authenticationSessions(); + RootAuthenticationSessionModel rootAuthenticationSession = + provider.getRootAuthenticationSession(tokenContext.getRealm(), token.getSessionId()); + if (rootAuthenticationSession == null) { + AuthenticationSessionModel authSession = + tokenContext.createAuthenticationSessionForClient(token.getIssuedFor()); + authSession.setAuthNote(AuthenticationManager.INVALIDATE_ACTION_TOKEN, "true"); + return authSession; + } + + AuthenticationSessionModel authSession = + rootAuthenticationSession.createAuthenticationSession(client); + return authSession; + } +} diff --git a/src/main/java/io/phasetwo/keycloak/magic/auth/util/MagicLinkConstants.java b/src/main/java/io/phasetwo/keycloak/magic/auth/util/MagicLinkConstants.java new file mode 100644 index 0000000..e136f78 --- /dev/null +++ b/src/main/java/io/phasetwo/keycloak/magic/auth/util/MagicLinkConstants.java @@ -0,0 +1,10 @@ +package io.phasetwo.keycloak.magic.auth.util; + +public final class MagicLinkConstants { + public static final String SESSION_INITIATED = "SESSION_INITIATED"; + public static final String SESSION_EXPIRATION = "SESSION_EXPIRATION"; + public static final String SESSION_CONFIRMED = "SESSION_CONFIRMED"; + + public static final String TIMEOUT = "TIMEOUT"; + public static final String AUTH_SESSION_ID = "AUTH_SESSION_ID"; +} diff --git a/src/main/resources/theme-resources/messages/messages_en.properties b/src/main/resources/theme-resources/messages/messages_en.properties index e4ec79e..e956daa 100644 --- a/src/main/resources/theme-resources/messages/messages_en.properties +++ b/src/main/resources/theme-resources/messages/messages_en.properties @@ -5,10 +5,19 @@ magicLinkBodyHtml=

Someone requested a login link to {0}

C otpSubject=Your access code for {0} otpBody=Someone requested a one-time-password to login to {0}.\n\nCode: {1}\n\nIf you did not request this code, please ignore this email. otpBodyHtml=

Someone requested a one-time-password to login to {0}.

Code: {1}

If you did not request this code, please ignore this email.

+magicLinkContinuationSubject=Log in to {0} +magicLinkContinuationBody=Someone requested a login link to {0}.\n\nClick to log in.\n\n{1}\n\nIf you did not request this link, please ignore this email. +magicLinkContinuationBodyHtml=

Someone requested a login link to {0}

Click to log in.

If you did not request this link, please ignore this email.

+ # login magicLinkConfirmation=Check your email, and click on the link to log in! doResend=Resend +magicLinkContinuationConfirmation=Check your email, and click on the link to log in! Please do not close this tab. +magicLinkSuccessfulLogin=Authentication session confirmed. Please return to login page tab. +magicLinkFailLogin=Authentication session expired. Please close this tab and restart the login flow. +loginPage=Login page +multipleSessionsError=Multiple login sessions opened on same browser. Please close it and restart login. # admin text ext-magic-form-display-name=Magic link diff --git a/src/main/resources/theme-resources/templates/email-confirmation-error.ftl b/src/main/resources/theme-resources/templates/email-confirmation-error.ftl new file mode 100644 index 0000000..229d32a --- /dev/null +++ b/src/main/resources/theme-resources/templates/email-confirmation-error.ftl @@ -0,0 +1,6 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayRequiredFields=false displayMessage=false; section> + <#if section = "form"> + ${msg("magicLinkFailLogin")} + + diff --git a/src/main/resources/theme-resources/templates/email-confirmation.ftl b/src/main/resources/theme-resources/templates/email-confirmation.ftl new file mode 100644 index 0000000..3b73873 --- /dev/null +++ b/src/main/resources/theme-resources/templates/email-confirmation.ftl @@ -0,0 +1,9 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayRequiredFields=false displayMessage=false; section> + <#if section = "form"> + ${msg("magicLinkSuccessfulLogin")} + + <#if section = "form" && magicLinkContinuation.sameBrowser> +

${msg("loginPage")}

+ + diff --git a/src/main/resources/theme-resources/templates/html/magic-link-continuation-email.ftl b/src/main/resources/theme-resources/templates/html/magic-link-continuation-email.ftl new file mode 100644 index 0000000..e2703ec --- /dev/null +++ b/src/main/resources/theme-resources/templates/html/magic-link-continuation-email.ftl @@ -0,0 +1,4 @@ +<#import "template.ftl" as layout> +<@layout.emailLayout> +${kcSanitize(msg("magicLinkContinuationBodyHtml", realmName, magicLink))?no_esc} + diff --git a/src/main/resources/theme-resources/templates/text/magic-link-continuation-email.ftl b/src/main/resources/theme-resources/templates/text/magic-link-continuation-email.ftl new file mode 100644 index 0000000..60848a5 --- /dev/null +++ b/src/main/resources/theme-resources/templates/text/magic-link-continuation-email.ftl @@ -0,0 +1,2 @@ +<#ftl output_format="plainText"> +${msg("magicLinkContinuationBody", realmName, magicLink)} diff --git a/src/main/resources/theme-resources/templates/view-email-continuation.ftl b/src/main/resources/theme-resources/templates/view-email-continuation.ftl new file mode 100644 index 0000000..ab51677 --- /dev/null +++ b/src/main/resources/theme-resources/templates/view-email-continuation.ftl @@ -0,0 +1,23 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayRequiredFields=false displayMessage=false; section> + <#if section = "header"> +
+ + + + +
+ <#elseif section = "form"> + ${msg("magicLinkContinuationConfirmation")} + + +